merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions
+5
View File
@@ -1,3 +1,8 @@
## v0.37.0 (WIP)
- @todo
## v0.36.9 ## v0.36.9
- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135). - Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
+15 -12
View File
@@ -1,17 +1,20 @@
# Contributing to PocketBase # Contributing to PocketBase
Thanks for taking the time to improve PocketBase! > [!IMPORTANT]
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
> If you stumble on an 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. This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code) - [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 ## Prerequisites
- Go 1.25+ (for making changes in the Go code) - 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: 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` 1. Navigate to `examples/base`
2. Run `go run main.go serve` 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:** **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 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 1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies 2. Run `npm install` to install the node dependencies
@@ -70,13 +73,13 @@ To start the Admin UI:
npm run dev 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] > [!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"> <p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener"> <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/3FTfaXH.png" alt="PocketBase - open source backend in 1 file" />
</a> </a>
</p> </p>
@@ -146,10 +146,13 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md) - [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues) - [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. Please refrain creating PRs for _new features_ without previously discussing the implementation details.
But 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. 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. 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). 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 an 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. // StaticWildcardParam is the name of Static handler wildcard parameter.
const StaticWildcardParam = "path" 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) { func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) { pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
event := new(core.RequestEvent) event := new(core.RequestEvent)
@@ -34,6 +34,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(securityHeaders()) pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize)) pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
// API routes
apiGroup := pbRouter.Group("/api") apiGroup := pbRouter.Group("/api")
bindSettingsApi(app, apiGroup) bindSettingsApi(app, apiGroup)
bindCollectionApi(app, apiGroup) bindCollectionApi(app, apiGroup)
@@ -47,6 +48,9 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindRealtimeApi(app, apiGroup) bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup) bindHealthApi(app, apiGroup)
// UI routes
bindUIExtensions(app)
return pbRouter, nil return pbRouter, nil
} }
+87
View File
@@ -3,10 +3,12 @@ package apis
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security" "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.DELETE("/{collection}/truncate", collectionTruncate)
subGroup.PUT("/import", collectionsImport) subGroup.PUT("/import", collectionsImport)
subGroup.GET("/meta/scaffolds", collectionScaffolds) 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 { func collectionsList(e *core.RequestEvent) error {
@@ -207,3 +213,84 @@ func collectionScaffolds(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, collections) 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"`, `"type":"base"`,
`"system":false`, `"system":false`,
// ensures that id field was prepended // 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{ ExpectedEvents: map[string]int{
"*": 0, "*": 0,
@@ -585,7 +585,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"verified"`, `"name":"verified"`,
`"duration":123`, `"duration":123`,
// should overwrite the user required option but keep the min value // 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{ NotExpectedContent: []string{
`"secret":"`, `"secret":"`,
@@ -751,7 +751,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new", "name":"new",
"type":"view", "type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery":"invalid" "viewQuery":"select '123' as abc"
}`), }`),
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -780,7 +780,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new", "name":"new",
"type":"view", "type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "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{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -789,7 +789,7 @@ func TestCollectionCreate(t *testing.T) {
ExpectedContent: []string{ ExpectedContent: []string{
`"name":"new"`, `"name":"new"`,
`"type":"view"`, `"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{ ExpectedEvents: map[string]int{
"*": 0, "*": 0,
@@ -1262,7 +1262,7 @@ func TestCollectionUpdate(t *testing.T) {
Body: strings.NewReader(`{ Body: strings.NewReader(`{
"name":"view2_update", "name":"view2_update",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "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{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -1584,3 +1584,187 @@ func TestCollectionTruncate(t *testing.T) {
scenario.Test(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.", Message: "API is healthy.",
} }
// @todo evaluate whether it is worth removing the extra info from the health endpoint
if e.HasSuperuserAuth() { if e.HasSuperuserAuth() {
resp.Data = make(map[string]any, 3) resp.Data = make(map[string]any, 3)
resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup) 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/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils" "github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/ui"
) )
// DefaultInstallerFunc is the default PocketBase installer function. // DefaultInstallerFunc is the default PocketBase installer function.
@@ -22,13 +23,18 @@ import (
// //
// See https://github.com/pocketbase/pocketbase/discussions/5814. // See https://github.com/pocketbase/pocketbase/discussions/5814.
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error { 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) token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
if err != nil { if err != nil {
return err return err
} }
// launch url (ignore errors and always print a help text as fallback) // 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) _ = 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.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url) color.New(color.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) 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 ( const (
DefaultBodyLimitMiddlewareId = "pbBodyLimit" DefaultBodyLimitMiddlewareId = "pbBodyLimit"
+10 -1
View File
@@ -34,6 +34,7 @@ type oauth2Response struct {
type providerInfo struct { type providerInfo struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Logo string `json:"logo"`
State string `json:"state"` State string `json:"state"`
AuthURL string `json:"authURL"` AuthURL string `json:"authURL"`
@@ -68,7 +69,14 @@ func (amr *authMethodsResponse) fillLegacyFields() {
amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username") amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
if amr.OAuth2.Enabled { 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{ info := providerInfo{
Name: config.Name, Name: config.Name,
DisplayName: provider.DisplayName(), DisplayName: provider.DisplayName(),
Logo: provider.Logo(),
State: security.RandomString(30), State: security.RandomString(30),
} }
+2
View File
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
`"providers":[{`, `"providers":[{`,
`"name":"google"`, `"name":"google"`,
`"name":"gitlab"`, `"name":"gitlab"`,
`"logo":"\u003csvg`,
`"logo":""`, // for the legacy fields
`"state":`, `"state":`,
`"displayName":`, `"displayName":`,
`"codeVerifier":`, `"codeVerifier":`,
+33 -11
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/ui"
) )
const ( const (
@@ -28,17 +29,12 @@ type oauth2RedirectData struct {
} }
func oauth2SubscriptionRedirect(e *core.RequestEvent) error { func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
redirectStatusCode := http.StatusTemporaryRedirect
if e.Request.Method != http.MethodGet {
redirectStatusCode = http.StatusSeeOther
}
data := oauth2RedirectData{} data := oauth2RedirectData{}
if e.Request.Method == http.MethodPost { if e.Request.Method == http.MethodPost {
if err := e.BindBody(&data); err != nil { if err := e.BindBody(&data); err != nil {
e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err) e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
} else { } else {
query := e.Request.URL.Query() query := e.Request.URL.Query()
@@ -49,13 +45,13 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.State == "" { if data.State == "" {
e.App.Logger().Debug("Missing OAuth2 state parameter") e.App.Logger().Debug("Missing OAuth2 state parameter")
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
client, err := e.App.SubscriptionsBroker().ClientById(data.State) client, err := e.App.SubscriptionsBroker().ClientById(data.State)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) { if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State) 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) defer client.Unsubscribe(oauth2SubscriptionTopic)
@@ -76,7 +72,7 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
encodedData, err := json.Marshal(data) encodedData, err := json.Marshal(data)
if err != nil { if err != nil {
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err) e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
msg := subscriptions.Message{ msg := subscriptions.Message{
@@ -88,10 +84,36 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.Error != "" || data.Code == "" { 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) 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 // parseAndStoreAppleRedirectName extracts the first and last name
+25 -15
View File
@@ -22,6 +22,8 @@ import (
"golang.org/x/crypto/acme/autocert" "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(). // ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct { type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message. // 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}, AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
})) }))
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)). // @todo consider moving in base
BindFunc(func(e *core.RequestEvent) error { if ui.DistDirFS != nil {
// ignore root path pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
if e.Request.PathValue(StaticWildcardParam) != "" { BindFunc(func(e *core.RequestEvent) error {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400") 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") == "" {
if e.Response.Header().Get("Content-Security-Policy") == "" { e.Response.Header().Set("Content-Security-Policy", defaultCSP)
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='") }
}
return e.Next() return e.Next()
}). }).
Bind(Gzip()) Bind(Gzip())
}
// start http server // start http server
// --- // ---
@@ -279,8 +285,12 @@ func Serve(app core.App, config ServeConfig) error {
) )
regular := color.New() regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL)) if ui.DistDirFS == nil {
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL)) 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 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.PATCH("", settingsSet)
subGroup.POST("/test/s3", settingsTestS3) subGroup.POST("/test/s3", settingsTestS3)
subGroup.POST("/test/email", settingsTestEmail) subGroup.POST("/test/email", settingsTestEmail)
// @todo move to collections
subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret) 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) 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 { 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) return e.JSON(http.StatusOK, appSettings)
}) })
}) })
+9
View File
@@ -267,6 +267,15 @@ type App interface {
// "dangerousSelectQuery" argument must come only from trusted input! // "dangerousSelectQuery" argument must come only from trusted input!
CreateViewFields(dangerousSelectQuery string) (FieldsList, error) 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 returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
+28 -26
View File
@@ -817,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
}{ }{
{ {
"unknown", "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, 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, 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, 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" 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 { scenarios := []struct {
name string name string
query string query string
expected string expected string
}{ }{
{ {
"no wrapping - text field", "no wrapping - id field",
"select text as id, bool from demo1", "select id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select id, bool from demo1)",
}, },
{ {
"no wrapping - id field", "no wrapping - text field",
"select text as id, bool from demo1", "select text as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - relation field", "no wrapping - relation field",
"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)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - select field", "no wrapping - select field",
"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)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - email field", "no wrapping - email field",
"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)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - datetime field", "no wrapping - datetime field",
"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)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - url field", "no wrapping - url field",
"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)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
}, },
{ {
"wrapping - bool field", "wrapping - bool field",
"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))", "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)", "wrapping - bool field (different order)",
"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))", "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", "wrapping - json field",
"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))", "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", "wrapping - numeric id",
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
}, },
expectedErrors: []string{"fields", "viewQuery"}, 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", name: "view with valid query",
collection: func(app core.App) (*core.Collection, error) { 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 return nil // nothing to check
} }
if _, err := cv.app.CreateViewFields(v); err != nil { _, err := cv.app.DryRunView(v, 10)
return validation.NewError( if err != nil {
"validation_invalid_view_query", rawErr := err.Error()
fmt.Sprintf("Invalid query - %s", 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 return nil
+19
View File
@@ -2,6 +2,7 @@ package core
import ( import (
"context" "context"
"io/fs"
"net" "net"
"net/http" "net/http"
"time" "time"
@@ -57,6 +58,9 @@ type baseCollectionEventData struct {
Collection *Collection 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 { func (e *baseCollectionEventData) Tags() []string {
if e.Collection == nil { if e.Collection == nil {
return nil return nil
@@ -125,6 +129,21 @@ type ServeEvent struct {
// //
// Set it to nil if you want to skip the installer. // Set it to nil if you want to skip the installer.
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
// @todo experimental
//
// UIExtensions is a list with the superuser UI extensions.
UIExtensions []UIExtension
}
type UIExtension struct {
// Name is the name of the extension.
// It is also used as path segment for the registered public extension endpoint
// (e.g. /_/extensions/{name}/*)
Name string
// FS is the extension file system.
FS fs.FS
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+20
View File
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
) error ) 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. // DefaultFieldIdValidationRule performs base validation on a field id value.
func DefaultFieldIdValidationRule(value any) error { func DefaultFieldIdValidationRule(value any) error {
v, ok := value.(string) v, ok := value.(string)
+2 -2
View File
@@ -46,12 +46,12 @@ type AutodateField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// ---
// OnCreate auto sets the current datetime as field value on record create. // OnCreate auto sets the current datetime as field value on record create.
OnCreate bool `form:"onCreate" json:"onCreate"` 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 hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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 will require the field value to be always "true".
Required bool `form:"required" json:"required"` 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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) { func TestBoolFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeBool) testDefaultFieldIdValidation(t, core.FieldTypeBool)
testDefaultFieldNameValidation(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 hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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))), 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) { func TestDateFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeDate) testDefaultFieldIdValidation(t, core.FieldTypeDate)
testDefaultFieldNameValidation(t, core.FieldTypeDate) testDefaultFieldNameValidation(t, core.FieldTypeDate)
testDefaultFieldHelpValidation[core.DateField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -41,11 +41,15 @@ type EditorField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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). // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), 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) { func TestEditorFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEditor) testDefaultFieldIdValidation(t, core.FieldTypeEditor)
testDefaultFieldNameValidation(t, core.FieldTypeEditor) testDefaultFieldNameValidation(t, core.FieldTypeEditor)
testDefaultFieldHelpValidation[core.EditorField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type EmailField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field( validation.Field(
&f.ExceptDomains, &f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), 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) { func TestEmailFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEmail) testDefaultFieldIdValidation(t, core.FieldTypeEmail)
testDefaultFieldNameValidation(t, core.FieldTypeEmail) testDefaultFieldNameValidation(t, core.FieldTypeEmail)
testDefaultFieldHelpValidation[core.EmailField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -88,11 +88,15 @@ type FileField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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). // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Thumbs, validation.Each( validation.Field(&f.Thumbs, validation.Each(
+1
View File
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
func TestFileFieldValidateSettings(t *testing.T) { func TestFileFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeFile) testDefaultFieldIdValidation(t, core.FieldTypeFile)
testDefaultFieldNameValidation(t, core.FieldTypeFile) testDefaultFieldNameValidation(t, core.FieldTypeFile)
testDefaultFieldHelpValidation[core.FileField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -46,11 +46,15 @@ type GeoPointField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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 will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"` Required bool `form:"required" json:"required"`
@@ -144,5 +148,6 @@ func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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) { func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint) testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(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 hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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). // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), 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) { func TestJSONFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeJSON) testDefaultFieldIdValidation(t, core.FieldTypeJSON)
testDefaultFieldNameValidation(t, core.FieldTypeJSON) testDefaultFieldNameValidation(t, core.FieldTypeJSON)
testDefaultFieldHelpValidation[core.JSONField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -48,11 +48,15 @@ type NumberField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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.Min, validation.By(f.checkOnlyInt)),
validation.Field(&f.Max, maxRules...), validation.Field(&f.Max, maxRules...),
) )
+1
View File
@@ -214,6 +214,7 @@ func TestNumberFieldValidateValue(t *testing.T) {
func TestNumberFieldValidateSettings(t *testing.T) { func TestNumberFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeNumber) testDefaultFieldIdValidation(t, core.FieldTypeNumber)
testDefaultFieldNameValidation(t, core.FieldTypeNumber) testDefaultFieldNameValidation(t, core.FieldTypeNumber)
testDefaultFieldHelpValidation[core.NumberField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+8 -1
View File
@@ -61,11 +61,17 @@ type PasswordField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// @todo remove
//
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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.Min, validation.Min(1), validation.Max(71)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)), validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)),
validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)), validation.Field(&f.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) { func TestPasswordFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypePassword) testDefaultFieldIdValidation(t, core.FieldTypePassword)
testDefaultFieldNameValidation(t, core.FieldTypePassword) testDefaultFieldNameValidation(t, core.FieldTypePassword)
testDefaultFieldHelpValidation[core.PasswordField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type RelationField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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 is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"` CollectionId string `form:"collectionId" json:"collectionId"`
@@ -237,6 +241,7 @@ func (f *RelationField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), 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.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))),
validation.Field(&f.MinSelect, validation.Min(0)), validation.Field(&f.MinSelect, validation.Min(0)),
validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)), 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) { func TestRelationFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeRelation) testDefaultFieldIdValidation(t, core.FieldTypeRelation)
testDefaultFieldNameValidation(t, core.FieldTypeRelation) testDefaultFieldNameValidation(t, core.FieldTypeRelation)
testDefaultFieldHelpValidation[core.RelationField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type SelectField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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 specifies the list of accepted values.
Values []string `form:"values" json:"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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Values, validation.Required), validation.Field(&f.Values, validation.Required),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)), 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) { func TestSelectFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeSelect) testDefaultFieldIdValidation(t, core.FieldTypeSelect)
testDefaultFieldNameValidation(t, core.FieldTypeSelect) testDefaultFieldNameValidation(t, core.FieldTypeSelect)
testDefaultFieldHelpValidation[core.SelectField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+61 -2
View File
@@ -2,6 +2,8 @@ package core_test
import ( import (
"context" "context"
"encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -113,7 +115,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
hasErr := errs["id"] != nil hasErr := errs["id"] != nil
if hasErr != s.expectError { 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 hasErr := errs["name"] != nil
if hasErr != s.expectError { 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 hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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.By(DefaultFieldNameValidationRule),
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)), 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.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Max, validation.Min(f.Min), 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) { func TestTextFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeText) testDefaultFieldIdValidation(t, core.FieldTypeText)
testDefaultFieldNameValidation(t, core.FieldTypeText) testDefaultFieldNameValidation(t, core.FieldTypeText)
testDefaultFieldHelpValidation[core.TextField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type URLField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` 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. // 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, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field( validation.Field(
&f.ExceptDomains, &f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), 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) { func TestURLFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeURL) testDefaultFieldIdValidation(t, core.FieldTypeURL)
testDefaultFieldNameValidation(t, core.FieldTypeURL) testDefaultFieldNameValidation(t, core.FieldTypeURL)
testDefaultFieldHelpValidation[core.URLField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -6
View File
@@ -473,13 +473,13 @@ func TestFieldsListScan(t *testing.T) {
"only the minimum field options", "only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`, `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false, 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", "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, 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", "only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`, `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false, 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", "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, 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"}]`,
}, },
} }
+7
View File
@@ -143,6 +143,7 @@ func newDefaultSettings() *Settings {
isNew: true, isNew: true,
settings: settings{ settings: settings{
Meta: MetaConfig{ Meta: MetaConfig{
AccentColor: "#1055c9",
AppName: "Acme", AppName: "Acme",
AppURL: "http://localhost:8090", AppURL: "http://localhost:8090",
HideControls: false, HideControls: false,
@@ -513,6 +514,11 @@ func checkCronExpression(value any) error {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type MetaConfig struct { type MetaConfig struct {
// @todo experimental
//
// AccentColor specify the UI "accent" color (HEX).
AccentColor string `form:"accentColor" json:"accentColor"`
AppName string `form:"appName" json:"appName"` AppName string `form:"appName" json:"appName"`
AppURL string `form:"appURL" json:"appURL"` AppURL string `form:"appURL" json:"appURL"`
SenderName string `form:"senderName" json:"senderName"` SenderName string `form:"senderName" json:"senderName"`
@@ -523,6 +529,7 @@ type MetaConfig struct {
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface. // Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
func (c MetaConfig) Validate() error { func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c, 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.AppName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.AppURL, validation.Required, is.URL), validation.Field(&c.AppURL, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)), 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)) 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 { if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr) 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) { func TestSettingsMerge(t *testing.T) {
t.Parallel()
s1 := &core.Settings{} s1 := &core.Settings{}
s1.Meta.AppURL = "app_url" // should be unset s1.Meta.AppURL = "app_url" // should be unset
@@ -126,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
} }
func TestSettingsClone(t *testing.T) { func TestSettingsClone(t *testing.T) {
t.Parallel()
s1 := &core.Settings{} s1 := &core.Settings{}
s1.Meta.AppName = "test_name" s1.Meta.AppName = "test_name"
@@ -156,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
} }
func TestSettingsMarshalJSON(t *testing.T) { func TestSettingsMarshalJSON(t *testing.T) {
t.Parallel()
settings := &core.Settings{} settings := &core.Settings{}
// control fields // control fields
@@ -174,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
} }
rawStr := string(raw) 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 { if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr) t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -230,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
} }
func TestMetaConfigValidate(t *testing.T) { func TestMetaConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.MetaConfig config core.MetaConfig
@@ -248,12 +256,14 @@ func TestMetaConfigValidate(t *testing.T) {
{ {
"invalid data", "invalid data",
core.MetaConfig{ core.MetaConfig{
AccentColor: "#fff",
AppName: strings.Repeat("a", 300), AppName: strings.Repeat("a", 300),
AppURL: "test", AppURL: "test",
SenderName: strings.Repeat("a", 300), SenderName: strings.Repeat("a", 300),
SenderAddress: "invalid_email", SenderAddress: "invalid_email",
}, },
[]string{ []string{
"accentColor",
"appName", "appName",
"appURL", "appURL",
"senderName", "senderName",
@@ -263,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
{ {
"valid data", "valid data",
core.MetaConfig{ core.MetaConfig{
AccentColor: "#ffffff",
AppName: "test", AppName: "test",
AppURL: "https://example.com", AppURL: "https://example.com",
SenderName: "test", SenderName: "test",
@@ -282,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
} }
func TestLogsConfigValidate(t *testing.T) { func TestLogsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.LogsConfig config core.LogsConfig
@@ -314,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
} }
func TestSMTPConfigValidate(t *testing.T) { func TestSMTPConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.SMTPConfig config core.SMTPConfig
@@ -373,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
} }
func TestS3ConfigValidate(t *testing.T) { func TestS3ConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.S3Config config core.S3Config
@@ -444,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
} }
func TestBackupsConfigValidate(t *testing.T) { func TestBackupsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.BackupsConfig config core.BackupsConfig
@@ -499,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
} }
func TestBatchConfigValidate(t *testing.T) { func TestBatchConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.BatchConfig config core.BatchConfig
@@ -554,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
} }
func TestRateLimitsConfigValidate(t *testing.T) { func TestRateLimitsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.RateLimitsConfig config core.RateLimitsConfig
@@ -699,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
} }
func TestRateLimitsFindRateLimitRule(t *testing.T) { func TestRateLimitsFindRateLimitRule(t *testing.T) {
t.Parallel()
limits := core.RateLimitsConfig{ limits := core.RateLimitsConfig{
Rules: []core.RateLimitRule{ Rules: []core.RateLimitRule{
{Label: "abc"}, {Label: "abc"},
@@ -753,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
} }
func TestRateLimitRuleValidate(t *testing.T) { func TestRateLimitRuleValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
rule core.RateLimitRule rule core.RateLimitRule
@@ -860,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
} }
func TestRateLimitRuleDurationTime(t *testing.T) { func TestRateLimitRuleDurationTime(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
rule core.RateLimitRule rule core.RateLimitRule
expected time.Duration expected time.Duration
@@ -880,6 +909,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
} }
func TestRateLimitRuleString(t *testing.T) { func TestRateLimitRuleString(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
rule core.RateLimitRule rule core.RateLimitRule
+107 -16
View File
@@ -1,6 +1,7 @@
package core package core
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -36,17 +37,14 @@ func (app *BaseApp) DeleteView(dangerousViewName string) error {
func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error { func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
return app.RunInTransaction(func(txApp App) error { return app.RunInTransaction(func(txApp App) error {
// delete old view (if exists) // delete old view (if exists)
if err := txApp.DeleteView(dangerousViewName); err != nil { err := txApp.DeleteView(dangerousViewName)
if err != nil {
return err return err
} }
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";") dangerousSelectQuery, err = normalizeViewSelectQuery(dangerousSelectQuery)
if err != nil {
// try to loosely detect multiple inline statements return err
tk := tokenizer.NewFromString(dangerousSelectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
} }
// (re)create the view // (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 // note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution // measure to discourage multiple inline sql statements execution
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery) 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 return err
} }
@@ -124,6 +123,76 @@ func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, e
return result, txErr 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. // FindRecordByViewFile returns the original Record of the provided view collection file.
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) { func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier) view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
@@ -198,6 +267,20 @@ func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fi
// Raw query to schema helpers // 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 { type queryField struct {
// field is the final resolved field. // field is the final resolved field.
field Field field Field
@@ -212,12 +295,26 @@ type queryField struct {
} }
func defaultViewField(name string) Field { func defaultViewField(name string) Field {
if name == FieldNameId {
return defaultViewIdField()
}
return &JSONField{ return &JSONField{
Name: name, Name: name,
MaxSize: 1, // unused for views 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*\)$`) var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) { 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) // pk (always assume text field for now)
if col.alias == FieldNameId { if col.alias == FieldNameId {
result[col.alias] = &queryField{ result[col.alias] = &queryField{
field: &TextField{ field: defaultViewIdField(),
Name: col.alias,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
},
} }
continue 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)
}
+45 -10
View File
@@ -289,9 +289,13 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return wrappedMiddlewares, nil return wrappedMiddlewares, nil
} }
// -------------------------------------------------------------------
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](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{}) vm.SetFieldNameMapper(FieldMapper{})
// deprecated: use toString // 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() obj := vm.NewObject()
vm.Set("$dbx", obj) vm.Set("$dbx", obj)
@@ -682,7 +689,10 @@ func dbxBinds(vm *goja.Runtime) {
obj.Set("notBetween", dbx.NotBetween) 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() obj := vm.NewObject()
vm.Set("$mails", obj) vm.Set("$mails", obj)
@@ -693,7 +703,10 @@ func mailsBinds(vm *goja.Runtime) {
obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert) 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() obj := vm.NewObject()
vm.Set("$security", obj) 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() obj := vm.NewObject()
vm.Set("$filesystem", obj) vm.Set("$filesystem", obj)
@@ -757,7 +774,11 @@ func filesystemBinds(vm *goja.Runtime) {
}) })
} }
func filepathBinds(vm *goja.Runtime) { // BindFilesystem 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() obj := vm.NewObject()
vm.Set("$filepath", obj) vm.Set("$filepath", obj)
@@ -778,7 +799,11 @@ func filepathBinds(vm *goja.Runtime) {
obj.Set("walkDir", filepath.WalkDir) obj.Set("walkDir", filepath.WalkDir)
} }
func osBinds(vm *goja.Runtime) { // BindFilesystem 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() obj := vm.NewObject()
vm.Set("$os", obj) vm.Set("$os", obj)
@@ -804,14 +829,20 @@ func osBinds(vm *goja.Runtime) {
obj.Set("openInRoot", os.OpenInRoot) 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, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert) registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend) registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend)
registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem) 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() obj := vm.NewObject()
vm.Set("$apis", obj) vm.Set("$apis", obj)
@@ -850,7 +881,11 @@ func apisBinds(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError) registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError)
} }
func httpClientBinds(vm *goja.Runtime) { // BindHttpClient registers $http.* namespaced object with common utils
// for sending HTTP requests.
//
// See https://pocketbase.io/jsvm/modules/_http.html.
func BindHttpClient(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$http", obj) 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 // note: this test is useful as a reminder to update the tests in case
// a new base binding is added. // a new base binding is added.
func TestBaseBindsCount(t *testing.T) { func TestBindCoreCount(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
testBindsCount(vm, "this", 41, t) testBindsCount(vm, "this", 41, t)
} }
func TestBaseBindsSleep(t *testing.T) { func TestBindCoreSleep(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("reader", strings.NewReader("test")) vm.Set("reader", strings.NewReader("test"))
start := time.Now() 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("reader", strings.NewReader("test")) vm.Set("reader", strings.NewReader("test"))
_, err := vm.RunString(` _, 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("scenarios", []struct { vm.Set("scenarios", []struct {
Name string Name string
Value any Value any
@@ -120,9 +120,9 @@ func TestBaseBindsToString(t *testing.T) {
} }
} }
func TestBaseBindsToBytes(t *testing.T) { func TestBindCoreToBytes(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("bytesEqual", bytes.Equal) vm.Set("bytesEqual", bytes.Equal)
vm.Set("scenarios", []struct { vm.Set("scenarios", []struct {
Name string Name string
@@ -160,9 +160,9 @@ func TestBaseBindsToBytes(t *testing.T) {
} }
} }
func TestBaseBindsUnmarshal(t *testing.T) { func TestBindCoreUnmarshal(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("data", &map[string]any{"a": 123}) vm.Set("data", &map[string]any{"a": 123})
_, err := vm.RunString(` _, 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const base = new Context(null, "a", 123); 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const cookie = new Cookie({ 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("bytesToString", func(b []byte) string { vm.Set("bytesToString", func(b []byte) string {
return string(b) 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() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -272,7 +272,7 @@ func TestBaseBindsRecord(t *testing.T) {
} }
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("collection", collection) vm.Set("collection", collection)
// without record data // 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`) v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`)
if err != nil { 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`) v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`)
if err != nil { 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`) v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`)
if err != nil { if err != nil {
@@ -379,11 +379,11 @@ func isType[T any](v any) bool {
return ok return ok
} }
func TestBaseBindsNamedFields(t *testing.T) { func TestBindCoreNamedFields(t *testing.T) {
t.Parallel() t.Parallel()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -470,9 +470,9 @@ func TestBaseBindsNamedFields(t *testing.T) {
} }
} }
func TestBaseBindsMailerMessage(t *testing.T) { func TestBindCoreMailerMessage(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new MailerMessage({ v, err := vm.RunString(`new MailerMessage({
from: {name: "test_from", address: "test_from@example.com"}, 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
let runCalls = 0; 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const info = new RequestInfo({ 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const m = new Middleware( 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const v0 = (new Timezone()).string(); 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const now = new DateTime(); 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() vm := goja.New()
baseBinds(vm) BindCore(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -697,14 +697,14 @@ func TestBaseBindsValidationError(t *testing.T) {
} }
} }
func TestDbxBinds(t *testing.T) { func TestBindDbx(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
vm.Set("db", app.DB()) vm.Set("db", app.DB())
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
testBindsCount(vm, "$dbx", 15, t) 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() vm := goja.New()
mailsBinds(vm) BindMails(vm)
testBindsCount(vm, "$mails", 5, t) testBindsCount(vm, "$mails", 5, t)
} }
func TestMailsBinds(t *testing.T) { func TestBindMails(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -809,8 +809,8 @@ func TestMailsBinds(t *testing.T) {
} }
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
mailsBinds(vm) BindMails(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("record", record) 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() vm := goja.New()
securityBinds(vm) BindSecurity(vm)
testBindsCount(vm, "$security", 16, t) testBindsCount(vm, "$security", 16, t)
} }
func TestSecurityCryptoBinds(t *testing.T) { func TestSecurityCryptoBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
sceneraios := []struct { sceneraios := []struct {
js string js string
@@ -888,8 +888,8 @@ func TestSecurityCryptoBinds(t *testing.T) {
func TestSecurityRandomStringBinds(t *testing.T) { func TestSecurityRandomStringBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
sceneraios := []struct { sceneraios := []struct {
js string js string
@@ -964,8 +964,8 @@ func TestSecurityJWTBinds(t *testing.T) {
for _, s := range sceneraios { for _, s := range sceneraios {
t.Run(s.name, func(t *testing.T) { t.Run(s.name, func(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
_, err := vm.RunString(s.js) _, err := vm.RunString(s.js)
if err != nil { if err != nil {
@@ -977,8 +977,8 @@ func TestSecurityJWTBinds(t *testing.T) {
func TestSecurityEncryptAndDecryptBinds(t *testing.T) { func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const key = "abcdabcdabcdabcdabcdabcdabcdabcd" 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() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1020,8 +1020,8 @@ func TestFilesystemBinds(t *testing.T) {
vm.Set("tmpDir", tmpDir) vm.Set("tmpDir", tmpDir)
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db")) vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseURL", srv.URL) vm.Set("baseURL", srv.URL)
baseBinds(vm) BindCore(vm)
filesystemBinds(vm) BindFilesystem(vm)
testBindsCount(vm, "$filesystem", 6, t) 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() vm := goja.New()
formsBinds(vm) BindForms(vm)
testBindsCount(vm, "this", 4, t) testBindsCount(vm, "this", 4, t)
} }
func TestApisBindsCount(t *testing.T) { func TestBindApisCount(t *testing.T) {
vm := goja.New() vm := goja.New()
apisBinds(vm) BindApis(vm)
testBindsCount(vm, "this", 8, t) testBindsCount(vm, "this", 8, t)
testBindsCount(vm, "$apis", 11, t) testBindsCount(vm, "$apis", 11, t)
} }
func TestApisBindsApiError(t *testing.T) { func TestBindApisErrors(t *testing.T) {
vm := goja.New() vm := goja.New()
apisBinds(vm) BindApis(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -1190,8 +1190,8 @@ func TestLoadingDynamicModel(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1291,8 +1291,8 @@ func TestDynamicModelMapFieldCaching(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1350,8 +1350,8 @@ func TestLoadingArrayOf(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1391,18 +1391,18 @@ func TestLoadingArrayOf(t *testing.T) {
} }
} }
func TestHttpClientBindsCount(t *testing.T) { func TestBindHttpClientCount(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
httpClientBinds(vm) BindHttpClient(vm)
testBindsCount(vm, "this", 2, t) // + FormData testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t) testBindsCount(vm, "$http", 1, t)
} }
func TestHttpClientBindsSend(t *testing.T) { func TestBindHttpClientSend(t *testing.T) {
t.Parallel() t.Parallel()
// start a test server // start a test server
@@ -1447,8 +1447,8 @@ func TestHttpClientBindsSend(t *testing.T) {
defer server.Close() defer server.Close()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
httpClientBinds(vm) BindHttpClient(vm)
vm.Set("testURL", server.URL) vm.Set("testURL", server.URL)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1626,7 +1626,7 @@ func TestHooksBinds(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("result", result) vm.Set("result", result)
return vm return vm
@@ -1712,7 +1712,7 @@ func TestHooksExceptionUnwrapping(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("goErr", goErr) vm.Set("goErr", goErr)
return vm return vm
@@ -1766,8 +1766,8 @@ func TestRouterBinds(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
apisBinds(vm) BindApis(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("result", result) vm.Set("result", result)
return vm return vm
@@ -1855,16 +1855,16 @@ func TestRouterBinds(t *testing.T) {
} }
} }
func TestFilepathBindsCount(t *testing.T) { func TestBindFilepathCount(t *testing.T) {
vm := goja.New() vm := goja.New()
filepathBinds(vm) BindFilepath(vm)
testBindsCount(vm, "$filepath", 15, t) testBindsCount(vm, "$filepath", 15, t)
} }
func TestOsBindsCount(t *testing.T) { func TestBindOsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
osBinds(vm) BindOs(vm)
testBindsCount(vm, "$os", 20, t) 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 // Package jsvm implements pluggable utilities for binding a JS goja runtime
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.). // 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: // Example:
// //
// jsvm.MustRegister(app, jsvm.Config{ // jsvm.MustRegister(app, jsvm.Config{
@@ -200,15 +203,15 @@ func (p *plugin) registerMigrations() error {
process.Enable(vm) process.Enable(vm)
buffer.Enable(vm) buffer.Enable(vm)
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
securityBinds(vm) BindSecurity(vm)
osBinds(vm) BindOs(vm)
filepathBinds(vm) BindFilepath(vm)
httpClientBinds(vm) BindHttpClient(vm)
filesystemBinds(vm) BindFilesystem(vm)
formsBinds(vm) BindForms(vm)
mailsBinds(vm) BindMails(vm)
vm.Set("$template", templateRegistry) vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir) vm.Set("__hooks", absHooksDir)
@@ -288,16 +291,16 @@ func (p *plugin) registerHooks() error {
process.Enable(vm) process.Enable(vm)
buffer.Enable(vm) buffer.Enable(vm)
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
filesystemBinds(vm) BindSecurity(vm)
securityBinds(vm) BindOs(vm)
osBinds(vm) BindFilepath(vm)
filepathBinds(vm) BindHttpClient(vm)
httpClientBinds(vm) BindFilesystem(vm)
formsBinds(vm) BindForms(vm)
apisBinds(vm) BindMails(vm)
mailsBinds(vm) BindApis(vm)
vm.Set("$app", p.app) vm.Set("$app", p.app)
vm.Set("$template", templateRegistry) vm.Set("$template", templateRegistry)
+32
View File
@@ -50,6 +50,7 @@ migrate((app) => {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -64,6 +65,7 @@ migrate((app) => {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -77,6 +79,7 @@ migrate((app) => {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -91,6 +94,7 @@ migrate((app) => {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
@@ -101,6 +105,7 @@ migrate((app) => {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -110,6 +115,7 @@ migrate((app) => {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "verified", "name": "verified",
@@ -226,6 +232,7 @@ func init() {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -240,6 +247,7 @@ func init() {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -253,6 +261,7 @@ func init() {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -267,6 +276,7 @@ func init() {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
@@ -277,6 +287,7 @@ func init() {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -286,6 +297,7 @@ func init() {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "verified", "name": "verified",
@@ -491,6 +503,7 @@ migrate((app) => {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -505,6 +518,7 @@ migrate((app) => {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -518,6 +532,7 @@ migrate((app) => {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -532,6 +547,7 @@ migrate((app) => {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
@@ -542,6 +558,7 @@ migrate((app) => {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -551,6 +568,7 @@ migrate((app) => {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool256245529", "id": "bool256245529",
"name": "verified", "name": "verified",
@@ -670,6 +688,7 @@ func init() {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -684,6 +703,7 @@ func init() {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -697,6 +717,7 @@ func init() {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -711,6 +732,7 @@ func init() {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
@@ -721,6 +743,7 @@ func init() {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -730,6 +753,7 @@ func init() {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool256245529", "id": "bool256245529",
"name": "verified", "name": "verified",
@@ -923,6 +947,7 @@ migrate((app) => {
// add field // add field
collection.fields.addAt(8, new Field({ collection.fields.addAt(8, new Field({
"autogeneratePattern": "", "autogeneratePattern": "",
"help": "",
"hidden": false, "hidden": false,
"id": "f4_id", "id": "f4_id",
"max": 0, "max": 0,
@@ -938,6 +963,7 @@ migrate((app) => {
// update field // update field
collection.fields.addAt(7, new Field({ collection.fields.addAt(7, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -976,6 +1002,7 @@ migrate((app) => {
// add field // add field
collection.fields.addAt(8, new Field({ collection.fields.addAt(8, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f3_id", "id": "f3_id",
"name": "f3_name", "name": "f3_name",
@@ -990,6 +1017,7 @@ migrate((app) => {
// update field // update field
collection.fields.addAt(7, new Field({ collection.fields.addAt(7, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -1054,6 +1082,7 @@ func init() {
// add field // add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"autogeneratePattern": "", "autogeneratePattern": "",
"help": "",
"hidden": false, "hidden": false,
"id": "f4_id", "id": "f4_id",
"max": 0, "max": 0,
@@ -1071,6 +1100,7 @@ func init() {
// update field // update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -1116,6 +1146,7 @@ func init() {
// add field // add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f3_id", "id": "f3_id",
"name": "f3_name", "name": "f3_name",
@@ -1132,6 +1163,7 @@ func init() {
// update field // update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
+2
View File
@@ -36,6 +36,8 @@ func NewAppleProvider() *Apple {
return &Apple{ return &Apple{
BaseProvider: BaseProvider{ BaseProvider: BaseProvider{
ctx: context.Background(), 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", displayName: "Apple",
pkce: true, pkce: true,
scopes: []string{"name", "email"}, scopes: []string{"name", "email"},
+20 -1
View File
@@ -28,8 +28,20 @@ func NewProviderByName(name string) (Provider, error) {
return factory(), nil return factory(), nil
} }
// @todo refactor and consider replace with a plain struct
//
// Provider defines a common interface for an OAuth2 client. // Provider defines a common interface for an OAuth2 client.
type Provider interface { 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 returns the context associated with the provider (if any).
Context() context.Context Context() context.Context
@@ -133,11 +145,18 @@ type AuthUser struct {
Id string `json:"id"` Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatarURL"` AvatarURL string `json:"avatarURL"`
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"` 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 // @todo
// deprecated: use AvatarURL instead // deprecated: use AvatarURL instead
// AvatarUrl will be removed after dropping v0.22 support // 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. // BaseProvider defines common fields and methods used by OAuth2 client providers.
type BaseProvider struct { type BaseProvider struct {
ctx context.Context ctx context.Context
extra map[string]any
redirectURL string
clientId string clientId string
clientSecret string clientSecret string
displayName string displayName string
redirectURL string logo string
authURL string authURL string
tokenURL string tokenURL string
userInfoURL string userInfoURL string
scopes []string scopes []string
order int
pkce bool 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. // Context implements Provider.Context() interface method.
+46 -14
View File
@@ -30,14 +30,46 @@ func TestDisplayName(t *testing.T) {
before := b.DisplayName() before := b.DisplayName()
if before != "" { 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") b.SetDisplayName("test")
after := b.DisplayName() after := b.DisplayName()
if after != "test" { 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() before := b.ClientId()
if before != "" { 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") b.SetClientId("test")
after := b.ClientId() after := b.ClientId()
if after != "test" { 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() before := b.ClientSecret()
if before != "" { 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") b.SetClientSecret("test")
after := b.ClientSecret() after := b.ClientSecret()
if after != "test" { 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() before := b.RedirectURL()
if before != "" { 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") b.SetRedirectURL("test")
after := b.RedirectURL() after := b.RedirectURL()
if after != "test" { 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() before := b.AuthURL()
if before != "" { 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") b.SetAuthURL("test")
after := b.AuthURL() after := b.AuthURL()
if after != "test" { 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() before := b.TokenURL()
if before != "" { 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") b.SetTokenURL("test")
after := b.TokenURL() after := b.TokenURL()
if after != "test" { 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() before := b.UserInfoURL()
if before != "" { 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") b.SetUserInfoURL("test")
after := b.UserInfoURL() after := b.UserInfoURL()
if after != "test" { if after != "test" {
t.Fatalf("Expected userInfoURL to be 'test', got %v", after) t.Fatalf("Expected userInfoURL to be %q, got %q", "test", after)
} }
} }
+2
View File
@@ -28,6 +28,8 @@ type Bitbucket struct {
func NewBitbucketProvider() *Bitbucket { func NewBitbucketProvider() *Bitbucket {
return &Bitbucket{BaseProvider{ return &Bitbucket{BaseProvider{
ctx: context.Background(), 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", displayName: "Bitbucket",
pkce: false, pkce: false,
scopes: []string{"account"}, scopes: []string{"account"},
+2
View File
@@ -27,6 +27,8 @@ type Box struct {
func NewBoxProvider() *Box { func NewBoxProvider() *Box {
return &Box{BaseProvider{ return &Box{BaseProvider{
ctx: context.Background(), 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", displayName: "Box",
pkce: true, pkce: true,
scopes: []string{"root_readonly"}, scopes: []string{"root_readonly"},
+2
View File
@@ -29,6 +29,8 @@ func NewDiscordProvider() *Discord {
// https://discord.com/developers/docs/resources/user#get-current-user // https://discord.com/developers/docs/resources/user#get-current-user
return &Discord{BaseProvider{ return &Discord{BaseProvider{
ctx: context.Background(), 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", displayName: "Discord",
pkce: true, pkce: true,
scopes: []string{"identify", "email"}, scopes: []string{"identify", "email"},
+2
View File
@@ -27,6 +27,8 @@ type Facebook struct {
func NewFacebookProvider() *Facebook { func NewFacebookProvider() *Facebook {
return &Facebook{BaseProvider{ return &Facebook{BaseProvider{
ctx: context.Background(), 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", displayName: "Facebook",
pkce: true, pkce: true,
scopes: []string{"email"}, scopes: []string{"email"},
+2
View File
@@ -27,6 +27,8 @@ type Gitea struct {
func NewGiteaProvider() *Gitea { func NewGiteaProvider() *Gitea {
return &Gitea{BaseProvider{ return &Gitea{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 11,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m396 484-127-61c-12-6-18-21-12-34l61-127c6-12 21-17 34-11l27 13V154h17v118s57 24 83 40q7 3 13 14 3 10-1 19l-61 127c-6 13-22 18-34 12" style="fill:#fff"/><path d="M623 150c-4-4-10-4-10-4l-178 8-39 1v117l-17-8V155l-89-3-157-8q-15-2-39 1c-9 2-34 8-54 27C-5 212 7 276 8 286c2 12 7 44 32 72 46 56 144 55 144 55s12 29 31 56c25 33 50 59 75 62h189s12 0 29-11c14-8 26-23 26-23s13-14 31-45l14-28s55-118 55-232c-1-34-9-40-11-42M126 354c-26-9-37-19-37-19s-19-13-29-40c-16-44-1-71-1-71s8-22 38-30c14-4 31-3 31-3s7 59 16 94c7 30 25 78 25 78s-26-3-43-9m300 108s-6 14-20 15l-10-1-5-2-113-55s-11-6-13-16c-2-8 3-18 3-18l54-112s5-10 12-13l5-1c8-3 18 2 18 2l110 54s13 6 16 16q1 12-2 17c-6 16-55 114-55 114" style="fill:#609926"/><path d="M327 380q-14 1-17 14-2 13 9 20c7 4 17 2 22-5q8-13-1-24l24-49h6l7-4 29 16 5 5c2 6-2 15-2 15-2 7-18 40-18 40q-13 1-18 13-4 14 9 21a18 18 0 0 0 21-28l6-11 13-30c1-2 6-11 3-22-2-11-13-16-13-16-12-8-29-16-29-16l-1-7-4-6 14-29-12-6-14 29q-11 0-16 10t1 20z" style="fill:#609926"/></svg>`,
displayName: "Gitea", displayName: "Gitea",
pkce: true, pkce: true,
scopes: []string{"read:user", "user:email"}, scopes: []string{"read:user", "user:email"},
+2
View File
@@ -29,6 +29,8 @@ type Gitee struct {
func NewGiteeProvider() *Gitee { func NewGiteeProvider() *Gitee {
return &Gitee{BaseProvider{ return &Gitee{BaseProvider{
ctx: context.Background(), 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", displayName: "Gitee",
pkce: true, pkce: true,
scopes: []string{"user_info", "emails"}, scopes: []string{"user_info", "emails"},
+2
View File
@@ -29,6 +29,8 @@ type Github struct {
func NewGithubProvider() *Github { func NewGithubProvider() *Github {
return &Github{BaseProvider{ return &Github{BaseProvider{
ctx: context.Background(), 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", displayName: "GitHub",
pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored
scopes: []string{"read:user", "user:email"}, scopes: []string{"read:user", "user:email"},
+2
View File
@@ -27,6 +27,8 @@ type Gitlab struct {
func NewGitlabProvider() *Gitlab { func NewGitlabProvider() *Gitlab {
return &Gitlab{BaseProvider{ return &Gitlab{BaseProvider{
ctx: context.Background(), 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", displayName: "GitLab",
pkce: true, pkce: true,
scopes: []string{"read_user"}, scopes: []string{"read_user"},
+2
View File
@@ -26,6 +26,8 @@ type Google struct {
func NewGoogleProvider() *Google { func NewGoogleProvider() *Google {
return &Google{BaseProvider{ return &Google{BaseProvider{
ctx: context.Background(), 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", displayName: "Google",
pkce: true, pkce: true,
scopes: []string{ scopes: []string{
+2
View File
@@ -26,6 +26,8 @@ type Instagram struct {
func NewInstagramProvider() *Instagram { func NewInstagramProvider() *Instagram {
return &Instagram{BaseProvider{ return &Instagram{BaseProvider{
ctx: context.Background(), 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", displayName: "Instagram",
pkce: true, pkce: true,
scopes: []string{"instagram_business_basic"}, scopes: []string{"instagram_business_basic"},
+2
View File
@@ -28,6 +28,8 @@ type Kakao struct {
func NewKakaoProvider() *Kakao { func NewKakaoProvider() *Kakao {
return &Kakao{BaseProvider{ return &Kakao{BaseProvider{
ctx: context.Background(), 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", displayName: "Kakao",
pkce: true, pkce: true,
scopes: []string{"account_email", "profile_nickname", "profile_image"}, scopes: []string{"account_email", "profile_nickname", "profile_image"},
+2
View File
@@ -26,6 +26,8 @@ type Lark struct {
func NewLarkProvider() *Lark { func NewLarkProvider() *Lark {
return &Lark{BaseProvider{ return &Lark{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 19,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" fill="none"><path fill="#00d6b9" d="M137 126v-1l2-1v-1l3-2 3-3 3-3 2-3 3-2 2-3 4-3 2-2 4-3 9-7 5-3 9-3 2-1a135 135 0 0 0-26-51 12 12 0 0 0-9-4H56l-2 1 1 2a288 288 0 0 1 82 93"/><path fill="#3370ff" d="M98 212c51 0 95-28 118-69l3-5-6 9-4 4-6 5-2 2-4 3-11 4-6 2h-15l-4-1h-3l-2-1-5-1-3-1-4-1-3-1-3-1-1-1-3-1h-2l-3-2h-2l-2-1-3-1-2-1-2-1-2-1h-2l-1-1-2-1h-1l-1-1-2-1-2-1-2-1-2-1a285 285 0 0 1-81-60l-3 1v94a12 12 0 0 0 5 11 135 135 0 0 0 76 22"/><path fill="#133c9a" d="M248 90a78 78 0 0 0-58-5l-2 1-14 6-6 4-7 6-2 2-4 3-2 3-3 2-2 3-3 3-3 3-3 2v1l-2 1-1 1-1 1a137 137 0 0 1-28 20l2 1 1 1h2l1 1 1 1h2l2 1 2 1 2 1 3 1 2 1h2l3 2h2l3 1 2 1 2 1 3 1 4 1 8 2 2 1 7 1h15l6-2 7-2 6-4 2-1 2-2 3-1 7-8 6-9 1-2 12-24a77 77 0 0 1 16-22"/></svg>`,
displayName: "Lark", displayName: "Lark",
pkce: true, pkce: true,
// Lark has two domains with the same API: feishu.cn and larksuite.com. // Lark has two domains with the same API: feishu.cn and larksuite.com.
+2
View File
@@ -31,6 +31,8 @@ type Linear struct {
func NewLinearProvider() *Linear { func NewLinearProvider() *Linear {
return &Linear{BaseProvider{ return &Linear{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 16,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#222326" viewBox="0 0 100 100"><path d="M1.2 61.5c-.2-1 1-1.5 1.6-.8l36.5 36.5c.7.7.1 1.8-.8 1.6A50 50 0 0 1 1.2 61.5M0 47q0 .4.3.7l52 52.1q.4.3.8.3a50 50 0 0 0 7-1 1 1 0 0 0 .5-1.6l-58-58a1 1 0 0 0-1.7.5 50 50 0 0 0-.9 7m4.2-17.2q-.2.6.2 1.1l64.8 64.8q.5.5 1 .2l5.3-2.7q.8-.6.2-1.5L8.4 24.3a1 1 0 0 0-1.5.2Q5.4 27 4.2 29.7m8.5-11.6a1 1 0 0 1 0-1.4A50 50 0 0 1 100 50a50 50 0 0 1-16.7 37.4 1 1 0 0 1-1.4 0z"/></svg>`,
displayName: "Linear", displayName: "Linear",
pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled
scopes: []string{"read"}, scopes: []string{"read"},
+2
View File
@@ -26,6 +26,8 @@ type Livechat struct {
func NewLivechatProvider() *Livechat { func NewLivechatProvider() *Livechat {
return &Livechat{BaseProvider{ return &Livechat{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 27,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 80 80"><path d="M79.5 49.7a19 19 0 0 1-19 17.3H50L30 80V67l20-13h10.5a6 6 0 0 0 6.1-5.3q1-15-.2-30a5.6 5.6 0 0 0-5.2-5.1Q50.9 13.1 40 13c-10.9-.1-14.4.2-21.2.7-2.8.2-5 2.3-5.2 5.1q-1 15-.2 30c.3 3.1 3 5.3 6.1 5.2H30v13H19.5c-9.9.1-18.2-7.4-19-17.3q-1-16 .2-32C1.5 8.5 8.8 1.3 18 .7a313 313 0 0 1 44.1.1c9.2.6 16.5 7.8 17.3 17q1.1 16 .1 31.9" style="fill:#ff5100"/></svg>`,
displayName: "LiveChat", displayName: "LiveChat",
pkce: true, pkce: true,
scopes: []string{}, // default scopes are specified from the provider dashboard scopes: []string{}, // default scopes are specified from the provider dashboard
+2
View File
@@ -28,6 +28,8 @@ type Mailcow struct {
func NewMailcowProvider() *Mailcow { func NewMailcowProvider() *Mailcow {
return &Mailcow{BaseProvider{ return &Mailcow{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 28,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="394.8" height="376.9"><path d="M56 213.3c0-20.3-.8-17.3-3.7-39.3 2.2-22.5-7.6-38.9-7.7-58.4 0-4.7-5.8-6.7-4.7-11A99 99 0 0 0 56 214.7z" style="fill:#3d5263" transform="translate(10 10)"/><path d="m254.8 180.4-.6.5a121 121 0 0 1-80 84l11 9.2 53.2 44.8 31.6 26.5q1.3-3 1.3-6.4V167.1zM23 185.5l-6.5-5.2-16.5-13v171.6q0 3.5 1.2 6.5l58.4-48.7 27-22.4L99 263.9a121 121 0 0 1-76-78.4" style="fill:#f9e82d" transform="translate(10 10)"/><path d="M238.4 318.9 185.1 274l-11-9.2a120 120 0 0 1-75.1-1l-12.4 10.4-27 22.5-58.4 48.6A18 18 0 0 0 18 357h235.4a18 18 0 0 0 16.7-11.5z" style="fill:#edd514;fill-opacity:.89499996" transform="translate(10 10)"/><path d="M238.4 318.9c-41.4 4-115 5.5-178.8-22.1-21.5-9.4-42-22.1-59.6-39.2V339q0 3.5 1.2 6.5A18 18 0 0 0 18 357h235.4a18 18 0 0 0 18-18v-24.5s-12.6 2.5-32.9 4.5" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M86.6 274.3a120 120 0 0 0 98.5-.2 120 120 0 0 0 69.7-93.7l-.6.5a120.5 120.5 0 0 1-155.2 83 121 121 0 0 1-76-78.4l-6.5-5.2c5.5 42 32.7 77.3 70 94" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M54.3 63.9q-2.7 2.6-5.2 5.4l-.2.1-.7.8.2-.2a120.2 120.2 0 0 1 174.8 165 120 120 0 0 0 35-84.8A120 120 0 0 0 187 40.4a119 119 0 0 0-100.7 1.2 120 120 0 0 0-32 22.3" style="fill:#fff" transform="translate(10 10)"/><path d="M95.8 118.5a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2m91.1 0a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2" style="fill:#fff" transform="translate(10 10)"/><path d="M223.7 234.4A120.2 120.2 0 0 0 48.4 70 121 121 0 0 0 23 185.5 120.5 120.5 0 0 0 174.2 265a120 120 0 0 0 47.7-28.6zm-5.8-58.4q-3.2 11-8.7 20.5A94.8 94.8 0 0 1 56 214.8a99 99 0 0 1-16-110.3 96 96 0 0 1 97.8-54 97.3 97.3 0 0 1 84.3 97.2q0 14.9-4 28.3M49 69.3" style="fill:#f1f2f2" transform="translate(10 10)"/><path d="M257.6 161.9a120 120 0 0 1-3.4 19l.6-.5 16.5-13.2zM0 167.2l16.5 13.1 6.5 5.2q-3.5-11.6-4.7-23.9l-2.9.9zm87.5 25.1a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m93.8 0a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m1.7-90c-7 0-15.4 7.7-15.4 14.7s8 17.2 15 17.2 15.8-9.5 15.8-16.5-8.4-15.4-15.4-15.4m3.9 16.2a4.6 4.6 0 1 1 0-9.2 4.6 4.6 0 0 1 0 9.2m-97.2-15.9c-7 0-14.4 8.1-14.4 15s8.9 16.2 15.8 16.2 15.8-9.1 15.8-16.1-10.2-15.1-17.2-15.1m6.1 16a4.6 4.6 0 1 1 0-9.3 4.6 4.6 0 0 1 0 9.2" style="fill:#5a3620" transform="translate(10 10)"/><path d="M336.3 256.4c3.6-9.1 7.7-11 9.3-11.3-40.7 3.7-36.6 27.7-34 36l1 2.7s2 4.8 7.6 8.7c4.1 3 10.3 5.5 19 5.2 27 .4 35.6-50.2 35.6-50.2-6.6 11.7-26.4 9.9-38.5 9M49 69.4l5.3-5.4c-9-7-24.4-15.9-41.8-11.7A54 54 0 0 0 36 86.5q5.5-8.8 12.4-16.5l-.2.2zm209.8-17.2a49 49 0 0 0-39.2 9.8q10.8 9.9 19 22.3a54 54 0 0 0 20.2-32M134.3 160.2c-43.3 0-78.4 23-78.4 51.3v1.7l.2 1.6a94.8 94.8 0 0 0 153.1-18.3c-9.8-21-39.6-36.3-75-36.3M87.5 215a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5m93.8 0a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5M86.3 0c-18.2 16.4-.2 41.4 0 41.6Q102.6 34 121 31.1C97.6 27.7 86.3 0 86.3 0m99.9 0S175.3 26.5 153 30.9q18 2.3 34 9.5c3.4-5.3 15-26.1-.8-40.4" style="fill:#fef3df" transform="translate(10 10)"/><path d="M218 176a163 163 0 0 0 6.5-35.7c0-50-39.3-83.9-86.8-89.8-2.1 28 3.7 87 80.2 125.5m-47.6-58.3a12.6 12.6 0 1 1 25.2 0 12.6 12.6 0 0 1-25.2 0" style="fill:#87654a" transform="translate(10 10)"/><path d="m312.7 283.8-1-2.8a54 54 0 0 1-27.1 12.5q-6 1-13.3.5v28a206 206 0 0 0 26.2-12.5h.1c8.2-4.7 16.4-10.5 22.6-17-5.5-4-7.5-8.8-7.5-8.8M12.5 52.2C30 48 45.4 57 54.3 64q8.4-8.2 18.3-14.6C48.3 18.5 2.2 37.2 2.2 37.2A55 55 0 0 0 31.7 94q2-3.7 4.3-7.5C15.4 72.7 12.5 52.2 12.5 52.2m187.9-4.8q10.3 6.3 19.2 14.6a49 49 0 0 1 39.2-9.8s-2.5 18.4-20.3 32q2.5 3.8 4.6 7.7a54 54 0 0 0 26-54.7s-44-18-68.7 10.2m-61.5 3.1a96 96 0 0 0-99 54q-1.5 6.6-1.6 13.6v4.2q.3 6 1 11.1c4.2 25 7.9 42.5 13.4 66.8l.2 1.2q1 6 3 11.8v-1.7c0-28.3 35-51.3 78.4-51.3 35.3 0 65.1 15.3 75 36.3a99 99 0 0 0 8.6-20.5c-38-23.5-53.9-41.1-64.6-64.2-10.8-23-15.5-47.3-14.4-61.3M91 130.3a12.6 12.6 0 1 1 0-25.2 12.6 12.6 0 0 1 0 25.2" style="fill:#b58765" transform="translate(10 10)"/></svg>`,
displayName: "mailcow", displayName: "mailcow",
pkce: true, pkce: true,
scopes: []string{"profile"}, scopes: []string{"profile"},
+2
View File
@@ -28,6 +28,8 @@ func NewMicrosoftProvider() *Microsoft {
endpoints := microsoft.AzureADEndpoint("") endpoints := microsoft.AzureADEndpoint("")
return &Microsoft{BaseProvider{ return &Microsoft{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 3,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#f1511b" d="M122 122H0V0h122z"/><path fill="#80cc28" d="M256 122H134V0h122z"/><path fill="#00adef" d="M122 256H0V134h122z"/><path fill="#fbbc09" d="M256 256H134V134h122z"/></svg>`,
displayName: "Microsoft", displayName: "Microsoft",
pkce: true, pkce: true,
scopes: []string{"User.Read"}, scopes: []string{"User.Read"},
+2
View File
@@ -29,6 +29,8 @@ type Monday struct {
func NewMondayProvider() *Monday { func NewMondayProvider() *Monday {
return &Monday{BaseProvider{ return &Monday{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 18,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 127 127"><path fill="#fb275d" d="M24.8 87.2c-3.7 0-7.1-1.9-8.9-5.1a9 9 0 0 1 .3-9.9L34.5 44c1.9-3.1 5.4-4.9 9.1-4.8s7.1 2.1 8.8 5.3 1.5 7-.5 9.9L33.4 82.6a10 10 0 0 1-8.6 4.6"/><path fill="#fc0" d="M56.1 87.2c-3.7 0-7.1-1.9-8.9-5a9 9 0 0 1 .3-9.9l18.4-28.1c1.9-3.1 5.3-5 9.1-4.9s7.1 2.1 8.8 5.3 1.5 7-.7 10l-18.4 28a11 11 0 0 1-8.6 4.6"/><path fill="#00ca72" d="M86.7 87.2c5.6 0 10.2-4.6 10.2-10.2s-4.6-10.2-10.2-10.2S76.5 71.4 76.5 77c0 5.7 4.5 10.2 10.2 10.2"/></svg>`,
displayName: "monday.com", displayName: "monday.com",
pkce: true, pkce: true,
scopes: []string{"me:read"}, scopes: []string{"me:read"},
+2
View File
@@ -27,6 +27,8 @@ type Notion struct {
func NewNotionProvider() *Notion { func NewNotionProvider() *Notion {
return &Notion{BaseProvider{ return &Notion{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 17,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="none"><path fill="#000" d="M61 0 6 4q-6 2-6 7v61l3 8 13 17q2 4 8 3l65-4q7-1 7-7V21q0-3-4-5L74 3q-4-4-13-3M26 20q-7 1-9-2l-8-6q-1-2 1-2l54-4q5 0 8 2l9 7 1 1zm-6 68V30q0-3 3-4l63-3q3 0 3 3v58q1 5-4 5l-60 3q-5 1-5-4m59-55q1 4-1 4l-3 1v43l-7 2q-4 0-6-4L43 49v29l6 1s0 4-5 4H30l2-3 3-1V41h-5q0-4 4-5l14-1 20 31V39l-5-1q0-3 3-4z"/></svg>`,
displayName: "Notion", displayName: "Notion",
pkce: true, pkce: true,
authURL: "https://api.notion.com/v1/oauth/authorize", authURL: "https://api.notion.com/v1/oauth/authorize",
+2
View File
@@ -57,6 +57,8 @@ type OIDC struct {
func NewOIDCProvider() *OIDC { func NewOIDCProvider() *OIDC {
return &OIDC{BaseProvider{ return &OIDC{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 99,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="93" height="84" fill="none"><path fill="#ccc" d="M83.4 32.9c-8.7-5.5-21-8.8-34.3-8.8C22 24 .4 37.5.4 54 .4 69.3 18.5 81.8 42 84v-8.7c-15.9-2-27.8-10.7-27.8-21 0-11.9 15.5-21.6 34.8-21.6 9.5 0 18.2 2.4 24.5 6.3l-9 5.6h27.9V27.3z"/><path fill="#ff6200" d="M42 9.2V84l14-8.7V.2z"/></svg>`,
displayName: "OIDC", displayName: "OIDC",
pkce: true, pkce: true,
scopes: []string{ scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Patreon struct {
func NewPatreonProvider() *Patreon { func NewPatreonProvider() *Patreon {
return &Patreon{BaseProvider{ return &Patreon{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 24,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 1080 1080"><path d="M1033 324c0-137-108-250-234-291a746 746 0 0 0-512 27C106 145 49 333 47 519c-2 154 14 558 242 561 169 2 194-216 273-321 56-75 127-96 216-118a320 320 0 0 0 255-317"/></svg>`,
displayName: "Patreon", displayName: "Patreon",
pkce: true, pkce: true,
scopes: []string{"identity", "identity[email]"}, scopes: []string{"identity", "identity[email]"},
+2
View File
@@ -27,6 +27,8 @@ type Planningcenter struct {
func NewPlanningcenterProvider() *Planningcenter { func NewPlanningcenterProvider() *Planningcenter {
return &Planningcenter{BaseProvider{ return &Planningcenter{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 29,
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0.1 26.2 26.4"><path fill="#2565f4" d="M23.2 2.7c1.8.5 3 2.2 3 4v13c0 1.8-1.3 3.5-3 4l-8 2.5a8 8 0 0 1-4.3 0L3 23.8c-1.7-.6-3-2.3-3-4.1v-13c0-1.8 1.3-3.4 3-4l8-2.3a8 8 0 0 1 4.2 0z"/><path fill="#fff" d="m18 7.6-4.7 1.3h-.5L8 7.6q-1.7-.3-1.8 1.3v10.5q0 .3.3.4l1.7.4q.5.1.5-.3v-2.5H9l3.5 1h1.4l5.2-1.6q.9-.2.9-1V9c0-1-1-1.7-2-1.4m-.6 7-.2.1-3.7 1.2h-.6l-3.7-1a.2.2 0 0 1-.2-.3v-3.8q0-.2.3-.2l3 .8a3 3 0 0 0 1.8 0l3-.8q.2 0 .3.2z"/></svg>`,
displayName: "Planning Center", displayName: "Planning Center",
pkce: true, pkce: true,
scopes: []string{"people"}, scopes: []string{"people"},
+2
View File
@@ -27,6 +27,8 @@ type Spotify struct {
func NewSpotifyProvider() *Spotify { func NewSpotifyProvider() *Spotify {
return &Spotify{BaseProvider{ return &Spotify{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 21,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#1ed760" d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128C256 57.31 198.7 0 128 0m58.7 184.61a7.97 7.97 0 0 1-10.98 2.65c-30.05-18.36-67.88-22.52-112.44-12.34a7.98 7.98 0 0 1-3.55-15.56c48.76-11.14 90.58-6.34 124.32 14.28a8 8 0 0 1 2.65 10.97m15.67-34.85a10 10 0 0 1-13.73 3.29c-34.4-21.15-86.85-27.27-127.55-14.92a10 10 0 0 1-12.45-6.65 10 10 0 0 1 6.65-12.45c46.49-14.1 104.28-7.27 143.79 17.01a10 10 0 0 1 3.29 13.72m1.34-36.3c-41.25-24.5-109.32-26.75-148.7-14.8a11.97 11.97 0 1 1-6.95-22.9c45.21-13.73 120.37-11.08 167.87 17.12a11.96 11.96 0 1 1-12.21 20.6z"/></svg>`,
displayName: "Spotify", displayName: "Spotify",
pkce: true, pkce: true,
scopes: []string{ scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Strava struct {
func NewStravaProvider() *Strava { func NewStravaProvider() *Strava {
return &Strava{BaseProvider{ return &Strava{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 25,
logo: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="2500" height="2500"><path d="M0 0h16v16H0z" fill="#fc4c02"/><g fill="#fff" fill-rule="evenodd"><path d="M6.9 8.8l2.5 4.5 2.4-4.5h-1.5l-.9 1.7-1-1.7z" opacity=".6"/><path d="M7.2 2.5l3.1 6.3H4zm0 3.8l1.2 2.5H5.9z"/></g></svg>`,
displayName: "Strava", displayName: "Strava",
pkce: true, pkce: true,
scopes: []string{ scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Trakt struct {
func NewTraktProvider() *Trakt { func NewTraktProvider() *Trakt {
return &Trakt{BaseProvider{ return &Trakt{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 22,
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><radialGradient id="a" cx="48.5" cy="-.9" r="64.8" fx="48.5" fy="-.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9f42c6"/><stop offset=".3" stop-color="#a041c3"/><stop offset=".4" stop-color="#a43ebb"/><stop offset=".5" stop-color="#aa39ad"/><stop offset=".6" stop-color="#b4339a"/><stop offset=".7" stop-color="#c02b81"/><stop offset=".8" stop-color="#cf2061"/><stop offset=".9" stop-color="#e1143c"/><stop offset="1" stop-color="#f50613"/><stop offset="1" stop-color="red"/></radialGradient></defs><path d="M48 11.3v25.4C48 43 43 48 36.7 48H11.3C5 48 0 43 0 36.7V11.3C0 5 5 0 11.3 0h25.4A11 11 0 0 1 48 11.3" style="fill:url(#a)"/><path d="m13.6 18 8 7.9 1.4-1.5-8-7.9zM28 32.4l1.5-1.5-2.2-2.2L47.6 8.4q-.2-1-.8-2.1L24.5 28.7zM13 18.7 11.4 20l14.4 14.4 1.4-1.4-4.3-4.3L46.4 5.4 45 3.7 21.5 27.3zm34.9-9.1L28.7 28.8l1.5 1.4L48 12.4v-1.1zM25.2 22.3l-8-8-1.4 1.5 7.9 8zM41.3 35c0 3.4-2.8 6.2-6.2 6.2H13A6 6 0 0 1 6.8 35V13c0-3.4 2.8-6.2 6.2-6.2h20.8V4.6H12.9a8.3 8.3 0 0 0-8.3 8.3V35c0 4.6 3.7 8.3 8.3 8.3H35c4.6 0 8.3-3.7 8.3-8.3v-3.5h-2z" style="fill:#fff"/></svg>`,
displayName: "Trakt", displayName: "Trakt",
pkce: true, pkce: true,
authURL: "https://trakt.tv/oauth/authorize", authURL: "https://trakt.tv/oauth/authorize",
+2
View File
@@ -29,6 +29,8 @@ type Twitch struct {
func NewTwitchProvider() *Twitch { func NewTwitchProvider() *Twitch {
return &Twitch{BaseProvider{ return &Twitch{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 23,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="268" preserveAspectRatio="xMidYMid"><path fill="#5a3e85" d="M17 0 0 47v186h64v35h35l35-35h52l70-70V0zm24 23h192v128l-41 41h-64l-35 35v-35H41zm64 117h23V70h-23zm64 0h23V70h-23z"/></svg>`,
displayName: "Twitch", displayName: "Twitch",
pkce: true, pkce: true,
scopes: []string{"user:read:email"}, scopes: []string{"user:read:email"},
+2
View File
@@ -26,6 +26,8 @@ type Twitter struct {
func NewTwitterProvider() *Twitter { func NewTwitterProvider() *Twitter {
return &Twitter{BaseProvider{ return &Twitter{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 13,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300.3"><path d="M179 127 290 0h-26l-97 110L89 0H0l117 167L0 300h26l103-116 82 116h89M36 20h41l187 262h-41"/></svg>`,
displayName: "X/Twitter", displayName: "X/Twitter",
pkce: true, pkce: true,
scopes: []string{ scopes: []string{
+4
View File
@@ -22,6 +22,8 @@ var _ Provider = (*VK)(nil)
// NameVK is the unique name of the VK provider. // NameVK is the unique name of the VK provider.
const NameVK string = "vk" const NameVK string = "vk"
// @todo mark as deprecated
//
// VK allows authentication via VK OAuth2. // VK allows authentication via VK OAuth2.
type VK struct { type VK struct {
BaseProvider BaseProvider
@@ -33,6 +35,8 @@ type VK struct {
func NewVKProvider() *VK { func NewVKProvider() *VK {
return &VK{BaseProvider{ return &VK{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 15,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none"><path fill="#07f" d="M0 23C0 12.2 0 6.7 3.4 3.4 6.7 0 12.2 0 23 0h2c10.8 0 16.3 0 19.6 3.4C48 6.7 48 12.2 48 23v2c0 10.8 0 16.3-3.4 19.6C41.3 48 35.8 48 25 48h-2c-10.8 0-16.3 0-19.6-3.4C0 41.3 0 35.8 0 25z"/><path fill="#fff" d="M25.5 34.6c-10.9 0-17.1-7.5-17.4-20h5.5c.2 9.2 4.2 13 7.4 13.8V14.6h5.2v7.9c3.1-.3 6.4-4 7.6-7.9h5.1a15 15 0 0 1-7 10c2.6 1.2 6.7 4.3 8.2 10h-5.7a10 10 0 0 0-8.2-7.2v7.2z"/></svg>`,
displayName: "ВКонтакте", displayName: "ВКонтакте",
pkce: false, // VK currently doesn't support PKCE and throws an error if PKCE params are send pkce: false, // VK currently doesn't support PKCE and throws an error if PKCE params are send
scopes: []string{"email"}, scopes: []string{"email"},
+2
View File
@@ -26,6 +26,8 @@ type Wakatime struct {
func NewWakatimeProvider() *Wakatime { func NewWakatimeProvider() *Wakatime {
return &Wakatime{BaseProvider{ return &Wakatime{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 26,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340" fill="none"><path stroke="#000" stroke-width="40" d="M170 20a150 150 0 1 0 0 300 150 150 0 0 0 0-300Z" clip-rule="evenodd"/><path fill="#000" stroke="#000" stroke-width="10" d="M190.2 213.5a8 8 0 0 1-7.6 3l-2-.8a9 9 0 0 1-2.3-2l-.9-1.3-8.8-14.2-8.8 14.2a8 8 0 0 1-6.8 4.3 8 8 0 0 1-6.8-4.4l-38.6-56.2a9 9 0 0 1-2-5.8c0-4.8 3.4-8.6 7.7-8.6 2.8 0 5.3 1.6 6.6 4l32.7 48.2 9.1-15a8 8 0 0 1 6.9-4.4q4.2.2 6.4 3.8l9.5 15.5 51.2-73.2q2.3-3.8 6.5-4c4.3 0 7.8 4 7.8 8.7q0 3.2-1.8 5.4z"/></svg>`,
displayName: "WakaTime", displayName: "WakaTime",
pkce: true, pkce: true,
scopes: []string{"email"}, scopes: []string{"email"},
+2
View File
@@ -29,6 +29,8 @@ type Yandex struct {
func NewYandexProvider() *Yandex { func NewYandexProvider() *Yandex {
return &Yandex{BaseProvider{ return &Yandex{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 4,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none"><rect width="44" height="44" fill="#fc3f1d" rx="22"/><path fill="#fff" d="M25.2 12.3H23c-3.8 0-5.7 2-5.7 4.8 0 3.2 1.3 4.8 4.1 6.7l2.3 1.6-6.4 9.8h-5.1l6-8.9c-3.5-2.5-5.4-4.8-5.4-8.9 0-5 3.5-8.6 10.2-8.6h6.7v26.4h-4.5z"/></svg>`,
displayName: "Yandex", displayName: "Yandex",
pkce: true, pkce: true,
scopes: []string{"login:email", "login:avatar", "login:info"}, scopes: []string{"login:email", "login:avatar", "login:info"},
+4 -2
View File
@@ -1,12 +1,14 @@
# all environments should start with 'PB_' prefix # all environments should start with 'PB_' prefix
PB_BACKEND_URL = "../" PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication" PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_EXAMPLE = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2" PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters" PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling" PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files" PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_DOCS_URL = "https://pocketbase.io/docs" PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk" PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk" PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases" PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.36.9" PB_VERSION = "v0.37.0"
+21 -6
View File
@@ -1,7 +1,22 @@
/node_modules/ # Logs
/.vscode/ *.log
.DS_Store npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# exclude local env files node_modules
.env.local *.local
.env.*.local *.todo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8 -5
View File
@@ -1,20 +1,23 @@
PocketBase Superuser dashboard UI PocketBase Superuser dashboard UI
====================================================================== ======================================================================
This is the PocketBase Superuser dashboard UI (built with Svelte and Vite). This is the PocketBase Superuser dashboard UI (built with Shablon and Vite).
Although it could be used independently, it is mainly intended to be embedded Although it could be used independently, it is intended to be embedded and extended
as part of a PocketBase app executable (hence the `embed.go` file). as part of the PocketBase app executable (hence the `dist` directory and `embed.go` file).
> [!WARNING]
> The UI kit and extension APIs remains deliberately undocumented for the time being until a stable PocketBase release is published ([#7612](https://github.com/pocketbase/pocketbase/discussions/7612)).
## Development ## Development
Download the repo and run the appropriate console commands: Download the repository and run the appropriate console commands:
```sh ```sh
# install dependencies # install dependencies
npm install npm install
# start a dev server with hot reload at localhost:3000 # start a dev server with hot reload at localhost:5173
npm run dev npm run dev
# or generate production ready bundle in dist/ directory # or generate production ready bundle in dist/ directory
-39
View File
@@ -1,39 +0,0 @@
import{S as Ce,i as Be,s as Te,V as Le,X as J,h as u,d as ae,t as Q,a as G,I as N,Z as we,_ as Se,C as De,$ as Re,D as Ue,l as d,n as a,m as ne,u as c,A as y,v as k,c as ie,w as h,p as oe,J as je,k as O,o as qe,W as Ee}from"./index-DMlPjiFP.js";import{F as Fe}from"./FieldsQueryParam-B36GF025.js";function ye(n,s,l){const o=n.slice();return o[8]=s[l],o}function Me(n,s,l){const o=n.slice();return o[8]=s[l],o}function Ae(n,s){let l,o=s[8].code+"",p,b,i,f;function m(){return s[6](s[8])}return{key:n,first:null,c(){l=c("button"),p=y(o),b=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(v,$){d(v,l,$),a(l,p),a(l,b),i||(f=qe(l,"click",m),i=!0)},p(v,$){s=v,$&4&&o!==(o=s[8].code+"")&&N(p,o),$&6&&O(l,"active",s[1]===s[8].code)},d(v){v&&u(l),i=!1,f()}}}function Pe(n,s){let l,o,p,b;return o=new Ee({props:{content:s[8].body}}),{key:n,first:null,c(){l=c("div"),ie(o.$$.fragment),p=k(),h(l,"class","tab-item"),O(l,"active",s[1]===s[8].code),this.first=l},m(i,f){d(i,l,f),ne(o,l,null),a(l,p),b=!0},p(i,f){s=i;const m={};f&4&&(m.content=s[8].body),o.$set(m),(!b||f&6)&&O(l,"active",s[1]===s[8].code)},i(i){b||(G(o.$$.fragment,i),b=!0)},o(i){Q(o.$$.fragment,i),b=!1},d(i){i&&u(l),ae(o)}}}function He(n){var ke,ge;let s,l,o=n[0].name+"",p,b,i,f,m,v,$,g=n[0].name+"",V,ce,W,M,X,L,Z,A,E,re,F,S,ue,z,H=n[0].name+"",K,de,Y,D,x,P,ee,fe,te,T,le,R,se,C,U,w=[],me=new Map,pe,j,_=[],be=new Map,B;M=new Le({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${n[3]}');
...
const result = await pb.collection('${(ke=n[0])==null?void 0:ke.name}').listAuthMethods();
`,dart:`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${n[3]}');
...
final result = await pb.collection('${(ge=n[0])==null?void 0:ge.name}').listAuthMethods();
`}}),T=new Fe({});let I=J(n[2]);const he=e=>e[8].code;for(let e=0;e<I.length;e+=1){let t=Me(n,I,e),r=he(t);me.set(r,w[e]=Ae(r,t))}let q=J(n[2]);const _e=e=>e[8].code;for(let e=0;e<q.length;e+=1){let t=ye(n,q,e),r=_e(t);be.set(r,_[e]=Pe(r,t))}return{c(){s=c("h3"),l=y("List auth methods ("),p=y(o),b=y(")"),i=k(),f=c("div"),m=c("p"),v=y("Returns a public list with all allowed "),$=c("strong"),V=y(g),ce=y(" authentication methods."),W=k(),ie(M.$$.fragment),X=k(),L=c("h6"),L.textContent="API details",Z=k(),A=c("div"),E=c("strong"),E.textContent="GET",re=k(),F=c("div"),S=c("p"),ue=y("/api/collections/"),z=c("strong"),K=y(H),de=y("/auth-methods"),Y=k(),D=c("div"),D.textContent="Query parameters",x=k(),P=c("table"),ee=c("thead"),ee.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr>',fe=k(),te=c("tbody"),ie(T.$$.fragment),le=k(),R=c("div"),R.textContent="Responses",se=k(),C=c("div"),U=c("div");for(let e=0;e<w.length;e+=1)w[e].c();pe=k(),j=c("div");for(let e=0;e<_.length;e+=1)_[e].c();h(s,"class","m-b-sm"),h(f,"class","content txt-lg m-b-sm"),h(L,"class","m-b-xs"),h(E,"class","label label-primary"),h(F,"class","content"),h(A,"class","alert alert-info"),h(D,"class","section-title"),h(P,"class","table-compact table-border m-b-base"),h(R,"class","section-title"),h(U,"class","tabs-header compact combined left"),h(j,"class","tabs-content"),h(C,"class","tabs")},m(e,t){d(e,s,t),a(s,l),a(s,p),a(s,b),d(e,i,t),d(e,f,t),a(f,m),a(m,v),a(m,$),a($,V),a(m,ce),d(e,W,t),ne(M,e,t),d(e,X,t),d(e,L,t),d(e,Z,t),d(e,A,t),a(A,E),a(A,re),a(A,F),a(F,S),a(S,ue),a(S,z),a(z,K),a(S,de),d(e,Y,t),d(e,D,t),d(e,x,t),d(e,P,t),a(P,ee),a(P,fe),a(P,te),ne(T,te,null),d(e,le,t),d(e,R,t),d(e,se,t),d(e,C,t),a(C,U);for(let r=0;r<w.length;r+=1)w[r]&&w[r].m(U,null);a(C,pe),a(C,j);for(let r=0;r<_.length;r+=1)_[r]&&_[r].m(j,null);B=!0},p(e,[t]){var ve,$e;(!B||t&1)&&o!==(o=e[0].name+"")&&N(p,o),(!B||t&1)&&g!==(g=e[0].name+"")&&N(V,g);const r={};t&9&&(r.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
...
const result = await pb.collection('${(ve=e[0])==null?void 0:ve.name}').listAuthMethods();
`),t&9&&(r.dart=`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${e[3]}');
...
final result = await pb.collection('${($e=e[0])==null?void 0:$e.name}').listAuthMethods();
`),M.$set(r),(!B||t&1)&&H!==(H=e[0].name+"")&&N(K,H),t&6&&(I=J(e[2]),w=we(w,t,he,1,e,I,me,U,Se,Ae,null,Me)),t&6&&(q=J(e[2]),De(),_=we(_,t,_e,1,e,q,be,j,Re,Pe,null,ye),Ue())},i(e){if(!B){G(M.$$.fragment,e),G(T.$$.fragment,e);for(let t=0;t<q.length;t+=1)G(_[t]);B=!0}},o(e){Q(M.$$.fragment,e),Q(T.$$.fragment,e);for(let t=0;t<_.length;t+=1)Q(_[t]);B=!1},d(e){e&&(u(s),u(i),u(f),u(W),u(X),u(L),u(Z),u(A),u(Y),u(D),u(x),u(P),u(le),u(R),u(se),u(C)),ae(M,e),ae(T);for(let t=0;t<w.length;t+=1)w[t].d();for(let t=0;t<_.length;t+=1)_[t].d()}}}function Ie(n,s,l){let o,{collection:p}=s,b=200,i=[],f={},m=!1;v();async function v(){l(5,m=!0);try{l(4,f=await oe.collection(p.name).listAuthMethods())}catch(g){oe.error(g)}l(5,m=!1)}const $=g=>l(1,b=g.code);return n.$$set=g=>{"collection"in g&&l(0,p=g.collection)},n.$$.update=()=>{n.$$.dirty&48&&l(2,i=[{code:200,body:m?"...":JSON.stringify(f,null,2)},{code:404,body:`
{
"status": 404,
"message": "Missing collection context.",
"data": {}
}
`}])},l(3,o=je.getApiExampleUrl(oe.baseURL)),[p,b,i,o,f,m,$]}class Ge extends Ce{constructor(s){super(),Be(this,s,Ie,He,Te,{collection:0})}}export{Ge as default};
-79
View File
@@ -1,79 +0,0 @@
import{S as je,i as xe,s as Ie,V as Ke,W as Ue,X as I,h as d,d as K,t as E,a as z,I as de,Z as Oe,_ as Qe,C as We,$ as Xe,D as Ze,l as u,n as o,m as Q,u as s,A as k,v as p,c as W,w as b,J as Ve,p as Ge,k as X,o as Ye}from"./index-DMlPjiFP.js";import{F as et}from"./FieldsQueryParam-B36GF025.js";function Ee(r,a,l){const n=r.slice();return n[5]=a[l],n}function ze(r,a,l){const n=r.slice();return n[5]=a[l],n}function Je(r,a){let l,n=a[5].code+"",m,_,i,h;function g(){return a[4](a[5])}return{key:r,first:null,c(){l=s("button"),m=k(n),_=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(v,w){u(v,l,w),o(l,m),o(l,_),i||(h=Ye(l,"click",g),i=!0)},p(v,w){a=v,w&4&&n!==(n=a[5].code+"")&&de(m,n),w&6&&X(l,"active",a[1]===a[5].code)},d(v){v&&d(l),i=!1,h()}}}function Ne(r,a){let l,n,m,_;return n=new Ue({props:{content:a[5].body}}),{key:r,first:null,c(){l=s("div"),W(n.$$.fragment),m=p(),b(l,"class","tab-item"),X(l,"active",a[1]===a[5].code),this.first=l},m(i,h){u(i,l,h),Q(n,l,null),o(l,m),_=!0},p(i,h){a=i;const g={};h&4&&(g.content=a[5].body),n.$set(g),(!_||h&6)&&X(l,"active",a[1]===a[5].code)},i(i){_||(z(n.$$.fragment,i),_=!0)},o(i){E(n.$$.fragment,i),_=!1},d(i){i&&d(l),K(n)}}}function tt(r){var qe,Fe;let a,l,n=r[0].name+"",m,_,i,h,g,v,w,D,Z,S,J,ue,N,M,pe,G,U=r[0].name+"",Y,he,fe,j,ee,q,te,T,oe,be,F,C,ae,me,le,_e,f,ke,P,ge,ve,$e,se,ye,ne,Se,we,Te,re,Ce,Re,A,ie,H,ce,R,L,y=[],Pe=new Map,Ae,O,$=[],Be=new Map,B;v=new Ke({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${r[3]}');
...
const authData = await pb.collection('${(qe=r[0])==null?void 0:qe.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
`,dart:`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${r[3]}');
...
final authData = await pb.collection('${(Fe=r[0])==null?void 0:Fe.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
`}}),P=new Ue({props:{content:"?expand=relField1,relField2.subRelField"}}),A=new et({props:{prefix:"record."}});let x=I(r[2]);const De=e=>e[5].code;for(let e=0;e<x.length;e+=1){let t=ze(r,x,e),c=De(t);Pe.set(c,y[e]=Je(c,t))}let V=I(r[2]);const Me=e=>e[5].code;for(let e=0;e<V.length;e+=1){let t=Ee(r,V,e),c=Me(t);Be.set(c,$[e]=Ne(c,t))}return{c(){a=s("h3"),l=k("Auth refresh ("),m=k(n),_=k(")"),i=p(),h=s("div"),h.innerHTML=`<p>Returns a new auth response (token and record data) for an
<strong>already authenticated record</strong>.</p> <p>This method is usually called by users on page/screen reload to ensure that the previously stored data
in <code>pb.authStore</code> is still valid and up-to-date.</p>`,g=p(),W(v.$$.fragment),w=p(),D=s("h6"),D.textContent="API details",Z=p(),S=s("div"),J=s("strong"),J.textContent="POST",ue=p(),N=s("div"),M=s("p"),pe=k("/api/collections/"),G=s("strong"),Y=k(U),he=k("/auth-refresh"),fe=p(),j=s("p"),j.innerHTML="Requires <code>Authorization:TOKEN</code> header",ee=p(),q=s("div"),q.textContent="Query parameters",te=p(),T=s("table"),oe=s("thead"),oe.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',be=p(),F=s("tbody"),C=s("tr"),ae=s("td"),ae.textContent="expand",me=p(),le=s("td"),le.innerHTML='<span class="label">String</span>',_e=p(),f=s("td"),ke=k(`Auto expand record relations. Ex.:
`),W(P.$$.fragment),ge=k(`
Supports up to 6-levels depth nested relations expansion. `),ve=s("br"),$e=k(`
The expanded relations will be appended to the record under the
`),se=s("code"),se.textContent="expand",ye=k(" property (eg. "),ne=s("code"),ne.textContent='"expand": {"relField1": {...}, ...}',Se=k(`).
`),we=s("br"),Te=k(`
Only the relations to which the request user has permissions to `),re=s("strong"),re.textContent="view",Ce=k(" will be expanded."),Re=p(),W(A.$$.fragment),ie=p(),H=s("div"),H.textContent="Responses",ce=p(),R=s("div"),L=s("div");for(let e=0;e<y.length;e+=1)y[e].c();Ae=p(),O=s("div");for(let e=0;e<$.length;e+=1)$[e].c();b(a,"class","m-b-sm"),b(h,"class","content txt-lg m-b-sm"),b(D,"class","m-b-xs"),b(J,"class","label label-primary"),b(N,"class","content"),b(j,"class","txt-hint txt-sm txt-right"),b(S,"class","alert alert-success"),b(q,"class","section-title"),b(T,"class","table-compact table-border m-b-base"),b(H,"class","section-title"),b(L,"class","tabs-header compact combined left"),b(O,"class","tabs-content"),b(R,"class","tabs")},m(e,t){u(e,a,t),o(a,l),o(a,m),o(a,_),u(e,i,t),u(e,h,t),u(e,g,t),Q(v,e,t),u(e,w,t),u(e,D,t),u(e,Z,t),u(e,S,t),o(S,J),o(S,ue),o(S,N),o(N,M),o(M,pe),o(M,G),o(G,Y),o(M,he),o(S,fe),o(S,j),u(e,ee,t),u(e,q,t),u(e,te,t),u(e,T,t),o(T,oe),o(T,be),o(T,F),o(F,C),o(C,ae),o(C,me),o(C,le),o(C,_e),o(C,f),o(f,ke),Q(P,f,null),o(f,ge),o(f,ve),o(f,$e),o(f,se),o(f,ye),o(f,ne),o(f,Se),o(f,we),o(f,Te),o(f,re),o(f,Ce),o(F,Re),Q(A,F,null),u(e,ie,t),u(e,H,t),u(e,ce,t),u(e,R,t),o(R,L);for(let c=0;c<y.length;c+=1)y[c]&&y[c].m(L,null);o(R,Ae),o(R,O);for(let c=0;c<$.length;c+=1)$[c]&&$[c].m(O,null);B=!0},p(e,[t]){var He,Le;(!B||t&1)&&n!==(n=e[0].name+"")&&de(m,n);const c={};t&9&&(c.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
...
const authData = await pb.collection('${(He=e[0])==null?void 0:He.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
`),t&9&&(c.dart=`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${e[3]}');
...
final authData = await pb.collection('${(Le=e[0])==null?void 0:Le.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
`),v.$set(c),(!B||t&1)&&U!==(U=e[0].name+"")&&de(Y,U),t&6&&(x=I(e[2]),y=Oe(y,t,De,1,e,x,Pe,L,Qe,Je,null,ze)),t&6&&(V=I(e[2]),We(),$=Oe($,t,Me,1,e,V,Be,O,Xe,Ne,null,Ee),Ze())},i(e){if(!B){z(v.$$.fragment,e),z(P.$$.fragment,e),z(A.$$.fragment,e);for(let t=0;t<V.length;t+=1)z($[t]);B=!0}},o(e){E(v.$$.fragment,e),E(P.$$.fragment,e),E(A.$$.fragment,e);for(let t=0;t<$.length;t+=1)E($[t]);B=!1},d(e){e&&(d(a),d(i),d(h),d(g),d(w),d(D),d(Z),d(S),d(ee),d(q),d(te),d(T),d(ie),d(H),d(ce),d(R)),K(v,e),K(P),K(A);for(let t=0;t<y.length;t+=1)y[t].d();for(let t=0;t<$.length;t+=1)$[t].d()}}}function ot(r,a,l){let n,{collection:m}=a,_=200,i=[];const h=g=>l(1,_=g.code);return r.$$set=g=>{"collection"in g&&l(0,m=g.collection)},r.$$.update=()=>{r.$$.dirty&1&&l(2,i=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:Ve.dummyCollectionRecord(m)},null,2)},{code:401,body:`
{
"status": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`},{code:403,body:`
{
"status": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`},{code:404,body:`
{
"status": 404,
"message": "Missing auth record context.",
"data": {}
}
`}])},l(3,n=Ve.getApiExampleUrl(Ge.baseURL)),[m,_,i,n,h]}class st extends je{constructor(a){super(),xe(this,a,ot,tt,Ie,{collection:0})}}export{st as default};
-117
View File
@@ -1,117 +0,0 @@
import{S as Je,i as xe,s as Ee,V as Ne,W as je,X as Q,h as r,d as Z,t as j,a as J,I as pe,Z as Ue,_ as Ie,C as Qe,$ as Ze,D as ze,l as c,n as a,m as z,u as o,A as _,v as h,c as K,w as p,J as Be,p as Ke,k as X,o as Xe}from"./index-DMlPjiFP.js";import{F as Ge}from"./FieldsQueryParam-B36GF025.js";function Fe(s,l,n){const i=s.slice();return i[5]=l[n],i}function Le(s,l,n){const i=s.slice();return i[5]=l[n],i}function He(s,l){let n,i=l[5].code+"",f,g,d,b;function k(){return l[4](l[5])}return{key:s,first:null,c(){n=o("button"),f=_(i),g=h(),p(n,"class","tab-item"),X(n,"active",l[1]===l[5].code),this.first=n},m(v,O){c(v,n,O),a(n,f),a(n,g),d||(b=Xe(n,"click",k),d=!0)},p(v,O){l=v,O&4&&i!==(i=l[5].code+"")&&pe(f,i),O&6&&X(n,"active",l[1]===l[5].code)},d(v){v&&r(n),d=!1,b()}}}function Ve(s,l){let n,i,f,g;return i=new je({props:{content:l[5].body}}),{key:s,first:null,c(){n=o("div"),K(i.$$.fragment),f=h(),p(n,"class","tab-item"),X(n,"active",l[1]===l[5].code),this.first=n},m(d,b){c(d,n,b),z(i,n,null),a(n,f),g=!0},p(d,b){l=d;const k={};b&4&&(k.content=l[5].body),i.$set(k),(!g||b&6)&&X(n,"active",l[1]===l[5].code)},i(d){g||(J(i.$$.fragment,d),g=!0)},o(d){j(i.$$.fragment,d),g=!1},d(d){d&&r(n),Z(i)}}}function Ye(s){let l,n,i=s[0].name+"",f,g,d,b,k,v,O,R,G,A,x,be,E,P,me,Y,N=s[0].name+"",ee,fe,te,M,ae,W,le,U,ne,y,oe,ge,B,S,se,_e,ie,ke,m,ve,C,we,$e,Oe,re,Ae,ce,ye,Se,Te,de,Ce,qe,q,ue,F,he,T,L,$=[],De=new Map,Re,H,w=[],Pe=new Map,D;v=new Ne({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${s[3]}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${s[3]}/api/oauth2-redirect as redirect url.
const authData = await pb.collection('${s[0].name}').authWithOAuth2({ provider: 'google' });
// OR authenticate with manual OAuth2 code exchange
// const authData = await pb.collection('${s[0].name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,dart:`
import 'package:pocketbase/pocketbase.dart';
import 'package:url_launcher/url_launcher.dart';
final pb = PocketBase('${s[3]}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${s[3]}/api/oauth2-redirect as redirect url.
final authData = await pb.collection('${s[0].name}').authWithOAuth2('google', (url) async {
await launchUrl(url);
});
// OR authenticate with manual OAuth2 code exchange
// final authData = await pb.collection('${s[0].name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`}}),C=new je({props:{content:"?expand=relField1,relField2.subRelField"}}),q=new Ge({props:{prefix:"record."}});let I=Q(s[2]);const Me=e=>e[5].code;for(let e=0;e<I.length;e+=1){let t=Le(s,I,e),u=Me(t);De.set(u,$[e]=He(u,t))}let V=Q(s[2]);const We=e=>e[5].code;for(let e=0;e<V.length;e+=1){let t=Fe(s,V,e),u=We(t);Pe.set(u,w[e]=Ve(u,t))}return{c(){l=o("h3"),n=_("Auth with OAuth2 ("),f=_(i),g=_(")"),d=h(),b=o("div"),b.innerHTML=`<p>Authenticate with an OAuth2 provider and returns a new auth token and record data.</p> <p>For more details please check the
<a href="https://pocketbase.io/docs/authentication#authenticate-with-oauth2" target="_blank" rel="noopener noreferrer">OAuth2 integration documentation
</a>.</p>`,k=h(),K(v.$$.fragment),O=h(),R=o("h6"),R.textContent="API details",G=h(),A=o("div"),x=o("strong"),x.textContent="POST",be=h(),E=o("div"),P=o("p"),me=_("/api/collections/"),Y=o("strong"),ee=_(N),fe=_("/auth-with-oauth2"),te=h(),M=o("div"),M.textContent="Body Parameters",ae=h(),W=o("table"),W.innerHTML=`<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>provider</span></div></td> <td><span class="label">String</span></td> <td>The name of the OAuth2 client provider (eg. &quot;google&quot;).</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>code</span></div></td> <td><span class="label">String</span></td> <td>The authorization code returned from the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>codeVerifier</span></div></td> <td><span class="label">String</span></td> <td>The code verifier sent with the initial request as part of the code_challenge.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>redirectURL</span></div></td> <td><span class="label">String</span></td> <td>The redirect url sent with the initial request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-warning">Optional</span> <span>createData</span></div></td> <td><span class="label">Object</span></td> <td><p>Optional data that will be used when creating the auth record on OAuth2 sign-up.</p> <p>The created auth record must comply with the same requirements and validations in the
regular <strong>create</strong> action.
<br/> <em>The data can only be in <code>json</code>, aka. <code>multipart/form-data</code> and files
upload currently are not supported during OAuth2 sign-ups.</em></p></td></tr></tbody>`,le=h(),U=o("div"),U.textContent="Query parameters",ne=h(),y=o("table"),oe=o("thead"),oe.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',ge=h(),B=o("tbody"),S=o("tr"),se=o("td"),se.textContent="expand",_e=h(),ie=o("td"),ie.innerHTML='<span class="label">String</span>',ke=h(),m=o("td"),ve=_(`Auto expand record relations. Ex.:
`),K(C.$$.fragment),we=_(`
Supports up to 6-levels depth nested relations expansion. `),$e=o("br"),Oe=_(`
The expanded relations will be appended to the record under the
`),re=o("code"),re.textContent="expand",Ae=_(" property (eg. "),ce=o("code"),ce.textContent='"expand": {"relField1": {...}, ...}',ye=_(`).
`),Se=o("br"),Te=_(`
Only the relations to which the request user has permissions to `),de=o("strong"),de.textContent="view",Ce=_(" will be expanded."),qe=h(),K(q.$$.fragment),ue=h(),F=o("div"),F.textContent="Responses",he=h(),T=o("div"),L=o("div");for(let e=0;e<$.length;e+=1)$[e].c();Re=h(),H=o("div");for(let e=0;e<w.length;e+=1)w[e].c();p(l,"class","m-b-sm"),p(b,"class","content txt-lg m-b-sm"),p(R,"class","m-b-xs"),p(x,"class","label label-primary"),p(E,"class","content"),p(A,"class","alert alert-success"),p(M,"class","section-title"),p(W,"class","table-compact table-border m-b-base"),p(U,"class","section-title"),p(y,"class","table-compact table-border m-b-base"),p(F,"class","section-title"),p(L,"class","tabs-header compact combined left"),p(H,"class","tabs-content"),p(T,"class","tabs")},m(e,t){c(e,l,t),a(l,n),a(l,f),a(l,g),c(e,d,t),c(e,b,t),c(e,k,t),z(v,e,t),c(e,O,t),c(e,R,t),c(e,G,t),c(e,A,t),a(A,x),a(A,be),a(A,E),a(E,P),a(P,me),a(P,Y),a(Y,ee),a(P,fe),c(e,te,t),c(e,M,t),c(e,ae,t),c(e,W,t),c(e,le,t),c(e,U,t),c(e,ne,t),c(e,y,t),a(y,oe),a(y,ge),a(y,B),a(B,S),a(S,se),a(S,_e),a(S,ie),a(S,ke),a(S,m),a(m,ve),z(C,m,null),a(m,we),a(m,$e),a(m,Oe),a(m,re),a(m,Ae),a(m,ce),a(m,ye),a(m,Se),a(m,Te),a(m,de),a(m,Ce),a(B,qe),z(q,B,null),c(e,ue,t),c(e,F,t),c(e,he,t),c(e,T,t),a(T,L);for(let u=0;u<$.length;u+=1)$[u]&&$[u].m(L,null);a(T,Re),a(T,H);for(let u=0;u<w.length;u+=1)w[u]&&w[u].m(H,null);D=!0},p(e,[t]){(!D||t&1)&&i!==(i=e[0].name+"")&&pe(f,i);const u={};t&9&&(u.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${e[3]}/api/oauth2-redirect as redirect url.
const authData = await pb.collection('${e[0].name}').authWithOAuth2({ provider: 'google' });
// OR authenticate with manual OAuth2 code exchange
// const authData = await pb.collection('${e[0].name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`),t&9&&(u.dart=`
import 'package:pocketbase/pocketbase.dart';
import 'package:url_launcher/url_launcher.dart';
final pb = PocketBase('${e[3]}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${e[3]}/api/oauth2-redirect as redirect url.
final authData = await pb.collection('${e[0].name}').authWithOAuth2('google', (url) async {
await launchUrl(url);
});
// OR authenticate with manual OAuth2 code exchange
// final authData = await pb.collection('${e[0].name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`),v.$set(u),(!D||t&1)&&N!==(N=e[0].name+"")&&pe(ee,N),t&6&&(I=Q(e[2]),$=Ue($,t,Me,1,e,I,De,L,Ie,He,null,Le)),t&6&&(V=Q(e[2]),Qe(),w=Ue(w,t,We,1,e,V,Pe,H,Ze,Ve,null,Fe),ze())},i(e){if(!D){J(v.$$.fragment,e),J(C.$$.fragment,e),J(q.$$.fragment,e);for(let t=0;t<V.length;t+=1)J(w[t]);D=!0}},o(e){j(v.$$.fragment,e),j(C.$$.fragment,e),j(q.$$.fragment,e);for(let t=0;t<w.length;t+=1)j(w[t]);D=!1},d(e){e&&(r(l),r(d),r(b),r(k),r(O),r(R),r(G),r(A),r(te),r(M),r(ae),r(W),r(le),r(U),r(ne),r(y),r(ue),r(F),r(he),r(T)),Z(v,e),Z(C),Z(q);for(let t=0;t<$.length;t+=1)$[t].d();for(let t=0;t<w.length;t+=1)w[t].d()}}}function et(s,l,n){let i,{collection:f}=l,g=200,d=[];const b=k=>n(1,g=k.code);return s.$$set=k=>{"collection"in k&&n(0,f=k.collection)},s.$$.update=()=>{s.$$.dirty&1&&n(2,d=[{code:200,body:JSON.stringify({token:"JWT_AUTH_TOKEN",record:Be.dummyCollectionRecord(f),meta:{id:"abc123",name:"John Doe",username:"john.doe",email:"test@example.com",avatarURL:"https://example.com/avatar.png",accessToken:"...",refreshToken:"...",expiry:"2022-01-01 10:00:00.123Z",isNew:!1,rawUser:{}}},null,2)},{code:400,body:`
{
"status": 400,
"message": "An error occurred while submitting the form.",
"data": {
"provider": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`}])},n(3,i=Be.getApiExampleUrl(Ke.baseURL)),[f,g,d,i,b]}class lt extends Je{constructor(l){super(),xe(this,l,et,Ye,Ee,{collection:0})}}export{lt as default};
-136
View File
@@ -1,136 +0,0 @@
import{S as be,i as _e,s as ve,W as ge,X as V,h as b,d as x,t as j,a as J,I as ce,Z as de,_ as je,C as ue,$ as Qe,D as he,l as _,n as s,m as ee,u as d,v as T,A as R,c as te,w as g,J as ke,k as N,o as $e,V as Ke,Y as De,p as Xe,a0 as Me}from"./index-DMlPjiFP.js";import{F as Ze}from"./FieldsQueryParam-B36GF025.js";function Be(a,t,e){const l=a.slice();return l[4]=t[e],l}function Ie(a,t,e){const l=a.slice();return l[4]=t[e],l}function We(a,t){let e,l=t[4].code+"",h,i,c,n;function m(){return t[3](t[4])}return{key:a,first:null,c(){e=d("button"),h=R(l),i=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(v,C){_(v,e,C),s(e,h),s(e,i),c||(n=$e(e,"click",m),c=!0)},p(v,C){t=v,C&4&&l!==(l=t[4].code+"")&&ce(h,l),C&6&&N(e,"active",t[1]===t[4].code)},d(v){v&&b(e),c=!1,n()}}}function Fe(a,t){let e,l,h,i;return l=new ge({props:{content:t[4].body}}),{key:a,first:null,c(){e=d("div"),te(l.$$.fragment),h=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(c,n){_(c,e,n),ee(l,e,null),s(e,h),i=!0},p(c,n){t=c;const m={};n&4&&(m.content=t[4].body),l.$set(m),(!i||n&6)&&N(e,"active",t[1]===t[4].code)},i(c){i||(J(l.$$.fragment,c),i=!0)},o(c){j(l.$$.fragment,c),i=!1},d(c){c&&b(e),x(l)}}}function ze(a){let t,e,l,h,i,c,n,m=a[0].name+"",v,C,F,B,I,D,Q,M,U,y,O,q,k,L,Y,A,X,E,o,$,P,z,u,p,S,w,Z,we,Te,Pe,pe,Oe,ye,le,fe,oe,me,G,ae,K=[],Se=new Map,qe,ne,H=[],Ce=new Map,se;P=new ge({props:{content:"?expand=relField1,relField2.subRelField"}}),le=new Ze({props:{prefix:"record."}});let re=V(a[2]);const Ae=r=>r[4].code;for(let r=0;r<re.length;r+=1){let f=Ie(a,re,r),W=Ae(f);Se.set(W,K[r]=We(W,f))}let ie=V(a[2]);const Re=r=>r[4].code;for(let r=0;r<ie.length;r+=1){let f=Be(a,ie,r),W=Re(f);Ce.set(W,H[r]=Fe(W,f))}return{c(){t=d("div"),e=d("strong"),e.textContent="POST",l=T(),h=d("div"),i=d("p"),c=R("/api/collections/"),n=d("strong"),v=R(m),C=R("/auth-with-otp"),F=T(),B=d("div"),B.textContent="Body Parameters",I=T(),D=d("table"),D.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>otpId</span></div></td> <td><span class="label">String</span></td> <td>The id of the OTP request.</td></tr> <tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>password</span></div></td> <td><span class="label">String</span></td> <td>The one-time password.</td></tr></tbody>',Q=T(),M=d("div"),M.textContent="Query parameters",U=T(),y=d("table"),O=d("thead"),O.innerHTML='<tr><th>Param</th> <th>Type</th> <th width="60%">Description</th></tr>',q=T(),k=d("tbody"),L=d("tr"),Y=d("td"),Y.textContent="expand",A=T(),X=d("td"),X.innerHTML='<span class="label">String</span>',E=T(),o=d("td"),$=R(`Auto expand record relations. Ex.:
`),te(P.$$.fragment),z=R(`
Supports up to 6-levels depth nested relations expansion. `),u=d("br"),p=R(`
The expanded relations will be appended to the record under the
`),S=d("code"),S.textContent="expand",w=R(" property (eg. "),Z=d("code"),Z.textContent='"expand": {"relField1": {...}, ...}',we=R(`).
`),Te=d("br"),Pe=R(`
Only the relations to which the request user has permissions to `),pe=d("strong"),pe.textContent="view",Oe=R(" will be expanded."),ye=T(),te(le.$$.fragment),fe=T(),oe=d("div"),oe.textContent="Responses",me=T(),G=d("div"),ae=d("div");for(let r=0;r<K.length;r+=1)K[r].c();qe=T(),ne=d("div");for(let r=0;r<H.length;r+=1)H[r].c();g(e,"class","label label-primary"),g(h,"class","content"),g(t,"class","alert alert-success"),g(B,"class","section-title"),g(D,"class","table-compact table-border m-b-base"),g(M,"class","section-title"),g(y,"class","table-compact table-border m-b-base"),g(oe,"class","section-title"),g(ae,"class","tabs-header compact combined left"),g(ne,"class","tabs-content"),g(G,"class","tabs")},m(r,f){_(r,t,f),s(t,e),s(t,l),s(t,h),s(h,i),s(i,c),s(i,n),s(n,v),s(i,C),_(r,F,f),_(r,B,f),_(r,I,f),_(r,D,f),_(r,Q,f),_(r,M,f),_(r,U,f),_(r,y,f),s(y,O),s(y,q),s(y,k),s(k,L),s(L,Y),s(L,A),s(L,X),s(L,E),s(L,o),s(o,$),ee(P,o,null),s(o,z),s(o,u),s(o,p),s(o,S),s(o,w),s(o,Z),s(o,we),s(o,Te),s(o,Pe),s(o,pe),s(o,Oe),s(k,ye),ee(le,k,null),_(r,fe,f),_(r,oe,f),_(r,me,f),_(r,G,f),s(G,ae);for(let W=0;W<K.length;W+=1)K[W]&&K[W].m(ae,null);s(G,qe),s(G,ne);for(let W=0;W<H.length;W+=1)H[W]&&H[W].m(ne,null);se=!0},p(r,[f]){(!se||f&1)&&m!==(m=r[0].name+"")&&ce(v,m),f&6&&(re=V(r[2]),K=de(K,f,Ae,1,r,re,Se,ae,je,We,null,Ie)),f&6&&(ie=V(r[2]),ue(),H=de(H,f,Re,1,r,ie,Ce,ne,Qe,Fe,null,Be),he())},i(r){if(!se){J(P.$$.fragment,r),J(le.$$.fragment,r);for(let f=0;f<ie.length;f+=1)J(H[f]);se=!0}},o(r){j(P.$$.fragment,r),j(le.$$.fragment,r);for(let f=0;f<H.length;f+=1)j(H[f]);se=!1},d(r){r&&(b(t),b(F),b(B),b(I),b(D),b(Q),b(M),b(U),b(y),b(fe),b(oe),b(me),b(G)),x(P),x(le);for(let f=0;f<K.length;f+=1)K[f].d();for(let f=0;f<H.length;f+=1)H[f].d()}}}function Ge(a,t,e){let{collection:l}=t,h=200,i=[];const c=n=>e(1,h=n.code);return a.$$set=n=>{"collection"in n&&e(0,l=n.collection)},a.$$.update=()=>{a.$$.dirty&1&&e(2,i=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:ke.dummyCollectionRecord(l)},null,2)},{code:400,body:`
{
"status": 400,
"message": "Failed to authenticate.",
"data": {
"otpId": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`}])},[l,h,i,c]}class xe extends be{constructor(t){super(),_e(this,t,Ge,ze,ve,{collection:0})}}function Ue(a,t,e){const l=a.slice();return l[4]=t[e],l}function He(a,t,e){const l=a.slice();return l[4]=t[e],l}function Le(a,t){let e,l=t[4].code+"",h,i,c,n;function m(){return t[3](t[4])}return{key:a,first:null,c(){e=d("button"),h=R(l),i=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(v,C){_(v,e,C),s(e,h),s(e,i),c||(n=$e(e,"click",m),c=!0)},p(v,C){t=v,C&4&&l!==(l=t[4].code+"")&&ce(h,l),C&6&&N(e,"active",t[1]===t[4].code)},d(v){v&&b(e),c=!1,n()}}}function Ye(a,t){let e,l,h,i;return l=new ge({props:{content:t[4].body}}),{key:a,first:null,c(){e=d("div"),te(l.$$.fragment),h=T(),g(e,"class","tab-item"),N(e,"active",t[1]===t[4].code),this.first=e},m(c,n){_(c,e,n),ee(l,e,null),s(e,h),i=!0},p(c,n){t=c;const m={};n&4&&(m.content=t[4].body),l.$set(m),(!i||n&6)&&N(e,"active",t[1]===t[4].code)},i(c){i||(J(l.$$.fragment,c),i=!0)},o(c){j(l.$$.fragment,c),i=!1},d(c){c&&b(e),x(l)}}}function et(a){let t,e,l,h,i,c,n,m=a[0].name+"",v,C,F,B,I,D,Q,M,U,y,O,q=[],k=new Map,L,Y,A=[],X=new Map,E,o=V(a[2]);const $=u=>u[4].code;for(let u=0;u<o.length;u+=1){let p=He(a,o,u),S=$(p);k.set(S,q[u]=Le(S,p))}let P=V(a[2]);const z=u=>u[4].code;for(let u=0;u<P.length;u+=1){let p=Ue(a,P,u),S=z(p);X.set(S,A[u]=Ye(S,p))}return{c(){t=d("div"),e=d("strong"),e.textContent="POST",l=T(),h=d("div"),i=d("p"),c=R("/api/collections/"),n=d("strong"),v=R(m),C=R("/request-otp"),F=T(),B=d("div"),B.textContent="Body Parameters",I=T(),D=d("table"),D.innerHTML='<thead><tr><th>Param</th> <th>Type</th> <th width="50%">Description</th></tr></thead> <tbody><tr><td><div class="inline-flex"><span class="label label-success">Required</span> <span>email</span></div></td> <td><span class="label">String</span></td> <td>The auth record email address to send the OTP request (if exists).</td></tr></tbody>',Q=T(),M=d("div"),M.textContent="Responses",U=T(),y=d("div"),O=d("div");for(let u=0;u<q.length;u+=1)q[u].c();L=T(),Y=d("div");for(let u=0;u<A.length;u+=1)A[u].c();g(e,"class","label label-primary"),g(h,"class","content"),g(t,"class","alert alert-success"),g(B,"class","section-title"),g(D,"class","table-compact table-border m-b-base"),g(M,"class","section-title"),g(O,"class","tabs-header compact combined left"),g(Y,"class","tabs-content"),g(y,"class","tabs")},m(u,p){_(u,t,p),s(t,e),s(t,l),s(t,h),s(h,i),s(i,c),s(i,n),s(n,v),s(i,C),_(u,F,p),_(u,B,p),_(u,I,p),_(u,D,p),_(u,Q,p),_(u,M,p),_(u,U,p),_(u,y,p),s(y,O);for(let S=0;S<q.length;S+=1)q[S]&&q[S].m(O,null);s(y,L),s(y,Y);for(let S=0;S<A.length;S+=1)A[S]&&A[S].m(Y,null);E=!0},p(u,[p]){(!E||p&1)&&m!==(m=u[0].name+"")&&ce(v,m),p&6&&(o=V(u[2]),q=de(q,p,$,1,u,o,k,O,je,Le,null,He)),p&6&&(P=V(u[2]),ue(),A=de(A,p,z,1,u,P,X,Y,Qe,Ye,null,Ue),he())},i(u){if(!E){for(let p=0;p<P.length;p+=1)J(A[p]);E=!0}},o(u){for(let p=0;p<A.length;p+=1)j(A[p]);E=!1},d(u){u&&(b(t),b(F),b(B),b(I),b(D),b(Q),b(M),b(U),b(y));for(let p=0;p<q.length;p+=1)q[p].d();for(let p=0;p<A.length;p+=1)A[p].d()}}}function tt(a,t,e){let{collection:l}=t,h=200,i=[];const c=n=>e(1,h=n.code);return a.$$set=n=>{"collection"in n&&e(0,l=n.collection)},e(2,i=[{code:200,body:JSON.stringify({otpId:ke.randomString(15)},null,2)},{code:400,body:`
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"email": {
"code": "validation_is_email",
"message": "Must be a valid email address."
}
}
}
`},{code:429,body:`
{
"status": 429,
"message": "You've send too many OTP requests, please try again later.",
"data": {}
}
`}]),[l,h,i,c]}class lt extends be{constructor(t){super(),_e(this,t,tt,et,ve,{collection:0})}}function Ve(a,t,e){const l=a.slice();return l[5]=t[e],l[7]=e,l}function Je(a,t,e){const l=a.slice();return l[5]=t[e],l[7]=e,l}function Ne(a){let t,e,l,h,i;function c(){return a[4](a[7])}return{c(){t=d("button"),e=d("div"),e.textContent=`${a[5].title}`,l=T(),g(e,"class","txt"),g(t,"class","tab-item"),N(t,"active",a[1]==a[7])},m(n,m){_(n,t,m),s(t,e),s(t,l),h||(i=$e(t,"click",c),h=!0)},p(n,m){a=n,m&2&&N(t,"active",a[1]==a[7])},d(n){n&&b(t),h=!1,i()}}}function Ee(a){let t,e,l,h;var i=a[5].component;function c(n,m){return{props:{collection:n[0]}}}return i&&(e=Me(i,c(a))),{c(){t=d("div"),e&&te(e.$$.fragment),l=T(),g(t,"class","tab-item"),N(t,"active",a[1]==a[7])},m(n,m){_(n,t,m),e&&ee(e,t,null),s(t,l),h=!0},p(n,m){if(i!==(i=n[5].component)){if(e){ue();const v=e;j(v.$$.fragment,1,0,()=>{x(v,1)}),he()}i?(e=Me(i,c(n)),te(e.$$.fragment),J(e.$$.fragment,1),ee(e,t,l)):e=null}else if(i){const v={};m&1&&(v.collection=n[0]),e.$set(v)}(!h||m&2)&&N(t,"active",n[1]==n[7])},i(n){h||(e&&J(e.$$.fragment,n),h=!0)},o(n){e&&j(e.$$.fragment,n),h=!1},d(n){n&&b(t),e&&x(e)}}}function ot(a){var Y,A,X,E;let t,e,l=a[0].name+"",h,i,c,n,m,v,C,F,B,I,D,Q,M,U;v=new Ke({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[2]}');
...
// send OTP email to the provided auth record
const req = await pb.collection('${(Y=a[0])==null?void 0:Y.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
const authData = await pb.collection('${(A=a[0])==null?void 0:A.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,dart:`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${a[2]}');
...
// send OTP email to the provided auth record
final req = await pb.collection('${(X=a[0])==null?void 0:X.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
final authData = await pb.collection('${(E=a[0])==null?void 0:E.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`}});let y=V(a[3]),O=[];for(let o=0;o<y.length;o+=1)O[o]=Ne(Je(a,y,o));let q=V(a[3]),k=[];for(let o=0;o<q.length;o+=1)k[o]=Ee(Ve(a,q,o));const L=o=>j(k[o],1,1,()=>{k[o]=null});return{c(){t=d("h3"),e=R("Auth with OTP ("),h=R(l),i=R(")"),c=T(),n=d("div"),n.innerHTML=`<p>Authenticate with an one-time password (OTP).</p> <p>Note that when requesting an OTP we return an <code>otpId</code> even if a user with the provided email
doesn&#39;t exist as a very basic enumeration protection.</p>`,m=T(),te(v.$$.fragment),C=T(),F=d("h6"),F.textContent="API details",B=T(),I=d("div"),D=d("div");for(let o=0;o<O.length;o+=1)O[o].c();Q=T(),M=d("div");for(let o=0;o<k.length;o+=1)k[o].c();g(t,"class","m-b-sm"),g(n,"class","content txt-lg m-b-sm"),g(F,"class","m-b-xs"),g(D,"class","tabs-header compact"),g(M,"class","tabs-content"),g(I,"class","tabs")},m(o,$){_(o,t,$),s(t,e),s(t,h),s(t,i),_(o,c,$),_(o,n,$),_(o,m,$),ee(v,o,$),_(o,C,$),_(o,F,$),_(o,B,$),_(o,I,$),s(I,D);for(let P=0;P<O.length;P+=1)O[P]&&O[P].m(D,null);s(I,Q),s(I,M);for(let P=0;P<k.length;P+=1)k[P]&&k[P].m(M,null);U=!0},p(o,[$]){var z,u,p,S;(!U||$&1)&&l!==(l=o[0].name+"")&&ce(h,l);const P={};if($&5&&(P.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${o[2]}');
...
// send OTP email to the provided auth record
const req = await pb.collection('${(z=o[0])==null?void 0:z.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
const authData = await pb.collection('${(u=o[0])==null?void 0:u.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`),$&5&&(P.dart=`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${o[2]}');
...
// send OTP email to the provided auth record
final req = await pb.collection('${(p=o[0])==null?void 0:p.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
final authData = await pb.collection('${(S=o[0])==null?void 0:S.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`),v.$set(P),$&10){y=V(o[3]);let w;for(w=0;w<y.length;w+=1){const Z=Je(o,y,w);O[w]?O[w].p(Z,$):(O[w]=Ne(Z),O[w].c(),O[w].m(D,null))}for(;w<O.length;w+=1)O[w].d(1);O.length=y.length}if($&11){q=V(o[3]);let w;for(w=0;w<q.length;w+=1){const Z=Ve(o,q,w);k[w]?(k[w].p(Z,$),J(k[w],1)):(k[w]=Ee(Z),k[w].c(),J(k[w],1),k[w].m(M,null))}for(ue(),w=q.length;w<k.length;w+=1)L(w);he()}},i(o){if(!U){J(v.$$.fragment,o);for(let $=0;$<q.length;$+=1)J(k[$]);U=!0}},o(o){j(v.$$.fragment,o),k=k.filter(Boolean);for(let $=0;$<k.length;$+=1)j(k[$]);U=!1},d(o){o&&(b(t),b(c),b(n),b(m),b(C),b(F),b(B),b(I)),x(v,o),De(O,o),De(k,o)}}}function at(a,t,e){let l,{collection:h}=t;const i=[{title:"OTP Request",component:lt},{title:"OTP Auth",component:xe}];let c=0;const n=m=>e(1,c=m);return a.$$set=m=>{"collection"in m&&e(0,h=m.collection)},e(2,l=ke.getApiExampleUrl(Xe.baseURL)),[h,c,l,i,n]}class it extends be{constructor(t){super(),_e(this,t,at,ot,ve,{collection:0})}}export{it as default};

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