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
- 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
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.
- [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
- [Making changes in the Superuser UI](#making-changes-in-the-admin-ui)
## Prerequisites
- Go 1.25+ (for making changes in the Go code)
- Node 18+ (for making changes in the Admin UI)
- Node 24+ (for making changes in the Superuser UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
@@ -34,7 +37,7 @@ So, let's assume that you already done some changes in the PocketBase Go code an
1. Navigate to `examples/base`
2. Run `go run main.go serve`
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
This will start a web server on `http://localhost:8090` with the embedded prebuilt Superuser UI from `ui/dist`. And that's it!
**Before making a PR to the main repository, it is a good idea to:**
@@ -57,11 +60,11 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
make lint
```
## Making changes in the Admin UI
## Making changes in the Superuser UI
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
PocketBase Superuser UI is a single-page application (SPA) built with Svelte and Vite.
To start the Admin UI:
To start the Superuser UI:
1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies
@@ -70,13 +73,13 @@ To start the Admin UI:
npm run dev
```
You could open the browser and access the running Admin UI at `http://localhost:3000`.
You could open the browser and access the running Superuser UI at `http://localhost:5173`.
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
Since the Superuser UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
> [!NOTE]
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
> By default, the Superuser UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
Every change you make in the Superuser UI should be automatically reflected in the browser at `http://localhost:5173` without reloading the page.
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
Once you are done with your changes, you have to build the Superuser UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
+7 -4
View File
@@ -1,6 +1,6 @@
<p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener">
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
<img src="https://i.imgur.com/3FTfaXH.png" alt="PocketBase - open source backend in 1 file" />
</a>
</p>
@@ -146,10 +146,13 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
Please refrain creating PRs for _new features_ without previously discussing the implementation details.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
> [!IMPORTANT]
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
> If you stumble on 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.
const StaticWildcardParam = "path"
// NewRouter returns a new router instance loaded with the default app middlewares and api routes.
// NewRouter returns a new router instance loaded with the default app middlewares and routes.
func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
event := new(core.RequestEvent)
@@ -34,6 +34,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
// API routes
apiGroup := pbRouter.Group("/api")
bindSettingsApi(app, apiGroup)
bindCollectionApi(app, apiGroup)
@@ -47,6 +48,9 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup)
// UI routes
bindUIExtensions(app)
return pbRouter, nil
}
+87
View File
@@ -3,10 +3,12 @@ package apis
import (
"errors"
"net/http"
"slices"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
@@ -23,6 +25,10 @@ func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
subGroup.DELETE("/{collection}/truncate", collectionTruncate)
subGroup.PUT("/import", collectionsImport)
subGroup.GET("/meta/scaffolds", collectionScaffolds)
// @todo experimental
subGroup.GET("/meta/oauth2-providers", collectionListOAuth2Providers)
subGroup.POST("/meta/dry-run-view", collectionDryRunView)
}
func collectionsList(e *core.RequestEvent) error {
@@ -207,3 +213,84 @@ func collectionScaffolds(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, collections)
}
type providerListItem struct {
order int
Name string `json:"name"`
DisplayName string `json:"displayName"`
Logo string `json:"logo"`
}
func collectionListOAuth2Providers(e *core.RequestEvent) error {
providers := make([]*providerListItem, 0, len(auth.Providers))
for name, factory := range auth.Providers {
p := factory()
providers = append(providers, &providerListItem{
order: p.Order(),
Name: name,
DisplayName: p.DisplayName(),
Logo: p.Logo(),
})
}
slices.SortStableFunc(providers, func(a, b *providerListItem) int {
// sort by order
if a.order < b.order {
return -1
}
if a.order > b.order {
return 1
}
// fallback sort by name
if a.Name < b.Name {
return -1
}
if a.Name > b.Name {
return 1
}
return 0
})
return e.JSON(http.StatusOK, providers)
}
func collectionDryRunView(e *core.RequestEvent) error {
// extra precaution in case reused in custom route group
if !e.HasSuperuserAuth() {
return e.ForbiddenError("", nil)
}
form := dryRunViewForm{}
err := e.BindBody(&form)
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
}
err = form.validate()
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
result, err := e.App.DryRunView(form.Query, 10)
if err != nil {
return firstApiError(err, e.BadRequestError("Invalid view query. Raw error: \n"+err.Error(), nil))
}
return e.JSON(http.StatusOK, result)
}
type dryRunViewForm struct {
Query string `form:"query" json:"query"`
}
func (form *dryRunViewForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
)
}
+190 -6
View File
@@ -536,7 +536,7 @@ func TestCollectionCreate(t *testing.T) {
`"type":"base"`,
`"system":false`,
// ensures that id field was prepended
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -585,7 +585,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"verified"`,
`"duration":123`,
// should overwrite the user required option but keep the min value
`{"autogeneratePattern":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
`{"autogeneratePattern":"","help":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
},
NotExpectedContent: []string{
`"secret":"`,
@@ -751,7 +751,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new",
"type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery":"invalid"
"viewQuery":"select '123' as abc"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -780,7 +780,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new",
"type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + `"
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + ` limit 1"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -789,7 +789,7 @@ func TestCollectionCreate(t *testing.T) {
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
`"fields":[{"autogeneratePattern":"","help":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -1262,7 +1262,7 @@ func TestCollectionUpdate(t *testing.T) {
Body: strings.NewReader(`{
"name":"view2_update",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + `"
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + ` limit 1"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -1584,3 +1584,187 @@ func TestCollectionTruncate(t *testing.T) {
scenario.Test(t)
}
}
func TestCollectionOAuth2Providers(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`,
},
NotExpectedContent: []string{
`"order":`,
`"pkce":`,
`"scopes":`,
`"authURL":`,
`"tokenURL":`,
`"userInfoURL":`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestCollectionTestView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"sample":[{`,
`"id":"1"`,
},
},
{
Name: "empty query",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":""}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"query":`,
},
},
{
Name: "query length beyond validator limit",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"query":`,
},
},
{
Name: "query with length equal to the validator limit",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id` + strings.Repeat(" ", 4986) + `"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"sample":[`,
`"id":"1"`,
},
},
{
Name: "missing ids sample",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"(select 1 as id union select '' as id)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
{
Name: "duplicated ids sample",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"(select 1 as id union all select 1 as id)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
{
Name: "write query",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"CREATE TABLE t1(x INT)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+94
View File
@@ -0,0 +1,94 @@
package apis
import (
"bytes"
"errors"
"fmt"
"io"
"log/slog"
"os"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/ui"
)
// bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router.
//
// This method does nothing if the superuser UI is not bundled (aka. build with "no_ui" tag),
func bindUIExtensions(app core.App) {
if ui.DistDirFS == nil {
return
}
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Priority: 9999, // execute as latest as possible
Func: func(se *core.ServeEvent) error {
uiGroup := se.Router.Group("/_").
BindFunc(func(e *core.RequestEvent) error {
if !e.App.IsDev() && e.Response.Header().Get("Cache-Control") == "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
}
return e.Next()
}).
Bind(Gzip())
// register static extension routes
for _, ext := range se.UIExtensions {
if ext.Name == "" || ext.FS == nil {
se.App.Logger().Debug("Invalid UI extension configuration", slog.Any("extension", ext))
continue
}
uiGroup.GET("/extensions/"+ext.Name+"/{path...}", Static(ext.FS, false))
}
// combine all extensions main.js in one file
//
// note: don't cache in memory to allow previewing changes without restart
uiGroup.GET("/extensions.js", func(re *core.RequestEvent) error {
buf := new(bytes.Buffer)
for _, ext := range se.UIExtensions {
err := copyExtensionMainjs(buf, ext)
if err != nil {
return re.InternalServerError("An error occurred while generating the main.js extension file", err)
}
}
return re.Stream(200, "text/javascript", buf)
}).Bind(SkipSuccessActivityLog())
return se.Next()
},
})
}
func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
f, err := ext.FS.Open("main.js")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // nothing to copy
}
return fmt.Errorf("[UI extension %q] main.js open error: %w", ext.Name, err)
}
defer f.Close()
// wrap in a self-executing function to avoid scope and concatenation issues
_, _ = buf.WriteString("(function(){")
_, err = io.Copy(buf, f)
if err != nil {
return fmt.Errorf("[UI extension %q] main.js copy error: %w", ext.Name, err)
}
_, _ = buf.WriteString("})();")
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package apis_test
import (
"net/http"
"testing"
"testing/fstest"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/ui"
)
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
func TestUIExtensions_Mainjs(t *testing.T) {
successAfterTestFunc := func(t testing.TB, app *tests.TestApp, res *http.Response) {
expected := "text/javascript"
if ct := res.Header.Get("content-type"); ct != expected {
t.Fatalf("Expected response Content-Type %q, got %q", expected, ct)
}
}
oldDistDirFS := ui.DistDirFS
scenarios := []tests.ApiScenario{
{
Name: "disabled UI",
Method: http.MethodGet,
URL: "/_/extensions.js",
TestAppFactory: func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
// simulate no_ui tag (needs to be cleared before the router is initialized)
ui.DistDirFS = nil
return app
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ui.DistDirFS = oldDistDirFS
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "no extensions",
Method: http.MethodGet,
URL: "/_/extensions.js",
AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with extensions",
Method: http.MethodGet,
URL: "/_/extensions.js",
TestAppFactory: func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
e.UIExtensions = createTestExtensions()
return e.Next()
})
return app
},
AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
func TestUIExtensions_Files(t *testing.T) {
testAppFactory := func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
e.UIExtensions = createTestExtensions()
return e.Next()
})
return app
}
scenarios := []tests.ApiScenario{
{
Name: "no extensions",
Method: http.MethodGet,
URL: "/_/extensions/ext1/test.txt",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with missing extension file",
Method: http.MethodGet,
URL: "/_/extensions/ext1/missing",
TestAppFactory: testAppFactory,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with existing extension file (ext1)",
Method: http.MethodGet,
URL: "/_/extensions/ext1/test.txt",
TestAppFactory: testAppFactory,
ExpectedStatus: 200,
ExpectedContent: []string{"ext1_txt"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with existing extension file (extension name escape)",
Method: http.MethodGet,
URL: "/_/extensions/ext3%20with%20spaces/test.txt",
TestAppFactory: testAppFactory,
ExpectedStatus: 200,
ExpectedContent: []string{"ext3_txt"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func createTestExtensions() []core.UIExtension {
return []core.UIExtension{
{
Name: "ext1",
FS: fstest.MapFS{
"main.js": &fstest.MapFile{
Data: []byte("ext1_main"),
},
"test.txt": &fstest.MapFile{
Data: []byte("ext1_txt"),
},
},
},
{
Name: "ext2",
FS: fstest.MapFS{
"test.txt": &fstest.MapFile{
Data: []byte("ext2_txt"),
},
},
},
{
Name: "ext3 with spaces",
FS: fstest.MapFS{
"main.js": &fstest.MapFile{
Data: []byte("ext3_main"),
},
"test.txt": &fstest.MapFile{
Data: []byte("ext3_txt"),
},
},
},
}
}
+1
View File
@@ -25,6 +25,7 @@ func healthCheck(e *core.RequestEvent) error {
Message: "API is healthy.",
}
// @todo evaluate whether it is worth removing the extra info from the health endpoint
if e.HasSuperuserAuth() {
resp.Data = make(map[string]any, 3)
resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup)
+7 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/ui"
)
// DefaultInstallerFunc is the default PocketBase installer function.
@@ -22,13 +23,18 @@ import (
//
// See https://github.com/pocketbase/pocketbase/discussions/5814.
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
if ui.DistDirFS == nil {
color.Magenta("You can create your first superuser by running: %s superuser upsert EMAIL PASS", executablePath())
return nil
}
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
if err != nil {
return err
}
// launch url (ignore errors and always print a help text as fallback)
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
url := fmt.Sprintf("%s/_/#/pbinstall/%s", strings.TrimRight(baseURL, "/"), token)
_ = osutils.LaunchURL(url)
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil)
const DefaultMaxBodySize int64 = 32 << 20
const DefaultMaxBodySize int64 = 32 << 20 // @todo consider replacing with router.DefaultMaxMemory
const (
DefaultBodyLimitMiddlewareId = "pbBodyLimit"
+10 -1
View File
@@ -34,6 +34,7 @@ type oauth2Response struct {
type providerInfo struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Logo string `json:"logo"`
State string `json:"state"`
AuthURL string `json:"authURL"`
@@ -68,7 +69,14 @@ func (amr *authMethodsResponse) fillLegacyFields() {
amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
if amr.OAuth2.Enabled {
amr.AuthProviders = amr.OAuth2.Providers
// clone without the logo
legacyProviders := make([]providerInfo, len(amr.OAuth2.Providers))
for i, p := range amr.OAuth2.Providers {
legacyProviders[i] = p
legacyProviders[i].Logo = ""
}
amr.AuthProviders = legacyProviders
}
}
@@ -128,6 +136,7 @@ func recordAuthMethods(e *core.RequestEvent) error {
info := providerInfo{
Name: config.Name,
DisplayName: provider.DisplayName(),
Logo: provider.Logo(),
State: security.RandomString(30),
}
+2
View File
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
`"providers":[{`,
`"name":"google"`,
`"name":"gitlab"`,
`"logo":"\u003csvg`,
`"logo":""`, // for the legacy fields
`"state":`,
`"displayName":`,
`"codeVerifier":`,
+33 -11
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/ui"
)
const (
@@ -28,17 +29,12 @@ type oauth2RedirectData struct {
}
func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
redirectStatusCode := http.StatusTemporaryRedirect
if e.Request.Method != http.MethodGet {
redirectStatusCode = http.StatusSeeOther
}
data := oauth2RedirectData{}
if e.Request.Method == http.MethodPost {
if err := e.BindBody(&data); err != nil {
e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
return failureRedirect(e)
}
} else {
query := e.Request.URL.Query()
@@ -49,13 +45,13 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.State == "" {
e.App.Logger().Debug("Missing OAuth2 state parameter")
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
return failureRedirect(e)
}
client, err := e.App.SubscriptionsBroker().ClientById(data.State)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
return failureRedirect(e)
}
defer client.Unsubscribe(oauth2SubscriptionTopic)
@@ -76,7 +72,7 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
encodedData, err := json.Marshal(data)
if err != nil {
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
return failureRedirect(e)
}
msg := subscriptions.Message{
@@ -88,10 +84,36 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.Error != "" || data.Code == "" {
e.App.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
return failureRedirect(e)
}
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
return successRedirect(e)
}
func redirectStatusCode(e *core.RequestEvent) int {
if e.Request.Method != http.MethodGet {
return http.StatusSeeOther
}
return http.StatusTemporaryRedirect
}
func failureRedirect(e *core.RequestEvent) error {
// fallback if UI is not bundled
if ui.DistDirFS == nil {
return e.String(http.StatusOK, "Failed to authenticate. You can close this window and go back to the app to try again.")
}
return e.Redirect(redirectStatusCode(e), oauth2RedirectFailurePath)
}
func successRedirect(e *core.RequestEvent) error {
// fallback if UI is not bundled
if ui.DistDirFS == nil {
return e.HTML(http.StatusOK, "Auth completed. You can close this window and go back to the app.")
}
return e.Redirect(redirectStatusCode(e), oauth2RedirectSuccessPath)
}
// parseAndStoreAppleRedirectName extracts the first and last name
+14 -4
View File
@@ -22,6 +22,8 @@ import (
"golang.org/x/crypto/acme/autocert"
)
const defaultCSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' http://127.0.0.1:*; frame-src 'none'"
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
@@ -77,21 +79,25 @@ func Serve(app core.App, config ServeConfig) error {
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// @todo consider moving in base
if ui.DistDirFS != nil {
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
BindFunc(func(e *core.RequestEvent) error {
// ignore root path
if e.Request.PathValue(StaticWildcardParam) != "" {
if !e.App.IsDev() &&
// exclude root path
e.Request.PathValue(StaticWildcardParam) != "" &&
e.Response.Header().Get("Cache-Control") == "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
}
return e.Next()
}).
Bind(Gzip())
}
// start http server
// ---
@@ -279,9 +285,13 @@ func Serve(app core.App, config ServeConfig) error {
)
regular := color.New()
if ui.DistDirFS == nil {
regular.Printf("└─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
} else {
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
}
}
var serveErr error
if config.HttpsAddr != "" {
+3 -1
View File
@@ -16,6 +16,8 @@ func bindSettingsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup.PATCH("", settingsSet)
subGroup.POST("/test/s3", settingsTestS3)
subGroup.POST("/test/email", settingsTestEmail)
// @todo move to collections
subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret)
}
@@ -62,12 +64,12 @@ func settingsSet(e *core.RequestEvent) error {
return e.BadRequestError("An error occurred while saving the new settings.", err)
}
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 execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, appSettings)
})
})
+9
View File
@@ -267,6 +267,15 @@ type App interface {
// "dangerousSelectQuery" argument must come only from trusted input!
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
// DryRunView executes the provided query by creating a temporary view
// collection and returning a sample of the resulting query records (if valid).
//
// The same caveats from CreateViewFields apply here too.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
// FindRecordByViewFile returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
+28 -26
View File
@@ -817,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
}{
{
"unknown",
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeBase,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeView,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeAuth,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
}
@@ -1576,60 +1576,62 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
viewName := "test_wrapping"
// note: some of the queries use "limit 0" because the tested field value could be empty
// which will trigger the extra sample records validation that are not important for this test
scenarios := []struct {
name string
query string
expected string
}{
{
"no wrapping - text field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
"no wrapping - id field",
"select id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select id, bool from demo1)",
},
{
"no wrapping - id field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
"no wrapping - text field",
"select text as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
},
{
"no wrapping - relation field",
"select rel_one as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
"select rel_one as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
},
{
"no wrapping - select field",
"select select_many as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
"select select_many as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
},
{
"no wrapping - email field",
"select email as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
"select email as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
},
{
"no wrapping - datetime field",
"select datetime as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
"select datetime as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
},
{
"no wrapping - url field",
"select url as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
"select url as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
},
{
"wrapping - bool field",
"select bool as id, text as txt, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
"select bool as id, text as txt, url from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1 limit 0))",
},
{
"wrapping - bool field (different order)",
"select text as txt, url, bool as id from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1))",
"select text as txt, url, bool as id from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1 limit 0))",
},
{
"wrapping - json field",
"select json as id, text, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
"select json as id, text, url from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
},
{
"wrapping - numeric id",
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
},
expectedErrors: []string{"fields", "viewQuery"},
},
{
name: "view with valid query but empty sample id",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewViewCollection("new_auth")
c.ViewQuery = "select '' as id"
return c, nil
},
expectedErrors: []string{"viewQuery"},
},
{
name: "view with valid query but duplicated sample id",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewViewCollection("new_auth")
c.ViewQuery = "(select 'a' as id union all select 'a' as id union all select 'c' as id)"
return c, nil
},
expectedErrors: []string{"viewQuery"},
},
{
name: "view with valid query",
collection: func(app core.App) (*core.Collection, error) {
+9 -5
View File
@@ -314,11 +314,15 @@ func (cv *collectionValidator) checkViewQuery(value any) error {
return nil // nothing to check
}
if _, err := cv.app.CreateViewFields(v); err != nil {
return validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
)
_, err := cv.app.DryRunView(v, 10)
if err != nil {
rawErr := err.Error()
if len(rawErr) > 500 {
// restrict just as an extra precaution
rawErr = rawErr[:500]
}
return validation.NewError("validation_invalid_view_query", "Invalid query - "+rawErr)
}
return nil
+19
View File
@@ -2,6 +2,7 @@ package core
import (
"context"
"io/fs"
"net"
"net/http"
"time"
@@ -57,6 +58,9 @@ type baseCollectionEventData struct {
Collection *Collection
}
// @todo consider storing the original collection name and use that as a tag
// to avoid the ambiguity when the collection is being modified (#7613);
// for new collection also maybe return empty tags?
func (e *baseCollectionEventData) Tags() []string {
if e.Collection == nil {
return nil
@@ -125,6 +129,21 @@ type ServeEvent struct {
//
// Set it to nil if you want to skip the installer.
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
// @todo experimental
//
// UIExtensions is a list with the superuser UI extensions.
UIExtensions []UIExtension
}
type UIExtension struct {
// Name is the name of the extension.
// It is also used as path segment for the registered public extension endpoint
// (e.g. /_/extensions/{name}/*)
Name string
// FS is the extension file system.
FS fs.FS
}
// -------------------------------------------------------------------
+20
View File
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
) error
}
// DefaultFieldHelpValidationRule performs base validation on a field's "help" value.
func DefaultFieldHelpValidationRule(value any) error {
v, ok := value.(string)
if !ok {
return validators.ErrUnsupportedValueType
}
rules := []validation.Rule{
validation.Length(1, 300),
}
for _, r := range rules {
if err := r.Validate(v); err != nil {
return err
}
}
return nil
}
// DefaultFieldIdValidationRule performs base validation on a field id value.
func DefaultFieldIdValidationRule(value any) error {
v, ok := value.(string)
+2 -2
View File
@@ -46,12 +46,12 @@ type AutodateField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// OnCreate auto sets the current datetime as field value on record create.
OnCreate bool `form:"onCreate" json:"onCreate"`
+6 -1
View File
@@ -36,11 +36,15 @@ type BoolField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Required will require the field value to be always "true".
Required bool `form:"required" json:"required"`
@@ -120,5 +124,6 @@ func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
)
}
+1
View File
@@ -147,4 +147,5 @@ func TestBoolFieldValidateValue(t *testing.T) {
func TestBoolFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeBool)
testDefaultFieldNameValidation(t, core.FieldTypeBool)
testDefaultFieldHelpValidation[core.BoolField](t)
}
+6 -1
View File
@@ -36,11 +36,15 @@ type DateField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the min allowed field value.
//
@@ -148,6 +152,7 @@ func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))),
)
}
+1
View File
@@ -133,6 +133,7 @@ func TestDateFieldValidateValue(t *testing.T) {
func TestDateFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeDate)
testDefaultFieldNameValidation(t, core.FieldTypeDate)
testDefaultFieldHelpValidation[core.DateField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -41,11 +41,15 @@ type EditorField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
@@ -148,6 +152,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
)
}
+1
View File
@@ -163,6 +163,7 @@ func TestEditorFieldValidateValue(t *testing.T) {
func TestEditorFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEditor)
testDefaultFieldNameValidation(t, core.FieldTypeEditor)
testDefaultFieldHelpValidation[core.EditorField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type EmailField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// ExceptDomains will require the email domain to NOT be included in the listed ones.
//
@@ -155,6 +159,7 @@ func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(
&f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+1
View File
@@ -182,6 +182,7 @@ func TestEmailFieldValidateValue(t *testing.T) {
func TestEmailFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEmail)
testDefaultFieldNameValidation(t, core.FieldTypeEmail)
testDefaultFieldHelpValidation[core.EmailField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -88,11 +88,15 @@ type FileField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
//
@@ -223,6 +227,7 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Thumbs, validation.Each(
+1
View File
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
func TestFileFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeFile)
testDefaultFieldNameValidation(t, core.FieldTypeFile)
testDefaultFieldHelpValidation[core.FileField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -46,11 +46,15 @@ type GeoPointField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"`
@@ -144,5 +148,6 @@ func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
)
}
+1
View File
@@ -199,4 +199,5 @@ func TestGeoPointFieldValidateValue(t *testing.T) {
func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldHelpValidation[core.GeoPointField](t)
}
+6 -1
View File
@@ -45,11 +45,15 @@ type JSONField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
@@ -181,6 +185,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
)
}
+1
View File
@@ -188,6 +188,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
func TestJSONFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeJSON)
testDefaultFieldNameValidation(t, core.FieldTypeJSON)
testDefaultFieldHelpValidation[core.JSONField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -48,11 +48,15 @@ type NumberField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the min allowed field value.
//
@@ -173,6 +177,7 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Min, validation.By(f.checkOnlyInt)),
validation.Field(&f.Max, maxRules...),
)
+1
View File
@@ -214,6 +214,7 @@ func TestNumberFieldValidateValue(t *testing.T) {
func TestNumberFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeNumber)
testDefaultFieldNameValidation(t, core.FieldTypeNumber)
testDefaultFieldHelpValidation[core.NumberField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+8 -1
View File
@@ -61,11 +61,17 @@ type PasswordField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// @todo remove
//
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Pattern specifies an optional regex pattern to match against the field value.
//
@@ -209,6 +215,7 @@ func (f *PasswordField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Min, validation.Min(1), validation.Max(71)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)),
validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)),
+1
View File
@@ -287,6 +287,7 @@ func TestPasswordFieldValidateValue(t *testing.T) {
func TestPasswordFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypePassword)
testDefaultFieldNameValidation(t, core.FieldTypePassword)
testDefaultFieldHelpValidation[core.PasswordField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type RelationField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// CollectionId is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"`
@@ -237,6 +241,7 @@ func (f *RelationField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))),
validation.Field(&f.MinSelect, validation.Min(0)),
validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)),
+1
View File
@@ -348,6 +348,7 @@ func TestRelationFieldValidateValue(t *testing.T) {
func TestRelationFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeRelation)
testDefaultFieldNameValidation(t, core.FieldTypeRelation)
testDefaultFieldHelpValidation[core.RelationField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type SelectField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Values specifies the list of accepted values.
Values []string `form:"values" json:"values"`
@@ -216,6 +220,7 @@ func (f *SelectField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Values, validation.Required),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)),
)
+1
View File
@@ -337,6 +337,7 @@ func TestSelectFieldValidateValue(t *testing.T) {
func TestSelectFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeSelect)
testDefaultFieldNameValidation(t, core.FieldTypeSelect)
testDefaultFieldHelpValidation[core.SelectField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+61 -2
View File
@@ -2,6 +2,8 @@ package core_test
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
@@ -113,7 +115,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
hasErr := errs["id"] != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
}
})
}
@@ -254,7 +256,64 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
hasErr := errs["name"] != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
}
})
}
}
func testDefaultFieldHelpValidation[T any](t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
scenarios := []struct {
name string
json string
expectError bool
}{
{
"empty value",
`{}`,
false,
},
{
"< max limit",
`{"help":"abc"}`,
false,
},
{
"= max limit",
`{"help":"` + strings.Repeat("a", 300) + `"}`,
false,
},
{
"> max limit",
`{"help":"` + strings.Repeat("a", 301) + `"}`,
true,
},
}
for _, s := range scenarios {
t.Run("[help] "+s.name, func(t *testing.T) {
var zeroField T
field, ok := reflect.New(reflect.TypeOf(zeroField)).Interface().(core.Field)
if !ok {
t.Fatalf("Expected core.Field instance, got %T", zeroField)
}
err := json.Unmarshal([]byte(s.json), &field)
if err != nil {
t.Fatal(err)
}
errs, _ := field.ValidateSettings(context.Background(), app, collection).(validation.Errors)
hasErr := errs["help"] != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
}
})
}
+6 -1
View File
@@ -72,11 +72,15 @@ type TextField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the minimum required string characters.
//
@@ -283,6 +287,7 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
validation.By(DefaultFieldNameValidationRule),
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
+1
View File
@@ -381,6 +381,7 @@ func TestTextFieldValidateValue(t *testing.T) {
func TestTextFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeText)
testDefaultFieldNameValidation(t, core.FieldTypeText)
testDefaultFieldHelpValidation[core.TextField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type URLField struct {
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// ExceptDomains will require the URL domain to NOT be included in the listed ones.
//
@@ -156,6 +160,7 @@ func (f *URLField) ValidateSettings(ctx context.Context, app App, collection *Co
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(
&f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+1
View File
@@ -182,6 +182,7 @@ func TestURLFieldValidateValue(t *testing.T) {
func TestURLFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeURL)
testDefaultFieldNameValidation(t, core.FieldTypeURL)
testDefaultFieldHelpValidation[core.URLField](t)
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6 -6
View File
@@ -473,13 +473,13 @@ func TestFieldsListScan(t *testing.T) {
"only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false,
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
},
{
"all field options",
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
false,
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
},
}
@@ -523,13 +523,13 @@ func TestFieldsListJSON(t *testing.T) {
"only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false,
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
},
{
"all field options",
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
false,
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
},
}
+7
View File
@@ -143,6 +143,7 @@ func newDefaultSettings() *Settings {
isNew: true,
settings: settings{
Meta: MetaConfig{
AccentColor: "#1055c9",
AppName: "Acme",
AppURL: "http://localhost:8090",
HideControls: false,
@@ -513,6 +514,11 @@ func checkCronExpression(value any) error {
// -------------------------------------------------------------------
type MetaConfig struct {
// @todo experimental
//
// AccentColor specify the UI "accent" color (HEX).
AccentColor string `form:"accentColor" json:"accentColor"`
AppName string `form:"appName" json:"appName"`
AppURL string `form:"appURL" json:"appURL"`
SenderName string `form:"senderName" json:"senderName"`
@@ -523,6 +529,7 @@ type MetaConfig struct {
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.AppURL, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
+33 -2
View File
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
valueStr = string(export["value"].([]byte))
}
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
}
@@ -93,6 +93,8 @@ func TestSettings_DBExport(t *testing.T) {
}
func TestSettingsMerge(t *testing.T) {
t.Parallel()
s1 := &core.Settings{}
s1.Meta.AppURL = "app_url" // should be unset
@@ -126,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
}
func TestSettingsClone(t *testing.T) {
t.Parallel()
s1 := &core.Settings{}
s1.Meta.AppName = "test_name"
@@ -156,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
}
func TestSettingsMarshalJSON(t *testing.T) {
t.Parallel()
settings := &core.Settings{}
// control fields
@@ -174,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
}
rawStr := string(raw)
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -230,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
}
func TestMetaConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.MetaConfig
@@ -248,12 +256,14 @@ func TestMetaConfigValidate(t *testing.T) {
{
"invalid data",
core.MetaConfig{
AccentColor: "#fff",
AppName: strings.Repeat("a", 300),
AppURL: "test",
SenderName: strings.Repeat("a", 300),
SenderAddress: "invalid_email",
},
[]string{
"accentColor",
"appName",
"appURL",
"senderName",
@@ -263,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
{
"valid data",
core.MetaConfig{
AccentColor: "#ffffff",
AppName: "test",
AppURL: "https://example.com",
SenderName: "test",
@@ -282,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
}
func TestLogsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.LogsConfig
@@ -314,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
}
func TestSMTPConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.SMTPConfig
@@ -373,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
}
func TestS3ConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.S3Config
@@ -444,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
}
func TestBackupsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.BackupsConfig
@@ -499,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
}
func TestBatchConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.BatchConfig
@@ -554,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
}
func TestRateLimitsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
config core.RateLimitsConfig
@@ -699,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
}
func TestRateLimitsFindRateLimitRule(t *testing.T) {
t.Parallel()
limits := core.RateLimitsConfig{
Rules: []core.RateLimitRule{
{Label: "abc"},
@@ -753,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
}
func TestRateLimitRuleValidate(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
rule core.RateLimitRule
@@ -860,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
}
func TestRateLimitRuleDurationTime(t *testing.T) {
t.Parallel()
scenarios := []struct {
rule core.RateLimitRule
expected time.Duration
@@ -880,6 +909,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
}
func TestRateLimitRuleString(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
rule core.RateLimitRule
+107 -16
View File
@@ -1,6 +1,7 @@
package core
import (
"context"
"errors"
"fmt"
"io"
@@ -36,17 +37,14 @@ func (app *BaseApp) DeleteView(dangerousViewName string) error {
func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
return app.RunInTransaction(func(txApp App) error {
// delete old view (if exists)
if err := txApp.DeleteView(dangerousViewName); err != nil {
err := txApp.DeleteView(dangerousViewName)
if err != nil {
return err
}
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
// try to loosely detect multiple inline statements
tk := tokenizer.NewFromString(dangerousSelectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
dangerousSelectQuery, err = normalizeViewSelectQuery(dangerousSelectQuery)
if err != nil {
return err
}
// (re)create the view
@@ -54,7 +52,8 @@ func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery stri
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
_, err = txApp.DB().NewQuery(viewQuery).Execute()
if err != nil {
return err
}
@@ -124,6 +123,76 @@ func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, e
return result, txErr
}
type DryRunViewResult struct {
Fields FieldsList `json:"fields"`
Sample []*Record `json:"sample"`
}
// DryRunView executes the provided query by creating a temporary view
// collection and returning a sample of the resulting query records (if valid).
//
// The same caveats from CreateViewFields apply here too.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
func (app *BaseApp) DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error) {
dangerousSelectQuery, err := normalizeViewSelectQuery(dangerousSelectQuery)
if err != nil {
return nil, err
}
fields, err := app.CreateViewFields(dangerousSelectQuery)
if err != nil {
return nil, err
}
tempName := "temp_view_" + security.RandomString(5)
tempCollection := NewViewCollection(tempName)
tempCollection.Fields = fields
// validate generated view fields
ctx := context.Background()
for i, f := range fields {
err = f.ValidateSettings(ctx, app, tempCollection)
if err != nil {
return nil, fmt.Errorf("invalid field %q (%d): %w", f.GetName(), i, err)
}
}
records := []*Record{}
err = app.RecordQuery(tempCollection).
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
From("(SELECT * FROM (" + dangerousSelectQuery + ")) as " + tempName).
Limit(int64(sampleSize)).
All(&records)
if err != nil {
return nil, fmt.Errorf("failed to retrieve query records: %w", err)
}
// warn for possible empty or duplicated record ids found in the sample
// (it is not intended for security and it is here to quickly provide a
// helpful error message without doing multiple query executions)
ids := make(map[string]struct{}, len(records))
for _, r := range records {
if r.Id == "" {
return nil, errors.New("the query could return records with empty or invalid ids")
}
if _, ok := ids[r.Id]; ok {
return nil, errors.New("the query could return records with non-unique ids")
}
ids[r.Id] = struct{}{}
}
return &DryRunViewResult{
Fields: fields,
Sample: records,
}, nil
}
// FindRecordByViewFile returns the original Record of the provided view collection file.
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
@@ -198,6 +267,20 @@ func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fi
// Raw query to schema helpers
// -------------------------------------------------------------------
// loosely normalizes the specified view query and warn against multiple inline statements
// (the check is not perfect and it is NOT intended as a security measure; it is done primarily to provide a helpful error message)
func normalizeViewSelectQuery(dangerousSelectQuery string) (string, error) {
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
tk := tokenizer.NewFromString(dangerousSelectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return "", errors.New("multiple statements are not supported")
}
return dangerousSelectQuery, nil
}
type queryField struct {
// field is the final resolved field.
field Field
@@ -212,12 +295,26 @@ type queryField struct {
}
func defaultViewField(name string) Field {
if name == FieldNameId {
return defaultViewIdField()
}
return &JSONField{
Name: name,
MaxSize: 1, // unused for views
}
}
func defaultViewIdField() Field {
return &TextField{
Name: FieldNameId,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
}
}
var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
@@ -245,13 +342,7 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
// pk (always assume text field for now)
if col.alias == FieldNameId {
result[col.alias] = &queryField{
field: &TextField{
Name: col.alias,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
},
field: defaultViewIdField(),
}
continue
}
+126
View File
@@ -732,3 +732,129 @@ func TestFindRecordByViewFile(t *testing.T) {
})
}
}
func TestDryRunView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
sampleSize int
expectError bool
expectFields map[string]string // name-type pairs
expectSampleIds []string // record ids of the resulting sample
}{
{
"empty query",
"",
10,
true,
nil,
nil,
},
{
"non-select query",
"CREATE TABLE t1(x INT)",
10,
true,
nil,
nil,
},
{
"multiple inline select statements",
"select 'a' as id; select 'b' as id",
10,
true,
nil,
nil,
},
{
"select with invalid formatted field name",
"select 'a' as id, count(*)", // missing field alias
10,
true,
nil,
nil,
},
{
"select resolving to records with missing id",
"(select 'a' as id UNION ALL select null as id UNION ALL select 'c' as id)",
10,
true,
nil,
nil,
},
{
"select resolving to records with duplicated ids",
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
10,
true,
nil,
nil,
},
{
"no sample size and valid select query but with invalid records result",
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
0,
false, // still "valid" because there is no sample to check
map[string]string{"id": "text"},
nil,
},
{
"sample size < total select records",
"(select 'a' as id UNION ALL select 'b' as id UNION ALL select 'c' as id UNION ALL select 'd' as id)",
3,
false,
map[string]string{"id": "text"},
[]string{"a", "b", "c"},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, err := app.DryRunView(s.query, s.sampleSize)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
// check fields
// ---
if len(s.expectFields) != len(result.Fields) {
serialized, _ := json.Marshal(result.Fields)
t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized)
}
for name, typ := range s.expectFields {
field := result.Fields.GetByName(name)
if field == nil {
t.Fatalf("Expected to find field %s, got nil", name)
}
if field.Type() != typ {
t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type())
}
}
// check sample ids
// ---
if len(s.expectSampleIds) != len(result.Sample) {
t.Fatalf("Expected %d sample records, got %d", len(s.expectSampleIds), len(result.Sample))
}
for i, r := range result.Sample {
if s.expectSampleIds[i] != r.Id {
t.Fatalf("Expected sample record id %q, got %q at %d", s.expectSampleIds[i], r.Id, i)
}
}
})
}
ensureNoTempViews(app, t)
}
+45 -10
View File
@@ -289,9 +289,13 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return wrappedMiddlewares, nil
}
// -------------------------------------------------------------------
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
func baseBinds(vm *goja.Runtime) {
// BindCore registers common core objects and functions such as sleep,
// toString, DynamicModel, etc. into the provided runtime.
func BindCore(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{})
// deprecated: use toString
@@ -659,7 +663,10 @@ func baseBinds(vm *goja.Runtime) {
})
}
func dbxBinds(vm *goja.Runtime) {
// BindDbx registers $dbx.* namespaced object with dbx database builder related methods.
//
// See https://pocketbase.io/jsvm/modules/_dbx.html.
func BindDbx(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$dbx", obj)
@@ -682,7 +689,10 @@ func dbxBinds(vm *goja.Runtime) {
obj.Set("notBetween", dbx.NotBetween)
}
func mailsBinds(vm *goja.Runtime) {
// BindMails registers $mail.* namespaced object with common mail related helpers.
//
// See https://pocketbase.io/jsvm/modules/_mails.html.
func BindMails(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$mails", obj)
@@ -693,7 +703,10 @@ func mailsBinds(vm *goja.Runtime) {
obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert)
}
func securityBinds(vm *goja.Runtime) {
// BindSecurity registers $security.* namespaced object with common security related helpers.
//
// See https://pocketbase.io/jsvm/modules/_security.html.
func BindSecurity(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$security", obj)
@@ -736,7 +749,11 @@ func securityBinds(vm *goja.Runtime) {
})
}
func filesystemBinds(vm *goja.Runtime) {
// BindFilesystem registers $filesystem.* namespaced object with
// common filesystem package related helpers.
//
// See https://pocketbase.io/jsvm/modules/_filesystem.html.
func BindFilesystem(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$filesystem", obj)
@@ -757,7 +774,11 @@ func filesystemBinds(vm *goja.Runtime) {
})
}
func filepathBinds(vm *goja.Runtime) {
// 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()
vm.Set("$filepath", obj)
@@ -778,7 +799,11 @@ func filepathBinds(vm *goja.Runtime) {
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()
vm.Set("$os", obj)
@@ -804,14 +829,20 @@ func osBinds(vm *goja.Runtime) {
obj.Set("openInRoot", os.OpenInRoot)
}
func formsBinds(vm *goja.Runtime) {
// BindForms registers various application form constructors.
// These bindings are mostly used internally and/or preserved for backward compatibility with earlier versions.
func BindForms(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend)
registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem)
}
func apisBinds(vm *goja.Runtime) {
// BindApis registers $apis.* namespaced object with reusable Web API
// handlers, middlewares and other related helpers.
//
// See https://pocketbase.io/jsvm/modules/_apis.html.
func BindApis(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$apis", obj)
@@ -850,7 +881,11 @@ func apisBinds(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError)
}
func httpClientBinds(vm *goja.Runtime) {
// 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()
vm.Set("$http", obj)
+88 -88
View File
@@ -43,16 +43,16 @@ func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T)
// note: this test is useful as a reminder to update the tests in case
// a new base binding is added.
func TestBaseBindsCount(t *testing.T) {
func TestBindCoreCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
testBindsCount(vm, "this", 41, t)
}
func TestBaseBindsSleep(t *testing.T) {
func TestBindCoreSleep(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("reader", strings.NewReader("test"))
start := time.Now()
@@ -69,9 +69,9 @@ func TestBaseBindsSleep(t *testing.T) {
}
}
func TestBaseBindsReaderToString(t *testing.T) {
func TestBindCoreReaderToString(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("reader", strings.NewReader("test"))
_, err := vm.RunString(`
@@ -86,9 +86,9 @@ func TestBaseBindsReaderToString(t *testing.T) {
}
}
func TestBaseBindsToString(t *testing.T) {
func TestBindCoreToString(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("scenarios", []struct {
Name string
Value any
@@ -120,9 +120,9 @@ func TestBaseBindsToString(t *testing.T) {
}
}
func TestBaseBindsToBytes(t *testing.T) {
func TestBindCoreToBytes(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("bytesEqual", bytes.Equal)
vm.Set("scenarios", []struct {
Name string
@@ -160,9 +160,9 @@ func TestBaseBindsToBytes(t *testing.T) {
}
}
func TestBaseBindsUnmarshal(t *testing.T) {
func TestBindCoreUnmarshal(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("data", &map[string]any{"a": 123})
_, err := vm.RunString(`
@@ -181,9 +181,9 @@ func TestBaseBindsUnmarshal(t *testing.T) {
}
}
func TestBaseBindsContext(t *testing.T) {
func TestBindCoreContext(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const base = new Context(null, "a", 123);
@@ -205,9 +205,9 @@ func TestBaseBindsContext(t *testing.T) {
}
}
func TestBaseBindsCookie(t *testing.T) {
func TestBindCoreCookie(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const cookie = new Cookie({
@@ -234,9 +234,9 @@ func TestBaseBindsCookie(t *testing.T) {
}
}
func TestBaseBindsSubscriptionMessage(t *testing.T) {
func TestBindCoreSubscriptionMessage(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("bytesToString", func(b []byte) string {
return string(b)
})
@@ -262,7 +262,7 @@ func TestBaseBindsSubscriptionMessage(t *testing.T) {
}
}
func TestBaseBindsRecord(t *testing.T) {
func TestBindCoreRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -272,7 +272,7 @@ func TestBaseBindsRecord(t *testing.T) {
}
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("collection", collection)
// without record data
@@ -308,9 +308,9 @@ func TestBaseBindsRecord(t *testing.T) {
}
}
func TestBaseBindsCollection(t *testing.T) {
func TestBindCoreCollection(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`)
if err != nil {
@@ -336,9 +336,9 @@ func TestBaseBindsCollection(t *testing.T) {
}
}
func TestBaseBindsFieldsList(t *testing.T) {
func TestBindCoreFieldsList(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`)
if err != nil {
@@ -355,9 +355,9 @@ func TestBaseBindsFieldsList(t *testing.T) {
}
}
func TestBaseBindsField(t *testing.T) {
func TestBindCoreField(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`)
if err != nil {
@@ -379,11 +379,11 @@ func isType[T any](v any) bool {
return ok
}
func TestBaseBindsNamedFields(t *testing.T) {
func TestBindCoreNamedFields(t *testing.T) {
t.Parallel()
vm := goja.New()
baseBinds(vm)
BindCore(vm)
scenarios := []struct {
js string
@@ -470,9 +470,9 @@ func TestBaseBindsNamedFields(t *testing.T) {
}
}
func TestBaseBindsMailerMessage(t *testing.T) {
func TestBindCoreMailerMessage(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
v, err := vm.RunString(`new MailerMessage({
from: {name: "test_from", address: "test_from@example.com"},
@@ -517,9 +517,9 @@ func TestBaseBindsMailerMessage(t *testing.T) {
}
}
func TestBaseBindsCommand(t *testing.T) {
func TestBindCoreCommand(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
let runCalls = 0;
@@ -546,9 +546,9 @@ func TestBaseBindsCommand(t *testing.T) {
}
}
func TestBaseBindsRequestInfo(t *testing.T) {
func TestBindCoreRequestInfo(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const info = new RequestInfo({
@@ -564,9 +564,9 @@ func TestBaseBindsRequestInfo(t *testing.T) {
}
}
func TestBaseBindsMiddleware(t *testing.T) {
func TestBindCoreMiddleware(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const m = new Middleware(
@@ -584,9 +584,9 @@ func TestBaseBindsMiddleware(t *testing.T) {
}
}
func TestBaseBindsTimezone(t *testing.T) {
func TestBindCoreTimezone(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const v0 = (new Timezone()).string();
@@ -609,9 +609,9 @@ func TestBaseBindsTimezone(t *testing.T) {
}
}
func TestBaseBindsDateTime(t *testing.T) {
func TestBindCoreDateTime(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
_, err := vm.RunString(`
const now = new DateTime();
@@ -650,9 +650,9 @@ func TestBaseBindsDateTime(t *testing.T) {
}
}
func TestBaseBindsValidationError(t *testing.T) {
func TestBindCoreValidationError(t *testing.T) {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
scenarios := []struct {
js string
@@ -697,14 +697,14 @@ func TestBaseBindsValidationError(t *testing.T) {
}
}
func TestDbxBinds(t *testing.T) {
func TestBindDbx(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
vm.Set("db", app.DB())
baseBinds(vm)
dbxBinds(vm)
BindCore(vm)
BindDbx(vm)
testBindsCount(vm, "$dbx", 15, t)
@@ -792,14 +792,14 @@ func TestDbxBinds(t *testing.T) {
}
}
func TestMailsBindsCount(t *testing.T) {
func TestBindMailsCount(t *testing.T) {
vm := goja.New()
mailsBinds(vm)
BindMails(vm)
testBindsCount(vm, "$mails", 5, t)
}
func TestMailsBinds(t *testing.T) {
func TestBindMails(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -809,8 +809,8 @@ func TestMailsBinds(t *testing.T) {
}
vm := goja.New()
baseBinds(vm)
mailsBinds(vm)
BindCore(vm)
BindMails(vm)
vm.Set("$app", app)
vm.Set("record", record)
@@ -845,17 +845,17 @@ func TestMailsBinds(t *testing.T) {
}
}
func TestSecurityBindsCount(t *testing.T) {
func TestBindSecurityCount(t *testing.T) {
vm := goja.New()
securityBinds(vm)
BindSecurity(vm)
testBindsCount(vm, "$security", 16, t)
}
func TestSecurityCryptoBinds(t *testing.T) {
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
BindCore(vm)
BindSecurity(vm)
sceneraios := []struct {
js string
@@ -888,8 +888,8 @@ func TestSecurityCryptoBinds(t *testing.T) {
func TestSecurityRandomStringBinds(t *testing.T) {
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
BindCore(vm)
BindSecurity(vm)
sceneraios := []struct {
js string
@@ -964,8 +964,8 @@ func TestSecurityJWTBinds(t *testing.T) {
for _, s := range sceneraios {
t.Run(s.name, func(t *testing.T) {
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
BindCore(vm)
BindSecurity(vm)
_, err := vm.RunString(s.js)
if err != nil {
@@ -977,8 +977,8 @@ func TestSecurityJWTBinds(t *testing.T) {
func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
BindCore(vm)
BindSecurity(vm)
_, err := vm.RunString(`
const key = "abcdabcdabcdabcdabcdabcdabcdabcd"
@@ -996,7 +996,7 @@ func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
}
}
func TestFilesystemBinds(t *testing.T) {
func TestBindFilesystem(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1020,8 +1020,8 @@ func TestFilesystemBinds(t *testing.T) {
vm.Set("tmpDir", tmpDir)
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseURL", srv.URL)
baseBinds(vm)
filesystemBinds(vm)
BindCore(vm)
BindFilesystem(vm)
testBindsCount(vm, "$filesystem", 6, t)
@@ -1116,24 +1116,24 @@ func TestFilesystemBinds(t *testing.T) {
}
}
func TestFormsBinds(t *testing.T) {
func TestBindForms(t *testing.T) {
vm := goja.New()
formsBinds(vm)
BindForms(vm)
testBindsCount(vm, "this", 4, t)
}
func TestApisBindsCount(t *testing.T) {
func TestBindApisCount(t *testing.T) {
vm := goja.New()
apisBinds(vm)
BindApis(vm)
testBindsCount(vm, "this", 8, t)
testBindsCount(vm, "$apis", 11, t)
}
func TestApisBindsApiError(t *testing.T) {
func TestBindApisErrors(t *testing.T) {
vm := goja.New()
apisBinds(vm)
BindApis(vm)
scenarios := []struct {
js string
@@ -1190,8 +1190,8 @@ func TestLoadingDynamicModel(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
dbxBinds(vm)
BindCore(vm)
BindDbx(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
@@ -1291,8 +1291,8 @@ func TestDynamicModelMapFieldCaching(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
dbxBinds(vm)
BindCore(vm)
BindDbx(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
@@ -1350,8 +1350,8 @@ func TestLoadingArrayOf(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
dbxBinds(vm)
BindCore(vm)
BindDbx(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
@@ -1391,18 +1391,18 @@ func TestLoadingArrayOf(t *testing.T) {
}
}
func TestHttpClientBindsCount(t *testing.T) {
func TestBindHttpClientCount(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
httpClientBinds(vm)
BindHttpClient(vm)
testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t)
}
func TestHttpClientBindsSend(t *testing.T) {
func TestBindHttpClientSend(t *testing.T) {
t.Parallel()
// start a test server
@@ -1447,8 +1447,8 @@ func TestHttpClientBindsSend(t *testing.T) {
defer server.Close()
vm := goja.New()
baseBinds(vm)
httpClientBinds(vm)
BindCore(vm)
BindHttpClient(vm)
vm.Set("testURL", server.URL)
_, err := vm.RunString(`
@@ -1626,7 +1626,7 @@ func TestHooksBinds(t *testing.T) {
vmFactory := func() *goja.Runtime {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1712,7 +1712,7 @@ func TestHooksExceptionUnwrapping(t *testing.T) {
vmFactory := func() *goja.Runtime {
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("$app", app)
vm.Set("goErr", goErr)
return vm
@@ -1766,8 +1766,8 @@ func TestRouterBinds(t *testing.T) {
vmFactory := func() *goja.Runtime {
vm := goja.New()
baseBinds(vm)
apisBinds(vm)
BindCore(vm)
BindApis(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1855,16 +1855,16 @@ func TestRouterBinds(t *testing.T) {
}
}
func TestFilepathBindsCount(t *testing.T) {
func TestBindFilepathCount(t *testing.T) {
vm := goja.New()
filepathBinds(vm)
BindFilepath(vm)
testBindsCount(vm, "$filepath", 15, t)
}
func TestOsBindsCount(t *testing.T) {
func TestBindOsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
BindOs(vm)
testBindsCount(vm, "$os", 20, t)
}
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -1,6 +1,9 @@
// Package jsvm implements pluggable utilities for binding a JS goja runtime
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
//
// The package also exports several reusable bindings so that users
// can utilize them as part of their own custom goja runtime setup.
//
// Example:
//
// jsvm.MustRegister(app, jsvm.Config{
@@ -200,15 +203,15 @@ func (p *plugin) registerMigrations() error {
process.Enable(vm)
buffer.Enable(vm)
baseBinds(vm)
dbxBinds(vm)
securityBinds(vm)
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
filesystemBinds(vm)
formsBinds(vm)
mailsBinds(vm)
BindCore(vm)
BindDbx(vm)
BindSecurity(vm)
BindOs(vm)
BindFilepath(vm)
BindHttpClient(vm)
BindFilesystem(vm)
BindForms(vm)
BindMails(vm)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
@@ -288,16 +291,16 @@ func (p *plugin) registerHooks() error {
process.Enable(vm)
buffer.Enable(vm)
baseBinds(vm)
dbxBinds(vm)
filesystemBinds(vm)
securityBinds(vm)
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
formsBinds(vm)
apisBinds(vm)
mailsBinds(vm)
BindCore(vm)
BindDbx(vm)
BindSecurity(vm)
BindOs(vm)
BindFilepath(vm)
BindHttpClient(vm)
BindFilesystem(vm)
BindForms(vm)
BindMails(vm)
BindApis(vm)
vm.Set("$app", p.app)
vm.Set("$template", templateRegistry)
+32
View File
@@ -50,6 +50,7 @@ migrate((app) => {
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
@@ -64,6 +65,7 @@ migrate((app) => {
},
{
"cost": 0,
"help": "",
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
@@ -77,6 +79,7 @@ migrate((app) => {
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
@@ -91,6 +94,7 @@ migrate((app) => {
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
@@ -101,6 +105,7 @@ migrate((app) => {
"type": "email"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
@@ -110,6 +115,7 @@ migrate((app) => {
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
@@ -226,6 +232,7 @@ func init() {
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
@@ -240,6 +247,7 @@ func init() {
},
{
"cost": 0,
"help": "",
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
@@ -253,6 +261,7 @@ func init() {
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
@@ -267,6 +276,7 @@ func init() {
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
@@ -277,6 +287,7 @@ func init() {
"type": "email"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
@@ -286,6 +297,7 @@ func init() {
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
@@ -491,6 +503,7 @@ migrate((app) => {
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
@@ -505,6 +518,7 @@ migrate((app) => {
},
{
"cost": 0,
"help": "",
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
@@ -518,6 +532,7 @@ migrate((app) => {
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
@@ -532,6 +547,7 @@ migrate((app) => {
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "email3885137012",
"name": "email",
@@ -542,6 +558,7 @@ migrate((app) => {
"type": "email"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
@@ -551,6 +568,7 @@ migrate((app) => {
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool256245529",
"name": "verified",
@@ -670,6 +688,7 @@ func init() {
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
@@ -684,6 +703,7 @@ func init() {
},
{
"cost": 0,
"help": "",
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
@@ -697,6 +717,7 @@ func init() {
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
@@ -711,6 +732,7 @@ func init() {
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "email3885137012",
"name": "email",
@@ -721,6 +743,7 @@ func init() {
"type": "email"
},
{
"help": "",
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
@@ -730,6 +753,7 @@ func init() {
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "bool256245529",
"name": "verified",
@@ -923,6 +947,7 @@ migrate((app) => {
// add field
collection.fields.addAt(8, new Field({
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "f4_id",
"max": 0,
@@ -938,6 +963,7 @@ migrate((app) => {
// update field
collection.fields.addAt(7, new Field({
"help": "",
"hidden": false,
"id": "f2_id",
"max": null,
@@ -976,6 +1002,7 @@ migrate((app) => {
// add field
collection.fields.addAt(8, new Field({
"help": "",
"hidden": false,
"id": "f3_id",
"name": "f3_name",
@@ -990,6 +1017,7 @@ migrate((app) => {
// update field
collection.fields.addAt(7, new Field({
"help": "",
"hidden": false,
"id": "f2_id",
"max": null,
@@ -1054,6 +1082,7 @@ func init() {
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "f4_id",
"max": 0,
@@ -1071,6 +1100,7 @@ func init() {
// update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false,
"id": "f2_id",
"max": null,
@@ -1116,6 +1146,7 @@ func init() {
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"help": "",
"hidden": false,
"id": "f3_id",
"name": "f3_name",
@@ -1132,6 +1163,7 @@ func init() {
// update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false,
"id": "f2_id",
"max": null,
+2
View File
@@ -36,6 +36,8 @@ func NewAppleProvider() *Apple {
return &Apple{
BaseProvider: BaseProvider{
ctx: context.Background(),
order: 1,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="315" preserveAspectRatio="xMidYMid"><path d="M213.8 167c.4 47.6 41.7 63.4 42.2 63.6-.3 1.2-6.6 22.6-21.8 44.8-13 19.1-26.7 38.2-48 38.6-21.1.4-28-12.5-52-12.5s-31.6 12.1-51.5 12.9c-20.7.8-36.4-20.7-49.6-39.8-27-39-47.7-110.3-20-158.4a77 77 0 0 1 65.1-39.4c20.3-.4 39.5 13.6 51.9 13.6s35.7-16.9 60.2-14.4c10.2.4 39 4.2 57.5 31.2-1.5 1-34.4 20-34 59.8M174.2 50.2A69 69 0 0 0 190.6 0c-15.8.6-35 10.5-46.3 23.8-10.2 11.8-19.1 30.6-16.7 48.7 17.6 1.3 35.7-9 46.6-22.3"/></svg>`,
displayName: "Apple",
pkce: true,
scopes: []string{"name", "email"},
+20 -1
View File
@@ -28,8 +28,20 @@ func NewProviderByName(name string) (Provider, error) {
return factory(), nil
}
// @todo refactor and consider replace with a plain struct
//
// Provider defines a common interface for an OAuth2 client.
type Provider interface {
// @todo temp backport
//
// Order returns the sorting order of the provider usually used in the auth methods list response.
Logo() string
// @todo temp backport
//
// Order returns the sorting order of the provider usually used in the auth methods list response.
Order() int
// Context returns the context associated with the provider (if any).
Context() context.Context
@@ -133,11 +145,18 @@ type AuthUser struct {
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatarURL"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
// @todo consider assigning the non-verified email and combining
// with an extra Verified bool flag.
// The VERIFIED OAuth2 account email.
//
// It must be empty if the provider is not able to verify the email ownership.
Email string `json:"email"`
// @todo
// deprecated: use AvatarURL instead
// AvatarUrl will be removed after dropping v0.22 support
+14 -2
View File
@@ -13,16 +13,28 @@ import (
// BaseProvider defines common fields and methods used by OAuth2 client providers.
type BaseProvider struct {
ctx context.Context
extra map[string]any
redirectURL string
clientId string
clientSecret string
displayName string
redirectURL string
logo string
authURL string
tokenURL string
userInfoURL string
scopes []string
order int
pkce bool
extra map[string]any
}
// Order implements Provider.Order() interface method.
func (p *BaseProvider) Order() int {
return p.order
}
// Logo implements Provider.Logo() interface method.
func (p *BaseProvider) Logo() string {
return p.logo
}
// Context implements Provider.Context() interface method.
+46 -14
View File
@@ -30,14 +30,46 @@ func TestDisplayName(t *testing.T) {
before := b.DisplayName()
if before != "" {
t.Fatalf("Expected displayName to be empty, got %v", before)
t.Fatalf("Expected displayName to be empty, got %q", before)
}
b.SetDisplayName("test")
after := b.DisplayName()
if after != "test" {
t.Fatalf("Expected displayName to be 'test', got %v", after)
t.Fatalf("Expected displayName to be %q, got %q", "test", after)
}
}
func TestOrder(t *testing.T) {
b := BaseProvider{}
before := b.Order()
if before != 0 {
t.Fatalf("Expected order to be empty, got %d", before)
}
b.order = 123
after := b.Order()
if after != 123 {
t.Fatalf("Expected order to be %d, got %d", 123, after)
}
}
func TestLogo(t *testing.T) {
b := BaseProvider{}
before := b.Logo()
if before != "" {
t.Fatalf("Expected logo to be empty, got %q", before)
}
b.logo = "test"
after := b.Logo()
if after != "test" {
t.Fatalf("Expected logo to be %q, got %q", "test", after)
}
}
@@ -78,14 +110,14 @@ func TestClientId(t *testing.T) {
before := b.ClientId()
if before != "" {
t.Fatalf("Expected clientId to be empty, got %v", before)
t.Fatalf("Expected clientId to be empty, got %q", before)
}
b.SetClientId("test")
after := b.ClientId()
if after != "test" {
t.Fatalf("Expected clientId to be 'test', got %v", after)
t.Fatalf("Expected clientId to be %q, got %q", "test", after)
}
}
@@ -94,14 +126,14 @@ func TestClientSecret(t *testing.T) {
before := b.ClientSecret()
if before != "" {
t.Fatalf("Expected clientSecret to be empty, got %v", before)
t.Fatalf("Expected clientSecret to be empty, got %q", before)
}
b.SetClientSecret("test")
after := b.ClientSecret()
if after != "test" {
t.Fatalf("Expected clientSecret to be 'test', got %v", after)
t.Fatalf("Expected clientSecret to be %q, got %q", "test", after)
}
}
@@ -110,14 +142,14 @@ func TestRedirectURL(t *testing.T) {
before := b.RedirectURL()
if before != "" {
t.Fatalf("Expected RedirectURL to be empty, got %v", before)
t.Fatalf("Expected RedirectURL to be empty, got %q", before)
}
b.SetRedirectURL("test")
after := b.RedirectURL()
if after != "test" {
t.Fatalf("Expected RedirectURL to be 'test', got %v", after)
t.Fatalf("Expected RedirectURL to be %q, got %q", "test", after)
}
}
@@ -126,14 +158,14 @@ func TestAuthURL(t *testing.T) {
before := b.AuthURL()
if before != "" {
t.Fatalf("Expected authURL to be empty, got %v", before)
t.Fatalf("Expected authURL to be empty, got %q", before)
}
b.SetAuthURL("test")
after := b.AuthURL()
if after != "test" {
t.Fatalf("Expected authURL to be 'test', got %v", after)
t.Fatalf("Expected authURL to be %q, got %q", "test", after)
}
}
@@ -142,14 +174,14 @@ func TestTokenURL(t *testing.T) {
before := b.TokenURL()
if before != "" {
t.Fatalf("Expected tokenURL to be empty, got %v", before)
t.Fatalf("Expected tokenURL to be empty, got %q", before)
}
b.SetTokenURL("test")
after := b.TokenURL()
if after != "test" {
t.Fatalf("Expected tokenURL to be 'test', got %v", after)
t.Fatalf("Expected tokenURL to be %q, got %q", "test", after)
}
}
@@ -158,14 +190,14 @@ func TestUserInfoURL(t *testing.T) {
before := b.UserInfoURL()
if before != "" {
t.Fatalf("Expected userInfoURL to be empty, got %v", before)
t.Fatalf("Expected userInfoURL to be empty, got %q", before)
}
b.SetUserInfoURL("test")
after := b.UserInfoURL()
if after != "test" {
t.Fatalf("Expected userInfoURL to be 'test', got %v", after)
t.Fatalf("Expected userInfoURL to be %q, got %q", "test", after)
}
}
+2
View File
@@ -28,6 +28,8 @@ type Bitbucket struct {
func NewBitbucketProvider() *Bitbucket {
return &Bitbucket{BaseProvider{
ctx: context.Background(),
order: 9,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2256" preserveAspectRatio="xMidYMid" viewBox="-1 -0.6 257.9 230.8"><linearGradient id="a" x1="108.6%" x2="46.9%" y1="13.8%" y2="78.8%"><stop offset=".2" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><g fill="none"><path d="M101 153h54l13-76H87z"/><path fill="#2684ff" d="M8 0a8 8 0 0 0-8 10l35 211a11 11 0 0 0 11 9h167a8 8 0 0 0 8-7l35-213a8 8 0 0 0-8-10zm147 153h-53L87 77h81z"/><path fill="url(#a)" d="M245 77h-77l-13 76h-53l-63 74 7 3h167a8 8 0 0 0 8-7z"/></g></svg>`,
displayName: "Bitbucket",
pkce: false,
scopes: []string{"account"},
+2
View File
@@ -27,6 +27,8 @@ type Box struct {
func NewBoxProvider() *Box {
return &Box{BaseProvider{
ctx: context.Background(),
order: 20,
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 21.6"><path fill="#0061d5" d="M39.7 19.2q.7 1.2-.2 2.1-1.2.7-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2q-1-.9-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2s1.7-.3 2.2.3l3.4 4.5L37.3 7q.9-1 2.2-.3 1 1 .2 2.2L35.8 14zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6a4.7 4.7 0 0 1-4.7 4.6m-13.8 0c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6M21.5 6.4a8 8 0 0 0-6.8 4 8 8 0 0 0-6.9-4q-2.7 0-4.7 1.5V1.5Q3 .2 1.6 0 .1.1 0 1.5v12.6a7.7 7.7 0 0 0 7.7 7.5c3 0 5.6-1.7 6.9-4.1a8 8 0 0 0 6.8 4.1c4.3 0 7.8-3.4 7.8-7.7a7.5 7.5 0 0 0-7.7-7.5"/></svg>`,
displayName: "Box",
pkce: true,
scopes: []string{"root_readonly"},
+2
View File
@@ -29,6 +29,8 @@ func NewDiscordProvider() *Discord {
// https://discord.com/developers/docs/resources/user#get-current-user
return &Discord{BaseProvider{
ctx: context.Background(),
order: 12,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199" preserveAspectRatio="xMidYMid"><path fill="#5865f2" d="M216.9 16.6A209 209 0 0 0 164 0c-2.2 4.1-4.9 9.6-6.7 14a194 194 0 0 0-58.6 0C97 9.6 94.2 4.1 92 0a208 208 0 0 0-53 16.6A222 222 0 0 0 1 165a211 211 0 0 0 65 33 161 161 0 0 0 13.8-22.8q-11.5-4.4-21.8-10.6l5.3-4.3a149 149 0 0 0 129.6 0q2.6 2.3 5.3 4.3a136 136 0 0 1-21.9 10.6q6 12 13.9 22.9a211 211 0 0 0 64.8-33.2c5.3-56.3-9-105.1-38-148.4M85.5 135.1c-12.7 0-23-11.8-23-26.2s10.1-26.2 23-26.2 23.2 11.8 23 26.2c0 14.4-10.2 26.2-23 26.2m85 0c-12.6 0-23-11.8-23-26.2s10.2-26.2 23-26.2 23.3 11.8 23 26.2c0 14.4-10.1 26.2-23 26.2"/></svg>`,
displayName: "Discord",
pkce: true,
scopes: []string{"identify", "email"},
+2
View File
@@ -27,6 +27,8 @@ type Facebook struct {
func NewFacebookProvider() *Facebook {
return &Facebook{BaseProvider{
ctx: context.Background(),
order: 5,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#1877f2" d="M256 128a128 128 0 1 0-148 126.4V165H75.5v-37H108V99.8c0-32 19.1-49.8 48.3-49.8 14 0 28.7 2.5 28.7 2.5V84h-16.1c-16 0-20.9 9.9-20.9 20v24h35.5l-5.7 37H148v89.4A128 128 0 0 0 256 128"/><path fill="#fff" d="m177.8 165 5.7-37H148v-24c0-10.1 5-20 20.9-20H185V52.5S170.4 50 156.3 50C127.1 50 108 67.7 108 99.8V128H75.5v37H108v89.4a129 129 0 0 0 40 0V165z"/></svg>`,
displayName: "Facebook",
pkce: true,
scopes: []string{"email"},
+2
View File
@@ -27,6 +27,8 @@ type Gitea struct {
func NewGiteaProvider() *Gitea {
return &Gitea{BaseProvider{
ctx: context.Background(),
order: 11,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m396 484-127-61c-12-6-18-21-12-34l61-127c6-12 21-17 34-11l27 13V154h17v118s57 24 83 40q7 3 13 14 3 10-1 19l-61 127c-6 13-22 18-34 12" style="fill:#fff"/><path d="M623 150c-4-4-10-4-10-4l-178 8-39 1v117l-17-8V155l-89-3-157-8q-15-2-39 1c-9 2-34 8-54 27C-5 212 7 276 8 286c2 12 7 44 32 72 46 56 144 55 144 55s12 29 31 56c25 33 50 59 75 62h189s12 0 29-11c14-8 26-23 26-23s13-14 31-45l14-28s55-118 55-232c-1-34-9-40-11-42M126 354c-26-9-37-19-37-19s-19-13-29-40c-16-44-1-71-1-71s8-22 38-30c14-4 31-3 31-3s7 59 16 94c7 30 25 78 25 78s-26-3-43-9m300 108s-6 14-20 15l-10-1-5-2-113-55s-11-6-13-16c-2-8 3-18 3-18l54-112s5-10 12-13l5-1c8-3 18 2 18 2l110 54s13 6 16 16q1 12-2 17c-6 16-55 114-55 114" style="fill:#609926"/><path d="M327 380q-14 1-17 14-2 13 9 20c7 4 17 2 22-5q8-13-1-24l24-49h6l7-4 29 16 5 5c2 6-2 15-2 15-2 7-18 40-18 40q-13 1-18 13-4 14 9 21a18 18 0 0 0 21-28l6-11 13-30c1-2 6-11 3-22-2-11-13-16-13-16-12-8-29-16-29-16l-1-7-4-6 14-29-12-6-14 29q-11 0-16 10t1 20z" style="fill:#609926"/></svg>`,
displayName: "Gitea",
pkce: true,
scopes: []string{"read:user", "user:email"},
+2
View File
@@ -29,6 +29,8 @@ type Gitee struct {
func NewGiteeProvider() *Gitee {
return &Gitee{BaseProvider{
ctx: context.Background(),
order: 10,
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="120 13 72 72"><g fill="none" fill-rule="evenodd"><path d="M0 0h312v100H0z"/><path fill="#c71d23" d="M156 85a36 36 0 1 1 0-72 36 36 0 0 1 0 72m18.2-40h-20.4q-1.7.1-1.8 1.8v4.4q.2 1.6 1.8 1.8h12.4q1.7.1 1.8 1.8v.9c0 3-2.4 5.3-5.3 5.3h-17q-1.6-.1-1.7-1.8V42.3c0-3 2.4-5.3 5.3-5.3h25q1.5-.1 1.7-1.8v-4.4a2 2 0 0 0-1.8-1.8h-24.9C142 29 136 35 136 42.3v25q.2 1.5 1.8 1.7H164a12 12 0 0 0 12-12V46.8q-.2-1.6-1.8-1.8"/></g></svg>`,
displayName: "Gitee",
pkce: true,
scopes: []string{"user_info", "emails"},
+2
View File
@@ -29,6 +29,8 @@ type Github struct {
func NewGithubProvider() *Github {
return &Github{BaseProvider{
ctx: context.Background(),
order: 7,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="250" preserveAspectRatio="xMidYMid"><path fill="#161614" d="M128 0a128 128 0 0 0-40.5 249.5c6.4 1.1 8.8-2.8 8.8-6.2l-.2-23.8C60.5 227.2 53 204.4 53 204.4c-5.8-14.8-14.2-18.8-14.2-18.8-11.6-7.9.8-7.7.8-7.7 12.9.9 19.7 13.1 19.7 13.1 11.4 19.6 30 14 37.2 10.7 1.2-8.3 4.5-14 8.1-17.1-28.4-3.3-58.3-14.2-58.3-63.3 0-14 5-25.4 13.2-34.3a46 46 0 0 1 1.3-34S71.5 49.7 96 66.3a123 123 0 0 1 64 0c24.5-16.6 35.2-13.1 35.2-13.1a46 46 0 0 1 1.3 33.9c8.2 9 13.2 20.3 13.2 34.3 0 49.2-30 60-58.5 63.2 4.6 4 8.7 11.7 8.7 23.7l-.2 35.1c0 3.4 2.4 7.4 8.8 6.1A128 128 0 0 0 128 0M48 182.3q-.6 1.1-2.3.4c-.9-.4-1.4-1.3-1.1-1.9q.6-1 2.2-.4 1.6.8 1.1 2m6.2 5.7c-.6.5-1.8.3-2.6-.6q-1.2-1.7-.4-2.7 1.2-.8 2.7.6 1.3 1.5.3 2.7m4.4 7.1c-.8.6-2.1 0-2.9-1-.8-1.2-.8-2.6 0-3.1q1.4-.7 2.9 1c.8 1.2.8 2.6 0 3.1m7.3 8.4c-.7.7-2.2.5-3.3-.5s-1.5-2.5-.8-3.3c.8-.8 2.3-.5 3.4.5 1 1 1.4 2.5.7 3.3m9.4 2.8c-.3 1-1.7 1.4-3.2 1-1.4-.4-2.4-1.6-2.1-2.6s1.7-1.5 3.2-1 2.4 1.6 2.1 2.6m10.7 1.2q-.1 1.7-2.7 2-2.5-.2-2.8-2c0-1 1.2-1.9 2.8-1.9s2.7.8 2.7 1.9m10.6-.4c.2 1-.9 2-2.4 2.3s-2.8-.3-3-1.3c-.2-1.1.9-2.2 2.3-2.4 1.6-.3 3 .3 3.1 1.4"/></svg>`,
displayName: "GitHub",
pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored
scopes: []string{"read:user", "user:email"},
+2
View File
@@ -27,6 +27,8 @@ type Gitlab struct {
func NewGitlabProvider() *Gitlab {
return &Gitlab{BaseProvider{
ctx: context.Background(),
order: 8,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="247" fill="none"><path fill="#e24329" d="m251.7 97.7-.3-.9-34.7-90.6a9 9 0 0 0-9-5.7q-3 .2-5.2 2a9 9 0 0 0-3.1 4.7L176 78.9H81L57.7 7.2a9.1 9.1 0 0 0-17.2-1L5.6 96.8l-.4.9a64.4 64.4 0 0 0 21.4 74.5h.1l.3.3L80 212l26 19.8 16 12a11 11 0 0 0 13 0l15.9-12 26.2-19.8 53.1-39.9h.2a64.5 64.5 0 0 0 21.3-74.5"/><path fill="#fc6d26" d="m251.7 97.7-.3-.9c-17 3.5-32.9 10.6-46.7 21l-76.2 57.6 48.5 36.7 53.2-39.8.2-.1a64.5 64.5 0 0 0 21.3-74.5"/><path fill="#fca326" d="m80 212.1 26 19.8 16 12a11 11 0 0 0 13 0l15.9-12 26.2-19.8s-22.7-17-48.6-36.7z"/><path fill="#fc6d26" d="M52.2 117.8a117 117 0 0 0-46.6-21l-.4.9a64.4 64.4 0 0 0 21.4 74.5h.1l.3.3L80 212l48.5-36.7z"/></svg>`,
displayName: "GitLab",
pkce: true,
scopes: []string{"read_user"},
+2
View File
@@ -26,6 +26,8 @@ type Google struct {
func NewGoogleProvider() *Google {
return &Google{BaseProvider{
ctx: context.Background(),
order: 2,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="262" preserveAspectRatio="xMidYMid"><path fill="#4285f4" d="M255.9 133.5c0-10.8-.9-18.6-2.8-26.7H130.6v48.4h71.9a64 64 0 0 1-26.7 42.4l-.2 1.6 38.7 30 2.7.3c24.7-22.8 38.9-56.3 38.9-96"/><path fill="#34a853" d="M130.6 261.1c35.2 0 64.8-11.6 86.4-31.6l-41.2-32a76 76 0 0 1-45.2 13.1 79 79 0 0 1-74.3-54.2l-1.5.1-40.3 31.2-.6 1.5A131 131 0 0 0 130.6 261"/><path fill="#fbbc05" d="M56.3 156.4a80 80 0 0 1-.2-51.7V103L15.3 71.3l-1.4.6a131 131 0 0 0 0 117.3z"/><path fill="#eb4335" d="M130.6 50.5c24.5 0 41 10.6 50.4 19.4L218 34c-22.8-21-52.2-34-87.4-34C79.5 0 35.4 29.3 13.9 72l42.2 32.7a79 79 0 0 1 74.5-54.2"/></svg>`,
displayName: "Google",
pkce: true,
scopes: []string{
+2
View File
@@ -26,6 +26,8 @@ type Instagram struct {
func NewInstagramProvider() *Instagram {
return &Instagram{BaseProvider{
ctx: context.Background(),
order: 6,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 132 132"><defs><radialGradient xlink:href="#a" id="c" cx="158.4" cy="578.1" r="65" fx="158.4" fy="578.1" gradientTransform="matrix(0 -1.98198 1.8439 0 -1031.4 454)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#b" id="d" cx="147.7" cy="473.5" r="65" fx="147.7" fy="473.5" gradientTransform="rotate(78.7 1103.9 776.3)scale(.88596 3.6529)" gradientUnits="userSpaceOnUse"/><linearGradient id="b"><stop offset="0" stop-color="#3771c8"/><stop offset=".1" stop-color="#3771c8"/><stop offset="1" stop-color="#60f" stop-opacity="0"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#fd5"/><stop offset=".1" stop-color="#fd5"/><stop offset=".5" stop-color="#ff543e"/><stop offset="1" stop-color="#c837ab"/></linearGradient></defs><path fill="url(#c)" d="M65 0C38 0 30 0 28.4.2c-5.6.4-9 1.3-12.8 3.2a28 28 0 0 0-15 21.3c-.4 3-.6 3.6-.6 19.1V65c0 27 0 35 .2 36.6.4 5.4 1.3 8.8 3 12.5 3.5 7.2 10 12.5 17.8 14.5q4 1 9.5 1.3a2913 2913 0 0 0 68.8 0c4.4-.2 7-.6 9.8-1.3 7.8-2 14.2-7.3 17.7-14.5 1.8-3.7 2.7-7.2 3.1-12.3a2759 2759 0 0 0 0-73.6c-.4-5.2-1.3-8.8-3.1-12.5q-2.1-4.3-5.6-7.6A28 28 0 0 0 105.4.6c-3-.4-3.7-.6-19.2-.6z" transform="translate(1 1)"/><path fill="url(#d)" d="M65 0C38 0 30 0 28.4.2c-5.6.4-9 1.3-12.8 3.2a28 28 0 0 0-15 21.3c-.4 3-.6 3.6-.6 19.1V65c0 27 0 35 .2 36.6.4 5.4 1.3 8.8 3 12.5 3.5 7.2 10 12.5 17.8 14.5q4 1 9.5 1.3a2913 2913 0 0 0 68.8 0c4.4-.2 7-.6 9.8-1.3 7.8-2 14.2-7.3 17.7-14.5 1.8-3.7 2.7-7.2 3.1-12.3a2759 2759 0 0 0 0-73.6c-.4-5.2-1.3-8.8-3.1-12.5q-2.1-4.3-5.6-7.6A28 28 0 0 0 105.4.6c-3-.4-3.7-.6-19.2-.6z" transform="translate(1 1)"/><path fill="#fff" d="M66 18c-13 0-14.7 0-19.8.3s-8.6 1-11.6 2.2a24 24 0 0 0-8.5 5.6 24 24 0 0 0-5.6 8.5 35 35 0 0 0-2.2 11.6C18 51.3 18 53 18 66s0 14.7.3 19.8 1 8.6 2.2 11.6q1.7 4.7 5.6 8.5 3.8 4 8.5 5.6c3 1.2 6.5 2 11.6 2.2s6.8.3 19.8.3 14.7 0 19.8-.3 8.6-1 11.6-2.2q4.7-1.7 8.5-5.6t5.6-8.5c1.2-3 2-6.5 2.2-11.6s.3-6.8.3-19.8 0-14.7-.3-19.8-1-8.6-2.2-11.6a24 24 0 0 0-5.6-8.5 24 24 0 0 0-8.5-5.6c-3-1.2-6.5-2-11.6-2.2S79 18 66 18m-4.3 8.7H66c12.8 0 14.3 0 19.4.2 4.7.2 7.2 1 9 1.7 2.2.8 3.7 1.9 5.4 3.6q2.4 2.2 3.6 5.5c.7 1.7 1.5 4.2 1.7 8.9.2 5 .3 6.6.3 19.4s-.1 14.3-.3 19.4c-.2 4.7-1 7.2-1.7 8.9q-1.1 3.1-3.6 5.5a15 15 0 0 1-5.5 3.6c-1.7.7-4.2 1.4-8.9 1.6-5 .3-6.6.3-19.4.3a334 334 0 0 1-19.4-.3 28 28 0 0 1-8.9-1.6q-3.1-1.3-5.5-3.6a15 15 0 0 1-3.6-5.5 25 25 0 0 1-1.7-9c-.2-5-.2-6.5-.2-19.3s0-14.4.2-19.4a26 26 0 0 1 1.7-9q1.2-3.1 3.6-5.4 2.4-2.5 5.5-3.6a25 25 0 0 1 9-1.7c4.3-.2 6-.3 15-.3zm30 8a5.8 5.8 0 1 0 0 11.4 5.8 5.8 0 0 0 0-11.5M66 41.2a24.7 24.7 0 1 0 0 49.4 24.7 24.7 0 0 0 0-49.3m0 8.7a16 16 0 1 1 0 32 16 16 0 0 1 0-32"/></svg>`,
displayName: "Instagram",
pkce: true,
scopes: []string{"instagram_business_basic"},
+2
View File
@@ -28,6 +28,8 @@ type Kakao struct {
func NewKakaoProvider() *Kakao {
return &Kakao{BaseProvider{
ctx: context.Background(),
order: 14,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 256 256"><path fill="#ffe812" d="M256 236q-2 18-20 20H20q-18-2-20-20V20Q2 2 20 0h216q18 2 20 20z"/><path d="M128 36C71 36 24 73 24 118c0 29 19 55 49 69l-11 38 1 3h3l44-29 18 1c57 0 104-37 104-82s-47-82-104-82"/><path fill="#ffe812" d="M71 147q-6-1-6-6v-36H55a6 6 0 0 1 0-11h31a6 6 0 0 1 0 11h-9v36q-1 5-6 6m52 0q-3 0-5-3l-3-8H97l-3 8q-2 3-5 3l-4-1q-3-1-1-9l14-38q2-4 8-5 6 1 8 5l14 38q2 8-1 9zm-11-22-6-17-6 17zm26 21q-5-1-6-6v-40q1-6 6-6 7 0 7 6v35h12q5 0 6 5 0 7-6 6zm33 1q-5-1-6-6v-41a6 6 0 0 1 12 0v12l17-16 3-2 5 2 1 4-1 4-14 13 15 20 1 4-2 4-4 1-5-2-14-19-2 2v14a6 6 0 0 1-6 6"/></svg>`,
displayName: "Kakao",
pkce: true,
scopes: []string{"account_email", "profile_nickname", "profile_image"},
+2
View File
@@ -26,6 +26,8 @@ type Lark struct {
func NewLarkProvider() *Lark {
return &Lark{BaseProvider{
ctx: context.Background(),
order: 19,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" fill="none"><path fill="#00d6b9" d="M137 126v-1l2-1v-1l3-2 3-3 3-3 2-3 3-2 2-3 4-3 2-2 4-3 9-7 5-3 9-3 2-1a135 135 0 0 0-26-51 12 12 0 0 0-9-4H56l-2 1 1 2a288 288 0 0 1 82 93"/><path fill="#3370ff" d="M98 212c51 0 95-28 118-69l3-5-6 9-4 4-6 5-2 2-4 3-11 4-6 2h-15l-4-1h-3l-2-1-5-1-3-1-4-1-3-1-3-1-1-1-3-1h-2l-3-2h-2l-2-1-3-1-2-1-2-1-2-1h-2l-1-1-2-1h-1l-1-1-2-1-2-1-2-1-2-1a285 285 0 0 1-81-60l-3 1v94a12 12 0 0 0 5 11 135 135 0 0 0 76 22"/><path fill="#133c9a" d="M248 90a78 78 0 0 0-58-5l-2 1-14 6-6 4-7 6-2 2-4 3-2 3-3 2-2 3-3 3-3 3-3 2v1l-2 1-1 1-1 1a137 137 0 0 1-28 20l2 1 1 1h2l1 1 1 1h2l2 1 2 1 2 1 3 1 2 1h2l3 2h2l3 1 2 1 2 1 3 1 4 1 8 2 2 1 7 1h15l6-2 7-2 6-4 2-1 2-2 3-1 7-8 6-9 1-2 12-24a77 77 0 0 1 16-22"/></svg>`,
displayName: "Lark",
pkce: true,
// Lark has two domains with the same API: feishu.cn and larksuite.com.
+2
View File
@@ -31,6 +31,8 @@ type Linear struct {
func NewLinearProvider() *Linear {
return &Linear{BaseProvider{
ctx: context.Background(),
order: 16,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#222326" viewBox="0 0 100 100"><path d="M1.2 61.5c-.2-1 1-1.5 1.6-.8l36.5 36.5c.7.7.1 1.8-.8 1.6A50 50 0 0 1 1.2 61.5M0 47q0 .4.3.7l52 52.1q.4.3.8.3a50 50 0 0 0 7-1 1 1 0 0 0 .5-1.6l-58-58a1 1 0 0 0-1.7.5 50 50 0 0 0-.9 7m4.2-17.2q-.2.6.2 1.1l64.8 64.8q.5.5 1 .2l5.3-2.7q.8-.6.2-1.5L8.4 24.3a1 1 0 0 0-1.5.2Q5.4 27 4.2 29.7m8.5-11.6a1 1 0 0 1 0-1.4A50 50 0 0 1 100 50a50 50 0 0 1-16.7 37.4 1 1 0 0 1-1.4 0z"/></svg>`,
displayName: "Linear",
pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled
scopes: []string{"read"},
+2
View File
@@ -26,6 +26,8 @@ type Livechat struct {
func NewLivechatProvider() *Livechat {
return &Livechat{BaseProvider{
ctx: context.Background(),
order: 27,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 80 80"><path d="M79.5 49.7a19 19 0 0 1-19 17.3H50L30 80V67l20-13h10.5a6 6 0 0 0 6.1-5.3q1-15-.2-30a5.6 5.6 0 0 0-5.2-5.1Q50.9 13.1 40 13c-10.9-.1-14.4.2-21.2.7-2.8.2-5 2.3-5.2 5.1q-1 15-.2 30c.3 3.1 3 5.3 6.1 5.2H30v13H19.5c-9.9.1-18.2-7.4-19-17.3q-1-16 .2-32C1.5 8.5 8.8 1.3 18 .7a313 313 0 0 1 44.1.1c9.2.6 16.5 7.8 17.3 17q1.1 16 .1 31.9" style="fill:#ff5100"/></svg>`,
displayName: "LiveChat",
pkce: true,
scopes: []string{}, // default scopes are specified from the provider dashboard
+2
View File
@@ -28,6 +28,8 @@ type Mailcow struct {
func NewMailcowProvider() *Mailcow {
return &Mailcow{BaseProvider{
ctx: context.Background(),
order: 28,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="394.8" height="376.9"><path d="M56 213.3c0-20.3-.8-17.3-3.7-39.3 2.2-22.5-7.6-38.9-7.7-58.4 0-4.7-5.8-6.7-4.7-11A99 99 0 0 0 56 214.7z" style="fill:#3d5263" transform="translate(10 10)"/><path d="m254.8 180.4-.6.5a121 121 0 0 1-80 84l11 9.2 53.2 44.8 31.6 26.5q1.3-3 1.3-6.4V167.1zM23 185.5l-6.5-5.2-16.5-13v171.6q0 3.5 1.2 6.5l58.4-48.7 27-22.4L99 263.9a121 121 0 0 1-76-78.4" style="fill:#f9e82d" transform="translate(10 10)"/><path d="M238.4 318.9 185.1 274l-11-9.2a120 120 0 0 1-75.1-1l-12.4 10.4-27 22.5-58.4 48.6A18 18 0 0 0 18 357h235.4a18 18 0 0 0 16.7-11.5z" style="fill:#edd514;fill-opacity:.89499996" transform="translate(10 10)"/><path d="M238.4 318.9c-41.4 4-115 5.5-178.8-22.1-21.5-9.4-42-22.1-59.6-39.2V339q0 3.5 1.2 6.5A18 18 0 0 0 18 357h235.4a18 18 0 0 0 18-18v-24.5s-12.6 2.5-32.9 4.5" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M86.6 274.3a120 120 0 0 0 98.5-.2 120 120 0 0 0 69.7-93.7l-.6.5a120.5 120.5 0 0 1-155.2 83 121 121 0 0 1-76-78.4l-6.5-5.2c5.5 42 32.7 77.3 70 94" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M54.3 63.9q-2.7 2.6-5.2 5.4l-.2.1-.7.8.2-.2a120.2 120.2 0 0 1 174.8 165 120 120 0 0 0 35-84.8A120 120 0 0 0 187 40.4a119 119 0 0 0-100.7 1.2 120 120 0 0 0-32 22.3" style="fill:#fff" transform="translate(10 10)"/><path d="M95.8 118.5a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2m91.1 0a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2" style="fill:#fff" transform="translate(10 10)"/><path d="M223.7 234.4A120.2 120.2 0 0 0 48.4 70 121 121 0 0 0 23 185.5 120.5 120.5 0 0 0 174.2 265a120 120 0 0 0 47.7-28.6zm-5.8-58.4q-3.2 11-8.7 20.5A94.8 94.8 0 0 1 56 214.8a99 99 0 0 1-16-110.3 96 96 0 0 1 97.8-54 97.3 97.3 0 0 1 84.3 97.2q0 14.9-4 28.3M49 69.3" style="fill:#f1f2f2" transform="translate(10 10)"/><path d="M257.6 161.9a120 120 0 0 1-3.4 19l.6-.5 16.5-13.2zM0 167.2l16.5 13.1 6.5 5.2q-3.5-11.6-4.7-23.9l-2.9.9zm87.5 25.1a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m93.8 0a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m1.7-90c-7 0-15.4 7.7-15.4 14.7s8 17.2 15 17.2 15.8-9.5 15.8-16.5-8.4-15.4-15.4-15.4m3.9 16.2a4.6 4.6 0 1 1 0-9.2 4.6 4.6 0 0 1 0 9.2m-97.2-15.9c-7 0-14.4 8.1-14.4 15s8.9 16.2 15.8 16.2 15.8-9.1 15.8-16.1-10.2-15.1-17.2-15.1m6.1 16a4.6 4.6 0 1 1 0-9.3 4.6 4.6 0 0 1 0 9.2" style="fill:#5a3620" transform="translate(10 10)"/><path d="M336.3 256.4c3.6-9.1 7.7-11 9.3-11.3-40.7 3.7-36.6 27.7-34 36l1 2.7s2 4.8 7.6 8.7c4.1 3 10.3 5.5 19 5.2 27 .4 35.6-50.2 35.6-50.2-6.6 11.7-26.4 9.9-38.5 9M49 69.4l5.3-5.4c-9-7-24.4-15.9-41.8-11.7A54 54 0 0 0 36 86.5q5.5-8.8 12.4-16.5l-.2.2zm209.8-17.2a49 49 0 0 0-39.2 9.8q10.8 9.9 19 22.3a54 54 0 0 0 20.2-32M134.3 160.2c-43.3 0-78.4 23-78.4 51.3v1.7l.2 1.6a94.8 94.8 0 0 0 153.1-18.3c-9.8-21-39.6-36.3-75-36.3M87.5 215a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5m93.8 0a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5M86.3 0c-18.2 16.4-.2 41.4 0 41.6Q102.6 34 121 31.1C97.6 27.7 86.3 0 86.3 0m99.9 0S175.3 26.5 153 30.9q18 2.3 34 9.5c3.4-5.3 15-26.1-.8-40.4" style="fill:#fef3df" transform="translate(10 10)"/><path d="M218 176a163 163 0 0 0 6.5-35.7c0-50-39.3-83.9-86.8-89.8-2.1 28 3.7 87 80.2 125.5m-47.6-58.3a12.6 12.6 0 1 1 25.2 0 12.6 12.6 0 0 1-25.2 0" style="fill:#87654a" transform="translate(10 10)"/><path d="m312.7 283.8-1-2.8a54 54 0 0 1-27.1 12.5q-6 1-13.3.5v28a206 206 0 0 0 26.2-12.5h.1c8.2-4.7 16.4-10.5 22.6-17-5.5-4-7.5-8.8-7.5-8.8M12.5 52.2C30 48 45.4 57 54.3 64q8.4-8.2 18.3-14.6C48.3 18.5 2.2 37.2 2.2 37.2A55 55 0 0 0 31.7 94q2-3.7 4.3-7.5C15.4 72.7 12.5 52.2 12.5 52.2m187.9-4.8q10.3 6.3 19.2 14.6a49 49 0 0 1 39.2-9.8s-2.5 18.4-20.3 32q2.5 3.8 4.6 7.7a54 54 0 0 0 26-54.7s-44-18-68.7 10.2m-61.5 3.1a96 96 0 0 0-99 54q-1.5 6.6-1.6 13.6v4.2q.3 6 1 11.1c4.2 25 7.9 42.5 13.4 66.8l.2 1.2q1 6 3 11.8v-1.7c0-28.3 35-51.3 78.4-51.3 35.3 0 65.1 15.3 75 36.3a99 99 0 0 0 8.6-20.5c-38-23.5-53.9-41.1-64.6-64.2-10.8-23-15.5-47.3-14.4-61.3M91 130.3a12.6 12.6 0 1 1 0-25.2 12.6 12.6 0 0 1 0 25.2" style="fill:#b58765" transform="translate(10 10)"/></svg>`,
displayName: "mailcow",
pkce: true,
scopes: []string{"profile"},
+2
View File
@@ -28,6 +28,8 @@ func NewMicrosoftProvider() *Microsoft {
endpoints := microsoft.AzureADEndpoint("")
return &Microsoft{BaseProvider{
ctx: context.Background(),
order: 3,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#f1511b" d="M122 122H0V0h122z"/><path fill="#80cc28" d="M256 122H134V0h122z"/><path fill="#00adef" d="M122 256H0V134h122z"/><path fill="#fbbc09" d="M256 256H134V134h122z"/></svg>`,
displayName: "Microsoft",
pkce: true,
scopes: []string{"User.Read"},
+2
View File
@@ -29,6 +29,8 @@ type Monday struct {
func NewMondayProvider() *Monday {
return &Monday{BaseProvider{
ctx: context.Background(),
order: 18,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 127 127"><path fill="#fb275d" d="M24.8 87.2c-3.7 0-7.1-1.9-8.9-5.1a9 9 0 0 1 .3-9.9L34.5 44c1.9-3.1 5.4-4.9 9.1-4.8s7.1 2.1 8.8 5.3 1.5 7-.5 9.9L33.4 82.6a10 10 0 0 1-8.6 4.6"/><path fill="#fc0" d="M56.1 87.2c-3.7 0-7.1-1.9-8.9-5a9 9 0 0 1 .3-9.9l18.4-28.1c1.9-3.1 5.3-5 9.1-4.9s7.1 2.1 8.8 5.3 1.5 7-.7 10l-18.4 28a11 11 0 0 1-8.6 4.6"/><path fill="#00ca72" d="M86.7 87.2c5.6 0 10.2-4.6 10.2-10.2s-4.6-10.2-10.2-10.2S76.5 71.4 76.5 77c0 5.7 4.5 10.2 10.2 10.2"/></svg>`,
displayName: "monday.com",
pkce: true,
scopes: []string{"me:read"},
+2
View File
@@ -27,6 +27,8 @@ type Notion struct {
func NewNotionProvider() *Notion {
return &Notion{BaseProvider{
ctx: context.Background(),
order: 17,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="none"><path fill="#000" d="M61 0 6 4q-6 2-6 7v61l3 8 13 17q2 4 8 3l65-4q7-1 7-7V21q0-3-4-5L74 3q-4-4-13-3M26 20q-7 1-9-2l-8-6q-1-2 1-2l54-4q5 0 8 2l9 7 1 1zm-6 68V30q0-3 3-4l63-3q3 0 3 3v58q1 5-4 5l-60 3q-5 1-5-4m59-55q1 4-1 4l-3 1v43l-7 2q-4 0-6-4L43 49v29l6 1s0 4-5 4H30l2-3 3-1V41h-5q0-4 4-5l14-1 20 31V39l-5-1q0-3 3-4z"/></svg>`,
displayName: "Notion",
pkce: true,
authURL: "https://api.notion.com/v1/oauth/authorize",
+2
View File
@@ -57,6 +57,8 @@ type OIDC struct {
func NewOIDCProvider() *OIDC {
return &OIDC{BaseProvider{
ctx: context.Background(),
order: 99,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="93" height="84" fill="none"><path fill="#ccc" d="M83.4 32.9c-8.7-5.5-21-8.8-34.3-8.8C22 24 .4 37.5.4 54 .4 69.3 18.5 81.8 42 84v-8.7c-15.9-2-27.8-10.7-27.8-21 0-11.9 15.5-21.6 34.8-21.6 9.5 0 18.2 2.4 24.5 6.3l-9 5.6h27.9V27.3z"/><path fill="#ff6200" d="M42 9.2V84l14-8.7V.2z"/></svg>`,
displayName: "OIDC",
pkce: true,
scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Patreon struct {
func NewPatreonProvider() *Patreon {
return &Patreon{BaseProvider{
ctx: context.Background(),
order: 24,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 1080 1080"><path d="M1033 324c0-137-108-250-234-291a746 746 0 0 0-512 27C106 145 49 333 47 519c-2 154 14 558 242 561 169 2 194-216 273-321 56-75 127-96 216-118a320 320 0 0 0 255-317"/></svg>`,
displayName: "Patreon",
pkce: true,
scopes: []string{"identity", "identity[email]"},
+2
View File
@@ -27,6 +27,8 @@ type Planningcenter struct {
func NewPlanningcenterProvider() *Planningcenter {
return &Planningcenter{BaseProvider{
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",
pkce: true,
scopes: []string{"people"},
+2
View File
@@ -27,6 +27,8 @@ type Spotify struct {
func NewSpotifyProvider() *Spotify {
return &Spotify{BaseProvider{
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",
pkce: true,
scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Strava struct {
func NewStravaProvider() *Strava {
return &Strava{BaseProvider{
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",
pkce: true,
scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Trakt struct {
func NewTraktProvider() *Trakt {
return &Trakt{BaseProvider{
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",
pkce: true,
authURL: "https://trakt.tv/oauth/authorize",
+2
View File
@@ -29,6 +29,8 @@ type Twitch struct {
func NewTwitchProvider() *Twitch {
return &Twitch{BaseProvider{
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",
pkce: true,
scopes: []string{"user:read:email"},
+2
View File
@@ -26,6 +26,8 @@ type Twitter struct {
func NewTwitterProvider() *Twitter {
return &Twitter{BaseProvider{
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",
pkce: true,
scopes: []string{
+4
View File
@@ -22,6 +22,8 @@ var _ Provider = (*VK)(nil)
// NameVK is the unique name of the VK provider.
const NameVK string = "vk"
// @todo mark as deprecated
//
// VK allows authentication via VK OAuth2.
type VK struct {
BaseProvider
@@ -33,6 +35,8 @@ type VK struct {
func NewVKProvider() *VK {
return &VK{BaseProvider{
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: "ВКонтакте",
pkce: false, // VK currently doesn't support PKCE and throws an error if PKCE params are send
scopes: []string{"email"},
+2
View File
@@ -26,6 +26,8 @@ type Wakatime struct {
func NewWakatimeProvider() *Wakatime {
return &Wakatime{BaseProvider{
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",
pkce: true,
scopes: []string{"email"},
+2
View File
@@ -29,6 +29,8 @@ type Yandex struct {
func NewYandexProvider() *Yandex {
return &Yandex{BaseProvider{
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",
pkce: true,
scopes: []string{"login:email", "login:avatar", "login:info"},
+4 -2
View File
@@ -1,12 +1,14 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = "../"
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_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.36.9"
PB_VERSION = "v0.37.0"
+21 -6
View File
@@ -1,7 +1,22 @@
/node_modules/
/.vscode/
.DS_Store
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# exclude local env files
.env.local
.env.*.local
node_modules
*.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
======================================================================
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
as part of a PocketBase app executable (hence the `embed.go` file).
Although it could be used independently, it is intended to be embedded and extended
as part of the PocketBase app executable (hence the `dist` directory and `embed.go` file).
> [!WARNING]
> The UI kit and extension APIs remains deliberately undocumented for the time being until a stable PocketBase release is published ([#7612](https://github.com/pocketbase/pocketbase/discussions/7612)).
## Development
Download the repo and run the appropriate console commands:
Download the repository and run the appropriate console commands:
```sh
# install dependencies
npm install
# start a dev server with hot reload at localhost:3000
# start a dev server with hot reload at localhost:5173
npm run dev
# or generate production ready bundle in dist/ directory
-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