Compare commits

...
70 Commits
Author SHA1 Message Date
Gani Georgiev 44bf55097a updated changelogs 2026-04-27 09:30:32 +03:00
Gani Georgiev 338d672bee updated ui/dist 2026-04-27 09:04:38 +03:00
Gani Georgiev 5bd9d87bad reorder editor buttons to avoid dropdowns text wrapping 2026-04-27 08:47:14 +03:00
Gani Georgiev 6ba78d5218 updated gitlab userinfo doc reference 2026-04-27 08:03:20 +03:00
Gani Georgiev 260bd59c5b updated jstypes 2026-04-27 07:57:43 +03:00
Gani Georgiev 006566478a added explicit gitlab confirmed_at check 2026-04-27 07:55:51 +03:00
Gani Georgiev 419f335f5b various minor ui fixes 2026-04-27 01:13:08 +03:00
Gani Georgiev 326f150db2 added more tests for internal record hooks 2026-04-26 20:47:47 +03:00
Gani Georgiev 1c86addc4c [#7665] added BaseURL to the ghupdate plugin configuration 2026-04-26 20:47:02 +03:00
Gani Georgiev 494f47efb8 bumped go deps 2026-04-26 16:50:24 +03:00
Gani Georgiev 555a4f1a1e lowered the default mfa duration and reorganized internal record pre/post handling 2026-04-26 16:46:16 +03:00
Gani Georgiev 37b258810a updated gitea displayName 2026-04-26 14:23:23 +03:00
Gani Georgiev ca7cf1162f added App.DeleteAllExternalAuthsByRecord 2026-04-26 11:40:09 +03:00
Gani Georgiev dddb0a029f updated bitbucket,github and gitea oauth2 providers 2026-04-25 17:51:28 +03:00
Gani Georgiev 5d55fc18ee added dummy bcrypt check 2026-04-25 16:16:23 +03:00
Gani Georgiev 449e5af590 adjust dark text color 2026-04-25 11:41:09 +03:00
Gani Georgiev 1e460d3f96 updated changelog and rebuild ui/dist 2026-04-24 22:27:26 +03:00
Gani Georgiev b5030ddfa1 [#7664] fixed codeEditor not firing the change and input events on autocomplete selection 2026-04-24 22:08:58 +03:00
Gani Georgiev fbeb09c40b [#7660] added missing type:button attribute and replaced form tag with div to minimize future regressions 2026-04-24 15:42:47 +03:00
Gani Georgiev 69cdda4bf3 [#7659] fixed SMTP IPv6 format 2026-04-23 21:25:58 +03:00
Gani Georgiev e708f39e1b updated erd styles 2026-04-23 21:18:32 +03:00
Gani Georgiev 52eccb3aac fade non-focused erd tables 2026-04-23 17:43:15 +03:00
Gani Georgiev 1d593476b0 updated view autocomplete keywords 2026-04-23 17:32:07 +03:00
Gani Georgiev 8a04904de1 fixed godoc example typo 2026-04-23 16:57:26 +03:00
Gani Georgiev a3ac674f36 removed title loader to minimize layout jumps 2026-04-23 14:12:59 +03:00
Gani Georgiev ae7041a889 preload the record preview to minimize content jumps 2026-04-23 00:37:05 +03:00
Gani Georgiev 257f03e1fa removed lazy tinymce mount since the relation are now preloaded 2026-04-23 00:01:43 +03:00
Gani Georgiev 3566ba3729 exclude expand from the record draft 2026-04-22 23:15:46 +03:00
Gani Georgiev a6002c4622 optimized record upsert panel loading to minimize layout jumps 2026-04-22 23:02:56 +03:00
Gani Georgiev 2ddf161314 renamed list-group to list-content for consistency with the others 2026-04-22 17:59:11 +03:00
Gani Georgiev b15f358fc9 [#7655] added backups list scroll container 2026-04-22 17:22:22 +03:00
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
831 changed files with 58334 additions and 55075 deletions
+3 -3
View File
@@ -16,14 +16,14 @@ 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
+98
View File
@@ -1,3 +1,101 @@
## v0.37.4
- Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)).
- Optimized record upsert and preview modals data loading to minimize layout jumps.
- Fixed SMTP IPv6 network address format ([#7659](https://github.com/pocketbase/pocketbase/issues/7659)).
- Fixed autocomplete selection not properly updating the underlying input value ([#7664](https://github.com/pocketbase/pocketbase/issues/7664)).
- Added `ghupdate.BaseURL` config option ([#7665](https://github.com/pocketbase/pocketbase/issues/7665)).
- Added dummy bcrypt password check for the failure auth path to minimize enumeration timing attacks when registrations are disabled.
- Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662); thanks @Alardiians for reporting it privately).
- Bumped Go and npm dependencies.
## v0.37.3
- Fixed total count load on page back/forward navigation.
- Fixed `editor` floating dialogs position when scrolling ([#7653](https://github.com/pocketbase/pocketbase/issues/7653)).
- Enabled text wrapping for the API rule fields.
- Added view query sample loading indicator.
- Other minor light UI contrast and styles improvements.
## v0.37.2
- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Slightly adjusted the dark theme colors for better readability ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Removed unnecessary tags stripping from the displayed log attributes ([#7649](https://github.com/pocketbase/pocketbase/issues/7649)).
- Workarounded Safari freeze caused by a buggy CSS popover property ([#7650](https://github.com/pocketbase/pocketbase/issues/7650)).
## v0.37.1
- Minor UI bugfixes:
- Fixed `number` field input values normalization ([#7646](https://github.com/pocketbase/pocketbase/issues/7646)).
- Allow opening collections in new tab with middle click.
- Show collection name in the page title on initial load.
## v0.37.0
- New UI rewritten from scratch and with support for external customization in mind.
> Note that as explained in [#7612](https://github.com/pocketbase/pocketbase/discussions/7612) the new UI kit and extensions APIs will intentionally remain undocumented until "Stage 2 completion" _(there no ETAs)_.
The new UI also introduced several other small improvements:
- ~2MB smaller bundle size.
- Dark mode and theming support.
- Basic responsive/mobile support _(it is far from perfect but certainly more usable than before)_.
- Help text option for the collection fields.
- Lifted the max nested level restriction of presentable relations _(children are lazy loaded)_.
- Lighter rules autocomplete.
- Live view query preview.
- Insert of an audio/video embed tag in the richtext editor from a collection file.
- Option to bulk export records as JSON.
- Local search history for all searchbars.
- API rules overview across all collections.
- Very basic ERD-like visualization for the collections structure and relations.
- New stepped logs chart visualization with panning support.
- `listAuthMethods()` (aka. `/api/collection/{col}/auth-methods`) now returns the OAuth2 provider logo for each provider as inlined SVG string in its response data.
_⚠️ Note that if your app for whatever reason rely on the dashboard OAuth2 logos available under `/_/images/oauth2/*` they are still available for now but will be removed in future versions and it is recommended to use the new inline SVGs!_
- Added optional `no_ui` build tag to exclude the UI from bundling with the executable ([#7548](https://github.com/pocketbase/pocketbase/issues/7548)).
```sh
go build -tags no_ui
```
- Exported the internal JSVM bind functions ([#7600](https://github.com/pocketbase/pocketbase/discussions/7600)).
```go
jsvm.BindCore(vm)
jsvm.BindDbx(vm)
jsvm.BindSecurity(vm)
jsvm.BindOS(vm)
jsvm.BindFilepath(vm)
jsvm.BindHTTP(vm)
jsvm.BindFilesystem(vm)
jsvm.BindForms(vm)
jsvm.BindMails(vm)
jsvm.BindApis(vm)
```
- Updated `modernc.org/sqlite` to v1.49.1 (SQLite 3.53.0).
## v0.36.9
- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
+8
View File
@@ -2,6 +2,14 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.42
- (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- (_Backported from v0.37.4_) ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662)).
## v0.22.41
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
+15 -12
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.25+ (for making changes in the Go code)
- Node 18+ (for making changes in the Admin UI)
- 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.
+7 -4
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>
@@ -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.
+5 -1
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
}
+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)
+1 -1
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"
+2 -2
View File
@@ -85,8 +85,8 @@ 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}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 500, false, 200},
{"/rate/a", 800, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
+40 -1
View File
@@ -111,12 +111,51 @@ func TestRecordConfirmEmailChange(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnRecordValidate": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByEmail("users", "change@example.com")
user, err := app.FindAuthRecordByEmail("users", "change@example.com")
if err != nil {
t.Fatalf("Expected to find user with email %q, got error: %v", "change@example.com", err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+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":`,
@@ -114,11 +114,18 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordValidate": 1,
// ---
"OnModelDelete": 2, // pre-existing OAuth2 links
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -151,6 +158,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
@@ -221,6 +237,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -251,11 +276,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
oldTokenKey := user.TokenKey()
// ensure that the user is already verified
user.SetVerified(true)
if err := app.Save(user); err != nil {
t.Fatalf("Failed to update user verified state")
}
// resave with the old token key since the verified change above
// would refresh it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByToken(
@@ -105,6 +105,51 @@ func TestRecordConfirmVerification(t *testing.T) {
"OnRecordValidate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+23 -6
View File
@@ -338,26 +338,43 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
e.Auth.Id == e.Record.Id &&
e.Auth.Collection().Id == e.Record.Collection().Id
// set random password for users with unverified email
// (this is in case a malicious actor has registered previously with the user email)
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
e.Record.SetRandomPassword()
// prevent pre-hijacking with password auth
//
// reset the unverified user password in case the record was precreated by a malicious actor
if !isLoggedAuthRecord && !e.Record.Verified() {
needUpdate = true
e.Record.SetRandomPassword()
}
// prevent pre-hijacking with different OAuth2 provider
//
// delete all other previous OAuth2 record links for the cases
// when the user was precreated by malicious OAuth2 auth with custom payload data
//
// while this would be also done automatically on unverified -> verified upgrade,
// doing it manually here ensures that a single unverified record could have
// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
if !e.Record.Verified() {
err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
optExternalAuth = nil // clear to allow recreate below
}
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if e.Record.Email() == "" && e.OAuth2User.Email != "" {
e.Record.SetEmail(e.OAuth2User.Email)
needUpdate = true
e.Record.SetEmail(e.OAuth2User.Email)
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
e.Record.SetVerified(true)
needUpdate = true
e.Record.SetVerified(true)
}
if needUpdate {
+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
+233 -13
View File
@@ -178,6 +178,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatal(err)
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Id != ea.Id {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
@@ -213,12 +227,12 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnModelCreate": 2, // user + recreated external auth
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -227,8 +241,15 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelValidate": 2, // create + update
"OnRecordValidate": 2,
"OnModelDelete": 3, // pre-existing external auths
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
// ---
"OnModelValidate": 3, // user create/update + recreated external auth
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -248,6 +269,24 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
@@ -343,7 +382,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to be valid", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -353,7 +392,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "link by email",
Name: "link by email (unverified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -376,6 +415,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
@@ -432,6 +485,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -449,6 +509,145 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
Name: "link by email (verified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected user %q to be verified", user.Email())
}
// ensure that the old password works
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id", Email: "test3@example.com"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that they are not deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test3@example.com"`,
`"id":"bgs820n361vj1qd"`,
`"id":"test_id"`,
`"verified":true`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 2, // authOrigins + externalAuths
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelValidate": 2, // authOrigins + externalAuths
"OnRecordValidate": 2,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
var hasTestEA = false
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) <= 1 {
t.Fatalf("Expected to have 2+ ExternalAuth records, got %d (%v)", len(externalAuths), err)
}
for _, rel := range externalAuths {
if rel.Provider() == "test" {
hasTestEA = true
break
}
}
if !hasTestEA {
t.Fatal("Expected test external auth to be linked")
}
},
},
{
@@ -531,6 +730,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 2,
"OnRecordValidate": 2,
},
@@ -541,7 +747,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -652,6 +858,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -662,7 +875,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -758,6 +971,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -768,7 +988,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
+20 -10
View File
@@ -65,28 +65,38 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
// ---
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
otpId := e.OTP.Id
otpSentTo := e.OTP.SentTo()
// eagerly delete the OTP to avoid unnecessery double delete model hook calls
// triggered by the password change below
err := e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
// update the user email verified state in case the OTP originate from an email address matching the current record one
//
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
e.Record.SetVerified(true)
err = e.App.Save(e.Record)
if err != nil {
// this is technically not required but we enforce password
// reset on verified upgrades in case the OTP is used on its own
// since this makes it less error prone to pre-hijacking attacks
if !e.Record.Collection().MFA.Enabled {
e.Record.SetRandomPassword()
}
if err := e.App.Save(e.Record); err != nil {
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
"error", err,
"otpId", e.OTP.Id,
"otpId", otpId,
"recordId", e.Record.Id,
)
}
}
// try to delete the used otp
err = e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+43 -7
View File
@@ -327,6 +327,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if user.Verified() {
t.Fatal("Expected the user to remain unverified because sentTo != email")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -364,6 +373,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// verify that there are at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auth")
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -388,10 +406,10 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
// OTP delete
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteSuccess": 1,
// record OTP + 2 ExternalAuths delete
"OnModelDelete": 3,
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
// user verified update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -401,9 +419,9 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
@@ -417,6 +435,24 @@ func TestRecordAuthWithOTP(t *testing.T) {
if !user.Verified() {
t.Fatal("Expected the user to be marked as verified")
}
// ensure that all pre-existing OTPs are cleared
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) > 0 {
t.Fatalf("Expected all OTPs to be cleared, found %d", len(otps))
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+20
View File
@@ -85,6 +85,11 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return e.App.OnRecordAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordRequestEvent) error {
if e.Record == nil || !e.Record.ValidatePassword(e.Password) {
// dummy password check to minimize enumeration side-channel attacks
if e.Record == nil {
dummyPasswordCheck(e.App, e.Collection)
}
return e.BadRequestError("Failed to authenticate.", errors.New("invalid login credentials"))
}
@@ -115,6 +120,21 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
)
}
// dummy password check to minimize side-channel attacks
// (performed with the collection configured field cost)
func dummyPasswordCheck(app core.App, collection *core.Collection) {
record := &core.Record{}
// find any random existing record
err := app.RecordQuery(collection).Limit(1).One(record)
if err != nil {
return
}
// the value and result doesn't matter, we just need a constant-time check
_ = record.ValidatePassword("")
}
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
return nil, errors.New("invalid identity field " + field)
+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)
})
})
+14
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)
@@ -493,6 +502,11 @@ type App interface {
// ExternalAuth model that satisfies the non-nil expression.
FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error)
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
DeleteAllExternalAuthsByRecord(authRecord *Record) error
// ---------------------------------------------------------------
// FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
+1 -1
View File
@@ -60,7 +60,7 @@ func (m *Collection) setDefaultAuthOptions() {
},
MFA: MFAConfig{
Enabled: false,
Duration: 1800, // 30min
Duration: 600, // 10min
},
OTP: OTPConfig{
Enabled: false,
+28 -26
View File
@@ -817,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"}`,
},
}
@@ -1576,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
}
// -------------------------------------------------------------------
+38
View File
@@ -137,4 +137,42 @@ func (app *BaseApp) registerExternalAuthHooks() {
},
Priority: 99,
})
// delete all pre-existing external auths on verified upgrade
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
if !e.Record.Collection().IsAuth() {
return e.Next()
}
hasUpgradedVerified := !e.Record.Original().IsNew() && !e.Record.Original().Verified() && e.Record.Verified()
if !hasUpgradedVerified {
return e.Next()
}
originalApp := e.App
return e.App.RunInTransaction(func(txApp App) error {
e.App = txApp
defer func() { e.App = originalApp }()
externalAuths, err := txApp.FindAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
if len(externalAuths) > 0 {
// delete all pre-existing external auths
if err := txApp.DeleteAllExternalAuthsByRecord(e.Record); err != nil {
return err
}
// force refresh tokens reset (if not already)
e.Record.RefreshTokenKey()
}
return e.Next()
})
},
Priority: 99,
})
}
+101
View File
@@ -308,3 +308,104 @@ func TestExternalAuthValidateHook(t *testing.T) {
})
}
}
func TestExternalAuthClearOnVerfiedUpgrade(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
t.Run("unverified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
t.Run("unverified->verified", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
user.SetVerified(true)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey == user.TokenKey() {
t.Fatal("Expected tokenKey to be renewed")
}
externalAuths, err = app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) != 0 {
t.Fatalf("Expected all user external auths to be deleted, found %d (%v)", len(externalAuths), err)
}
})
t.Run("verified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatal("Expected user to be verified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
}
+24
View File
@@ -1,6 +1,8 @@
package core
import (
"errors"
"github.com/pocketbase/dbx"
)
@@ -59,3 +61,25 @@ func (app *BaseApp) FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalA
return model, nil
}
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
func (app *BaseApp) DeleteAllExternalAuthsByRecord(authRecord *Record) error {
models, err := app.FindAllExternalAuthsByRecord(authRecord)
if err != nil {
return err
}
var errs []error
for _, m := range models {
if err := app.Delete(m); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
+66
View File
@@ -2,6 +2,7 @@ package core_test
import (
"fmt"
"slices"
"testing"
"github.com/pocketbase/dbx"
@@ -174,3 +175,68 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
})
}
}
func TestDeleteAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
if err != nil {
t.Fatal(err)
}
client2, err := testApp.FindAuthRecordByEmail("clients", "test2@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
record *core.Record
deletedIds []string
}{
{demo1, nil}, // non-auth record
{user1, []string{"dlmflokuq1xl342", "clmflokuq1xl341"}},
{client1, []string{"f1z5b3843pzc964"}},
{client2, nil},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
deletedIds := []string{}
app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
deletedIds = append(deletedIds, e.Record.Id)
return e.Next()
})
err := app.DeleteAllExternalAuthsByRecord(s.record)
if err != nil {
t.Fatal(err)
}
if len(deletedIds) != len(s.deletedIds) {
t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
}
for _, id := range s.deletedIds {
if !slices.Contains(deletedIds, id) {
t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
}
}
})
}
}
+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"}]`,
},
}
+6 -5
View File
@@ -3,6 +3,7 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -141,11 +142,11 @@ func (app *BaseApp) registerMFAHooks() {
if old != new {
err = e.App.DeleteAllMFAsByRecord(e.Record)
if err != nil {
e.App.Logger().Warn(
"Failed to delete all previous mfas",
"error", err,
"recordId", e.Record.Id,
"collectionId", e.Record.Collection().Id,
return fmt.Errorf(
"[%s] failed to delete all previos MFAs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
+61
View File
@@ -300,3 +300,64 @@ func TestMFAValidateHook(t *testing.T) {
})
}
}
func TestMFAClearOnPasswordChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
mfasToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range mfasToCreate {
for range total {
mfa := core.NewMFA(app)
mfa.SetCollectionRef(user.Collection().Id)
mfa.SetRecordRef(user.Id)
mfa.SetMethod(core.MFAMethodPassword)
if err := app.Save(mfa); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.SetRandomPassword()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedMFAs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedMFAs {
mfas, err := app.FindAllMFAsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(mfas) != expected {
t.Fatalf("Expected %d MFAs, got %d", expected, len(mfas))
}
}
}
+27
View File
@@ -3,8 +3,10 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -124,4 +126,29 @@ func (app *BaseApp) registerOTPHooks() {
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
}
})
// delete all record OTPs on tokenKey change to minimize the risk of hijacking attacks
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
err := e.Next()
if err != nil || !e.Record.Collection().IsAuth() {
return err
}
if !e.Record.Original().IsNew() && e.Record.Original().TokenKey() != e.Record.TokenKey() {
err := e.App.DeleteAllOTPsByRecord(e.Record)
if err != nil {
return fmt.Errorf(
"[%s] failed to delete all previos OTPs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
return nil
},
Priority: 99,
})
}
+61
View File
@@ -300,3 +300,64 @@ func TestOTPValidateHook(t *testing.T) {
})
}
}
func TestOTPClearOnTokenKeyChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
otpsToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range otpsToCreate {
for range total {
otp := core.NewOTP(app)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.RefreshTokenKey()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedOTPs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedOTPs {
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) != expected {
t.Fatalf("Expected %d OTPs, got %d", expected, len(otps))
}
}
}
+3 -2
View File
@@ -1428,13 +1428,13 @@ func onRecordValidate(e *RecordEvent) error {
func onRecordSaveExecute(e *RecordEvent) error {
if e.Record.Collection().IsAuth() {
// ensure that the token key is regenerated on password change or email change
if !e.Record.IsNew() {
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
if err != nil {
return err
}
// ensure that the token key is regenerated on password change or email change
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
lastSavedRecord.Email() != e.Record.Email()) {
@@ -1442,7 +1442,8 @@ func onRecordSaveExecute(e *RecordEvent) error {
}
}
// cross-check that the auth record id is unique across all auth collections.
// loosely cross-check that the auth record id is unique across all auth collections
// to minimize impact of mistakes in API rules when multiple auth collections are used
authCollections, err := e.App.FindAllCollections(CollectionTypeAuth)
if err != nil {
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
+7
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,
@@ -513,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"`
@@ -523,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)),
+33 -2
View File
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
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":{"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}}`
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)
}
@@ -93,6 +93,8 @@ func TestSettings_DBExport(t *testing.T) {
}
func TestSettingsMerge(t *testing.T) {
t.Parallel()
s1 := &core.Settings{}
s1.Meta.AppURL = "app_url" // should be unset
@@ -126,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
}
func TestSettingsClone(t *testing.T) {
t.Parallel()
s1 := &core.Settings{}
s1.Meta.AppName = "test_name"
@@ -156,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
}
func TestSettingsMarshalJSON(t *testing.T) {
t.Parallel()
settings := &core.Settings{}
// control fields
@@ -174,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)
@@ -230,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
}
func TestMetaConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.MetaConfig
@@ -248,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",
@@ -263,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
{
"valid data",
core.MetaConfig{
AccentColor: "#ffffff",
AppName: "test",
AppURL: "https://example.com",
SenderName: "test",
@@ -282,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
}
func TestLogsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.LogsConfig
@@ -314,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
}
func TestSMTPConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.SMTPConfig
@@ -373,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
}
func TestS3ConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.S3Config
@@ -444,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
}
func TestBackupsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.BackupsConfig
@@ -499,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
}
func TestBatchConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.BatchConfig
@@ -554,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
}
func TestRateLimitsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.RateLimitsConfig
@@ -699,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
}
func TestRateLimitsFindRateLimitRule(t *testing.T) {
t.Parallel()
limits := core.RateLimitsConfig{
Rules: []core.RateLimitRule{
{Label: "abc"},
@@ -753,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
}
func TestRateLimitRuleValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
rule core.RateLimitRule
@@ -860,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
}
func TestRateLimitRuleDurationTime(t *testing.T) {
t.Parallel()
scenarios := []struct {
rule core.RateLimitRule
expected time.Duration
@@ -880,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)
}
+5 -5
View File
@@ -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.49.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0
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.48.2
modernc.org/sqlite v1.50.0
)
require (
@@ -43,7 +43,7 @@ require (
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.70.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+14 -14
View File
@@ -82,16 +82,16 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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=
@@ -112,10 +112,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
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.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
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=
@@ -124,8 +124,8 @@ 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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
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=
@@ -134,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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/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.48.2"
expectedLibcVersion = "v1.70.0"
expectedDriverVersion = "v1.50.0"
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.
+16 -10
View File
@@ -46,6 +46,12 @@ type Config struct {
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// BaseURL is the base URL of the GitHub API (or similar compatible)
// used to fetch the latest releases information.
//
// Defaults to "https://api.github.com".
BaseURL string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
@@ -82,6 +88,12 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p.config.ArchiveExecutable = "pocketbase"
}
if p.config.BaseURL == "" {
p.config.BaseURL = "https://api.github.com"
} else {
p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/")
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
@@ -145,12 +157,9 @@ func (p *plugin) updateCmd() *cobra.Command {
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
latest, err := fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
p.config.Owner,
p.config.Repo,
)
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", p.config.BaseURL, p.config.Owner, p.config.Repo)
latest, err := fetchLatestRelease(p.config.Context, p.config.HttpClient, url)
if err != nil {
return err
}
@@ -260,11 +269,8 @@ func (p *plugin) update(withBackup bool) error {
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
owner string,
repo string,
url string,
) (*release, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
+45 -10
View File
@@ -289,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
@@ -659,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)
@@ -682,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)
@@ -693,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)
@@ -736,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)
@@ -757,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)
@@ -778,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)
@@ -804,14 +829,20 @@ 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)
@@ -850,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
+22 -19
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)
+36 -4
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",
@@ -131,7 +137,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -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",
@@ -307,7 +319,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -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",
@@ -572,7 +590,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -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",
@@ -751,7 +775,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -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"},
+20 -1
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
@@ -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)
}
}
+6 -3
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"},
@@ -118,8 +120,9 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
expected := struct {
Values []struct {
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
IsConfirmed bool `json:"is_confirmed"`
} `json:"values"`
}{}
if err := json.Unmarshal(data, &expected); err != nil {
@@ -127,7 +130,7 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
}
for _, v := range expected.Values {
if v.IsPrimary {
if v.IsPrimary && v.IsConfirmed {
return v.Email, nil
}
}
+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"},
+2
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"},
+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"},
+67 -7
View File
@@ -3,6 +3,9 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
@@ -15,10 +18,10 @@ func init() {
var _ Provider = (*Gitea)(nil)
// NameGitea is the unique name of the Gitea provider.
// NameGitea is the unique name of the Gitea/Forgejo provider.
const NameGitea string = "gitea"
// Gitea allows authentication via Gitea OAuth2.
// Gitea allows authentication via Gitea/Forgejo OAuth2.
type Gitea struct {
BaseProvider
}
@@ -27,7 +30,9 @@ type Gitea struct {
func NewGiteaProvider() *Gitea {
return &Gitea{BaseProvider{
ctx: context.Background(),
displayName: "Gitea",
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/Forgejo",
pkce: true,
scopes: []string{"read:user", "user:email"},
authURL: "https://gitea.com/login/oauth/authorize",
@@ -36,9 +41,9 @@ func NewGiteaProvider() *Gitea {
}}
}
// FetchAuthUser returns an AuthUser instance based on Gitea's user api.
// FetchAuthUser returns an AuthUser instance based on Gitea/Forgejo's user api.
//
// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent
// API reference: https://codeberg.org/api/swagger#/user/userGetCurrent
func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -53,26 +58,81 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Active bool `json:"active"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if !extracted.Active {
return nil, errors.New("user account is not active")
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, fmt.Errorf("failed to fetch primary email: %w", err)
}
user.Email = email
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://codeberg.org/api/swagger#/user/userListEmails
func (p *Gitea) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 {
return "", nil
}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
emails := []struct {
Email string
Verified bool
Primary bool
}{}
if err := json.Unmarshal(content, &emails); err != nil {
return "", err
}
// extract the verified primary email
for _, email := range emails {
if email.Verified && email.Primary {
return email.Email, nil
}
}
return "", nil
}
+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"},
+14 -15
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"},
@@ -40,7 +42,7 @@ func NewGithubProvider() *Github {
// FetchAuthUser returns an AuthUser instance based the Github's user api.
//
// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
// API reference: https://docs.github.com/en/rest/users/users?apiVersion=2026-03-10#get-the-authenticated-user
func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -53,9 +55,8 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
@@ -67,7 +68,6 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -76,27 +76,26 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
// in case user has set "Keep my email address private", send an
// **optional** API request to retrieve the verified primary email
if user.Email == "" {
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
// always send a primary email request even though the email is
// returned in the userinfo endpoint since the API may change and
// enterprise setups may have configuration that could allow unverified emails
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the verified
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28
func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
func (p *Github) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
+15 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
@@ -27,6 +28,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"},
@@ -38,7 +41,7 @@ func NewGitlabProvider() *Gitlab {
// FetchAuthUser returns an AuthUser instance based the Gitlab's user api.
//
// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin
// API reference: https://docs.gitlab.com/api/users/#retrieve-the-current-user
func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -51,11 +54,12 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
ConfirmedAt string `json:"confirmed_at"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
@@ -65,7 +69,6 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -74,5 +77,10 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
confirmedAt, err := time.Parse(time.RFC3339, extracted.ConfirmedAt)
if err == nil && !confirmedAt.IsZero() {
user.Email = extracted.Email
}
return user, nil
}
+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"},

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