mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cec579da9 | ||
|
|
5f496665c7 | ||
|
|
adf4eb822f | ||
|
|
114c01ac12 | ||
|
|
5684ee24f1 | ||
|
|
b3de909790 | ||
|
|
a53f189f74 | ||
|
|
7ff7e0f043 | ||
|
|
6e29284e6a | ||
|
|
1984952e4e | ||
|
|
f611256d90 | ||
|
|
10f4f28312 | ||
|
|
44f817e7f1 | ||
|
|
75c6a4fd1f | ||
|
|
57c0b034e6 | ||
|
|
4709f63183 | ||
|
|
53a6cd04e2 | ||
|
|
df4e6eeb35 | ||
|
|
dbea21f1b2 | ||
|
|
97f9d63a1b | ||
|
|
bf12ae0244 | ||
|
|
7ac159b61b |
@@ -1,3 +1,35 @@
|
||||
## v0.40.4
|
||||
|
||||
- Fixed migration deadlock if a logs db write happens to run while the migration is still executing ([#7836](https://github.com/pocketbase/pocketbase/issues/7836)).
|
||||
|
||||
- `app.ResetBootstrapState()` was soft-deprecated in favour of `app.ClearBootstrap()`.
|
||||
_Additionally a new `app.OnClearBootstrap()` hook was added to allow clearing custom allocated `OnBootstrap` resources in case the app uses a non-standard initialization (e.g. doesn't call `Start()` or intentionally skip the `OnTerminate` hook)._
|
||||
|
||||
- Bumped `golang.org/x/*` dependencies.
|
||||
|
||||
|
||||
## v0.40.3
|
||||
|
||||
- Write the status header for JSON responses only if the fields picker succeed or has acceptable fallback.
|
||||
_This is to allow custom response status code for failed json writes._
|
||||
|
||||
- Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
|
||||
|
||||
- Clamped arccosine to [-1,1] in the Harvesine formula for the `geoDistance()` filter function to workaround edge case related to float rounding errors for some coordinates.
|
||||
|
||||
- Prevent unnecessary body chunk read if we already known that we are beyond the allowed limit.
|
||||
|
||||
- Updated the `json` field validator to check the `encoding/json/v2` semantics and allow duplicated keys on record marshalize for compliance with old jsonv1 data.
|
||||
|
||||
- Fixed nested cascade delete of self-referenced relation records.
|
||||
|
||||
- Minor UI fixes (updated dark primary btn color contrast, force reload the records list if the deleted record has self-referenced cascade relation field, etc.).
|
||||
|
||||
- Changed JSVM `$app` variable definition from TS type to interface ([#7834](https://github.com/pocketbase/pocketbase/issues/7834)).
|
||||
|
||||
- Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
|
||||
|
||||
|
||||
## v0.40.2
|
||||
|
||||
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.55
|
||||
|
||||
- (_Backported from v0.40.3_) Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
|
||||
|
||||
- (_Backported from v0.40.3_) Fixed nested cascade delete of self-referenced relation records.
|
||||
|
||||
- (_Backported from v0.40.3_) Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
|
||||
|
||||
|
||||
## v0.22.54
|
||||
|
||||
- (_Backported from v0.40.2_) Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ func healthCheck(e *core.RequestEvent) error {
|
||||
headersToCheck := append(
|
||||
slices.Clone(e.App.Settings().TrustedProxy.Headers),
|
||||
// common proxy headers
|
||||
"CF-Connecting-IP", "Fly-Client-IP", "X‑Forwarded-For",
|
||||
"CF-Connecting-IP", "Fly-Client-IP", "X-Forwarded-For",
|
||||
)
|
||||
for _, header := range headersToCheck {
|
||||
if e.Request.Header.Get(header) != "" {
|
||||
|
||||
@@ -84,49 +84,73 @@ func applyBodyLimit(e *core.RequestEvent, limitBytes int64) error {
|
||||
}
|
||||
|
||||
// replace the request body
|
||||
//
|
||||
// note: we don't use sync.Pool since the size of the elements could vary too much
|
||||
// and it might not be efficient (see https://github.com/golang/go/issues/23199)
|
||||
e.Request.Body = &limitedReader{ReadCloser: e.Request.Body, limit: limitBytes}
|
||||
e.Request.Body = newMaxBytesReader(e.Request.Body, limitBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
io.ReadCloser
|
||||
limit int64
|
||||
totalRead int64
|
||||
func newMaxBytesReader(body io.ReadCloser, limitBytes int64) *maxBytesReader {
|
||||
return &maxBytesReader{
|
||||
ReadCloser: body,
|
||||
limit: limitBytes,
|
||||
remaining: limitBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Read(b []byte) (int, error) {
|
||||
// maxBytesReader is very similar to the http.MaxBytesReader but support
|
||||
// rereads and doesn't try to prematurely close the related response
|
||||
// to allow consequent middlewares to operate correctly.
|
||||
type maxBytesReader struct {
|
||||
io.ReadCloser
|
||||
limit int64
|
||||
remaining int64
|
||||
stickyErr error
|
||||
}
|
||||
|
||||
func (r *maxBytesReader) Read(b []byte) (int, error) {
|
||||
if r.stickyErr != nil {
|
||||
return 0, r.stickyErr
|
||||
}
|
||||
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// if possible no need to read the entire chunk since
|
||||
// remaining+1 is enough to determine whether it exceed the limit
|
||||
if int64(len(b))-1 > r.remaining {
|
||||
b = b[:r.remaining+1]
|
||||
}
|
||||
|
||||
n, err := r.ReadCloser.Read(b)
|
||||
if err != nil {
|
||||
|
||||
if int64(n) <= r.remaining {
|
||||
r.remaining -= int64(n)
|
||||
r.stickyErr = err
|
||||
return n, err
|
||||
}
|
||||
|
||||
r.totalRead += int64(n)
|
||||
if r.totalRead > r.limit {
|
||||
return n, ErrRequestEntityTooLarge
|
||||
}
|
||||
n = int(r.remaining)
|
||||
|
||||
return n, nil
|
||||
r.remaining = 0
|
||||
r.stickyErr = ErrRequestEntityTooLarge
|
||||
|
||||
return n, r.stickyErr
|
||||
}
|
||||
|
||||
// explicit casts to ensure that the main struct methods will be invoked
|
||||
// (extra precautions in case of nested interface wrapping erasure)
|
||||
// ---
|
||||
|
||||
func (r *limitedReader) Reread() {
|
||||
func (r *maxBytesReader) Reread() {
|
||||
rereader, ok := r.ReadCloser.(router.Rereader)
|
||||
if ok {
|
||||
rereader.Reread()
|
||||
r.remaining = r.limit
|
||||
r.stickyErr = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Close() error {
|
||||
closer, ok := r.ReadCloser.(io.Closer)
|
||||
if ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
func (r *maxBytesReader) Close() error {
|
||||
return r.ReadCloser.Close()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -19,13 +20,35 @@ func TestBodyLimitMiddleware(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pbRouter.POST("/a", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "a")
|
||||
}) // default global BodyLimit check
|
||||
|
||||
pbRouter.POST("/b", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "b")
|
||||
}).Bind(apis.BodyLimit(20))
|
||||
testHandler := func(e *core.RequestEvent) error {
|
||||
// read the body multiple times to ensure that the limited
|
||||
// reader guards and rereads are invoked
|
||||
var result any
|
||||
if err := e.BindBody(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.BindBody(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(200, result)
|
||||
}
|
||||
|
||||
const customLimit = 20
|
||||
|
||||
pbRouter.POST("/a", testHandler) // default global BodyLimit check
|
||||
pbRouter.POST("/b", testHandler).Bind(apis.BodyLimit(customLimit))
|
||||
pbRouter.POST("/iof", func(e *core.RequestEvent) error {
|
||||
// ensure that normal io methods still operate correctly
|
||||
b, err := io.ReadAll(e.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.String(http.StatusOK, string(b))
|
||||
}).Bind(apis.BodyLimit(customLimit))
|
||||
|
||||
mux, err := pbRouter.BuildMux()
|
||||
if err != nil {
|
||||
@@ -33,20 +56,90 @@ func TestBodyLimitMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
size int64
|
||||
expectedStatus int
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
lazyContentLength bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/a", 21, 200},
|
||||
{"/a", apis.DefaultMaxBodySize + 1, 413},
|
||||
{"/b", 20, 200},
|
||||
{"/b", 21, 413},
|
||||
{
|
||||
"(eager content-length check) with body = default limit",
|
||||
"/a",
|
||||
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
|
||||
false,
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"(eager content-length check) with body > default limit",
|
||||
"/a",
|
||||
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
|
||||
false,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
"(lazy content-length check) with body = default limit",
|
||||
"/a",
|
||||
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
|
||||
true,
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"(lazy content-length check) with body > default limit",
|
||||
"/a",
|
||||
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
|
||||
true,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
// ---
|
||||
{
|
||||
"(eager content-length check) with body = custom limit",
|
||||
"/b",
|
||||
`"` + strings.Repeat("a", customLimit-2) + `"`,
|
||||
false,
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"(eager content-length check) with body > custom limit",
|
||||
"/b",
|
||||
`"` + strings.Repeat("a", customLimit) + `"`,
|
||||
false,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
"(lazy content-length check) with body = custom limit",
|
||||
"/b",
|
||||
`"` + strings.Repeat("a", customLimit-2) + `"`,
|
||||
true,
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"(lazy content-length check) with body > custom limit",
|
||||
"/b",
|
||||
`"` + strings.Repeat("a", customLimit) + `"`,
|
||||
true,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
// ---
|
||||
{
|
||||
"io.ReadAll io.EOF exact limit check",
|
||||
"/iof",
|
||||
`"` + strings.Repeat("a", customLimit-2) + `"`,
|
||||
true,
|
||||
http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%s_%d", s.url, s.size), func(t *testing.T) {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", s.url, bytes.NewReader(make([]byte, s.size)))
|
||||
|
||||
req := httptest.NewRequest("POST", s.url, strings.NewReader(s.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if s.lazyContentLength {
|
||||
req.ContentLength = -1
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
+14
-2
@@ -54,11 +54,16 @@ type App interface {
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
// It calls ClearBootstrap() if the application was already bootstrapped.
|
||||
Bootstrap() error
|
||||
|
||||
// ResetBootstrapState releases the initialized core app resources
|
||||
// ClearBootstrap releases the initialized core app resources
|
||||
// (closing db connections, stopping cron ticker, etc.).
|
||||
//
|
||||
// This method is no-op if the application is not bootstrapped yet.
|
||||
ClearBootstrap() error
|
||||
|
||||
// Deprecated: use ClearBootstrap().
|
||||
ResetBootstrapState() error
|
||||
|
||||
// DataDir returns the app data directory path.
|
||||
@@ -713,6 +718,13 @@ type App interface {
|
||||
// resources (db, app settings, etc).
|
||||
OnBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBootstrapClear hook is triggered when clearing the main application
|
||||
// resources (db connections, cron, logger, etc.)
|
||||
//
|
||||
// It is usually invoked automatically right before app termination
|
||||
// or when manually calling app.ClearBootstrap().
|
||||
OnBootstrapClear() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnServe hook is triggered when the app web server is started
|
||||
// (after starting the TCP listener but before initializing the blocking serve task),
|
||||
// allowing you to adjust its options and attach new routes or middlewares.
|
||||
|
||||
+120
-65
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -89,11 +90,12 @@ type BaseApp struct {
|
||||
auxNonconcurrentDB dbx.Builder
|
||||
|
||||
// app event hooks
|
||||
onBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onServe *hook.Hook[*ServeEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
onBackupCreate *hook.Hook[*BackupEvent]
|
||||
onBackupRestore *hook.Hook[*BackupEvent]
|
||||
onBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onBootstrapClear *hook.Hook[*BootstrapEvent]
|
||||
onServe *hook.Hook[*ServeEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
onBackupCreate *hook.Hook[*BackupEvent]
|
||||
onBackupRestore *hook.Hook[*BackupEvent]
|
||||
|
||||
// db model hooks
|
||||
onModelValidate *hook.Hook[*ModelEvent]
|
||||
@@ -249,6 +251,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
func (app *BaseApp) initHooks() {
|
||||
// app event hooks
|
||||
app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
|
||||
app.onBootstrapClear = &hook.Hook[*BootstrapEvent]{}
|
||||
app.onServe = &hook.Hook[*ServeEvent]{}
|
||||
app.onTerminate = &hook.Hook[*TerminateEvent]{}
|
||||
app.onBackupCreate = &hook.Hook[*BackupEvent]{}
|
||||
@@ -405,14 +408,14 @@ func (app *BaseApp) IsBootstrapped() bool {
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
// It calls ClearBootstrap() if the application was already bootstrapped.
|
||||
func (app *BaseApp) Bootstrap() error {
|
||||
event := &BootstrapEvent{}
|
||||
event.App = app
|
||||
|
||||
err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
|
||||
// clear resources of previous core state (if any)
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
// clear previous bootstrap state (if any)
|
||||
if err := app.ClearBootstrap(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -460,41 +463,55 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
type closer interface {
|
||||
Close() error
|
||||
// Deprecated: use [ClearBootstrap].
|
||||
func (app *BaseApp) ResetBootstrapState() error {
|
||||
return app.ClearBootstrap()
|
||||
}
|
||||
|
||||
// ResetBootstrapState releases the initialized core app resources
|
||||
// ClearBootstrap releases the initialized core app resources
|
||||
// (closing db connections, stopping cron ticker, etc.).
|
||||
func (app *BaseApp) ResetBootstrapState() error {
|
||||
app.Cron().Stop()
|
||||
|
||||
var errs []error
|
||||
|
||||
dbs := []*dbx.Builder{
|
||||
&app.concurrentDB,
|
||||
&app.nonconcurrentDB,
|
||||
&app.auxConcurrentDB,
|
||||
&app.auxNonconcurrentDB,
|
||||
//
|
||||
// This method is no-op if the application is not bootstrapped yet.
|
||||
func (app *BaseApp) ClearBootstrap() error {
|
||||
if !app.IsBootstrapped() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, db := range dbs {
|
||||
if db == nil {
|
||||
continue
|
||||
event := &BootstrapEvent{}
|
||||
event.App = app
|
||||
|
||||
return app.OnBootstrapClear().Trigger(event, func(e *BootstrapEvent) error {
|
||||
type closer interface {
|
||||
Close() error
|
||||
}
|
||||
if v, ok := (*db).(closer); ok {
|
||||
if err := v.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
|
||||
var errs []error
|
||||
|
||||
dbs := []*dbx.Builder{
|
||||
&app.concurrentDB,
|
||||
&app.nonconcurrentDB,
|
||||
&app.auxConcurrentDB,
|
||||
&app.auxNonconcurrentDB,
|
||||
}
|
||||
|
||||
for _, db := range dbs {
|
||||
if db == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := (*db).(closer); ok {
|
||||
if err := v.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
*db = nil
|
||||
}
|
||||
*db = nil
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DB returns the default app data.db builder instance.
|
||||
@@ -817,7 +834,7 @@ func (app *BaseApp) Restart() error {
|
||||
event.IsRestart = true
|
||||
|
||||
return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
|
||||
_ = e.App.ResetBootstrapState()
|
||||
_ = e.App.ClearBootstrap()
|
||||
|
||||
// attempt to restart the bootstrap process in case execve returns an error for some reason
|
||||
defer func() {
|
||||
@@ -860,6 +877,10 @@ func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBootstrapClear() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBootstrapClear
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
|
||||
return app.onServe
|
||||
}
|
||||
@@ -1413,7 +1434,15 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
Id: "__pbCronStart__",
|
||||
Func: func(e *ServeEvent) error {
|
||||
app.Cron().Start()
|
||||
return e.Next()
|
||||
},
|
||||
Priority: 999,
|
||||
})
|
||||
|
||||
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
|
||||
Id: "__pbCronStop__",
|
||||
Func: func(e *BootstrapEvent) error {
|
||||
app.Cron().Stop()
|
||||
return e.Next()
|
||||
},
|
||||
Priority: 999,
|
||||
@@ -1470,9 +1499,41 @@ func getLoggerMinLevel(app App) slog.Level {
|
||||
}
|
||||
|
||||
func (app *BaseApp) initLogger() error {
|
||||
var stopped atomic.Bool
|
||||
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool, 1)
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
runLogsWrite := func(logs []*logger.Log) {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
//
|
||||
// note: based on several local tests there is no
|
||||
// significant performance difference between small number
|
||||
// of separate write queries vs 1 big INSERT
|
||||
app.AuxRunInTransaction(func(txApp App) error {
|
||||
model := &Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
model.Id = GenerateDefaultRandomId()
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
|
||||
if err := txApp.AuxSave(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: getLoggerMinLevel(app),
|
||||
@@ -1487,35 +1548,25 @@ func (app *BaseApp) initLogger() error {
|
||||
}
|
||||
}
|
||||
|
||||
ticker.Reset(duration)
|
||||
if !stopped.Load() {
|
||||
ticker.Reset(duration)
|
||||
}
|
||||
|
||||
return app.Settings().Logs.MaxDays > 0
|
||||
},
|
||||
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return nil
|
||||
// don't block and wait for the write transaction to complete
|
||||
// when we can't be sure if the logs write wasn't triggered while
|
||||
// inside another AUX db transaction (ticker or batch threshold reached)
|
||||
// which can block indefinitely and cause deadlock
|
||||
// (https://github.com/pocketbase/pocketbase/issues/7836)
|
||||
shouldBlock, _ := ctx.Value(logger.BlockKey).(bool)
|
||||
if shouldBlock {
|
||||
runLogsWrite(logs)
|
||||
} else {
|
||||
routine.FireAndForget(func() { runLogsWrite(logs) })
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
|
||||
app.AuxRunInTransaction(func(txApp App) error {
|
||||
model := &Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
model.Id = GenerateDefaultRandomId()
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
|
||||
if err := txApp.AuxSave(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
@@ -1535,17 +1586,21 @@ func (app *BaseApp) initLogger() error {
|
||||
|
||||
app.logger = slog.New(handler)
|
||||
|
||||
// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls
|
||||
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
|
||||
Id: "__pbAppLoggerOnTerminate__",
|
||||
Func: func(e *TerminateEvent) error {
|
||||
handler.WriteAll(context.Background())
|
||||
// attempt to write all queued logs before clearing the application bootstrap state
|
||||
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
|
||||
Id: "__pbAppLoggerFlushBeforeStop__",
|
||||
Func: func(e *BootstrapEvent) error {
|
||||
// extra precaution in case the hook was manually triggered while inside aux db transaction
|
||||
_, isTx := e.App.AuxNonconcurrentDB().(*dbx.Tx)
|
||||
ctx := context.WithValue(context.Background(), logger.BlockKey, !isTx)
|
||||
handler.WriteAll(ctx)
|
||||
|
||||
stopped.Store(true)
|
||||
ticker.Stop()
|
||||
|
||||
// don't block in case OnTerminate is triggered more than once
|
||||
// don't block in case the hook is triggered more than once
|
||||
select {
|
||||
case done <- true:
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
|
||||
+144
-43
@@ -65,7 +65,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
@@ -115,7 +115,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
runNilChecks(nilChecksBeforeReset)
|
||||
|
||||
// reset
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
if err := app.ClearBootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestNewBaseAppTx(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -185,7 +185,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
m1, ok := client1.(*mailer.Sendmail)
|
||||
@@ -215,7 +215,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
@@ -244,7 +244,7 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
@@ -266,74 +266,175 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const logsThreshold = 200
|
||||
|
||||
func assertLogsCount(t *testing.T, app core.App, expected int) {
|
||||
var total int
|
||||
|
||||
err := app.LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
if total != expected {
|
||||
t.Fatalf("Expected %d log(s), got %d", expected, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// note: outside of synctest because the bootstrap tickers could deadlock
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// reset
|
||||
if err := app.DeleteOldLogs(time.Now()); err != nil {
|
||||
// clear old logs
|
||||
err := app.DeleteOldLogs(time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const logsThreshold = 200
|
||||
|
||||
totalLogs := func(app core.App, t *testing.T) int {
|
||||
var total int
|
||||
|
||||
err := app.LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
t.Run("disabled logs retention", func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
|
||||
for i := 0; i < logsThreshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
for i := 0; i < logsThreshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
// short delay for the non-blocking write goroutine
|
||||
synctest.Sleep(time.Nanosecond)
|
||||
|
||||
assertLogsCount(t, app, 0)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("test batch logs writes", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
app.Settings().Logs.MaxDays = 2
|
||||
|
||||
for i := 0; i < logsThreshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
// short delay for the non-blocking write goroutine
|
||||
synctest.Sleep(time.Nanosecond)
|
||||
|
||||
// should trigger batch write
|
||||
// below threshold
|
||||
assertLogsCount(t, app, 0)
|
||||
|
||||
// threshold reached -> should trigger batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be added for the next batch write
|
||||
// should be skipped from this batch and added for the next
|
||||
app.Logger().Error("test")
|
||||
|
||||
if total := totalLogs(app, t); total != logsThreshold {
|
||||
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
|
||||
}
|
||||
// short delay for the non-blocking write goroutine
|
||||
synctest.Sleep(time.Nanosecond)
|
||||
|
||||
// wait for 3 secs to check the timer trigger
|
||||
synctest.Sleep(3000 * time.Millisecond)
|
||||
assertLogsCount(t, app, logsThreshold)
|
||||
|
||||
if total := totalLogs(app, t); total != logsThreshold {
|
||||
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
|
||||
}
|
||||
// note: we can't test the flush timer here because the ticker
|
||||
// was started out of the synctest buble to avoid deadlocks
|
||||
// (see TestBaseAppLoggerWritesAwaited for a flaky but real timer test)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerWritesAwaited(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// clear old logs
|
||||
err := app.DeleteOldLogs(time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// enable logs persistence
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
err = app.Save(app.Settings())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("flush on timer tick", func(t *testing.T) {
|
||||
timeout := time.After(5 * time.Second)
|
||||
done := make(chan struct{})
|
||||
|
||||
logsHook := app.OnModelAfterCreateSuccess("_logs")
|
||||
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
|
||||
done <- struct{}{}
|
||||
return e.Next()
|
||||
})
|
||||
defer logsHook.Unbind(hookId)
|
||||
|
||||
app.Logger().Error("test")
|
||||
|
||||
// short wait to ensure that there is no non-blocking write
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
assertLogsCount(t, app, 0)
|
||||
|
||||
// wait for the ticker to write the db record
|
||||
select {
|
||||
case <-timeout:
|
||||
t.Fatal("ticker wait timeout")
|
||||
case <-done:
|
||||
}
|
||||
|
||||
assertLogsCount(t, app, 1)
|
||||
})
|
||||
|
||||
t.Run("before ClearBootstrap flush", func(t *testing.T) {
|
||||
app.Logger().Error("test")
|
||||
|
||||
app.Bootstrap()
|
||||
|
||||
assertLogsCount(t, app, 2)
|
||||
})
|
||||
|
||||
t.Run("batch flush inside aux transaction shouldn't hang", func(t *testing.T) {
|
||||
timeout := time.After(1 * time.Second)
|
||||
done := make(chan struct{})
|
||||
totalCreated := 0
|
||||
|
||||
logsHook := app.OnModelAfterCreateSuccess("_logs")
|
||||
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
|
||||
totalCreated++
|
||||
if totalCreated == 200 {
|
||||
done <- struct{}{}
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
defer logsHook.Unbind(hookId)
|
||||
|
||||
app.AuxRunInTransaction(func(txApp core.App) error {
|
||||
for range logsThreshold {
|
||||
txApp.Logger().Error("test")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// wait for the non-blocking write
|
||||
select {
|
||||
case <-timeout:
|
||||
t.Fatal("non-blocking write timeout")
|
||||
case <-done:
|
||||
}
|
||||
|
||||
assertLogsCount(t, app, 202)
|
||||
|
||||
// force clear to ensure that there are no other logs
|
||||
app.Bootstrap()
|
||||
|
||||
assertLogsCount(t, app, 202)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -373,7 +474,7 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
DataDir: testDataDir,
|
||||
IsDev: s.isDev,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -171,6 +173,18 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
return validation.NewError("validation_invalid_json", "Must be a valid json value")
|
||||
}
|
||||
|
||||
// temp extra encoding/json/v2 check since the above validator is
|
||||
// still using the v1 semantics
|
||||
//
|
||||
// @todo remove after updating the string validator
|
||||
if len(raw) > 0 {
|
||||
var dummy any
|
||||
err := json.Unmarshal(raw, &dummy, jsontext.AllowInvalidUTF8(true))
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_json", "Must be a valid json value")
|
||||
}
|
||||
}
|
||||
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
|
||||
if f.Required && slices.Contains(emptyJSONValues, rawStr) {
|
||||
|
||||
@@ -101,6 +101,16 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid raw value (v2 semantics)",
|
||||
&core.JSONField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.JSONRaw(`{"a": 1, "a": 2}`))
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"zero field value (not required)",
|
||||
&core.JSONField{Name: "test"},
|
||||
|
||||
@@ -3,17 +3,24 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
)
|
||||
|
||||
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
// temp unset to avoid littering the stdout if the test fails when in dev mode
|
||||
colorOutput := color.Output
|
||||
color.Output = io.Discard
|
||||
defer func() { color.Output = colorOutput }()
|
||||
|
||||
testLogLevel := 4
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -48,7 +55,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
DataDir: testDataDir,
|
||||
IsDev: s.isDev,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -68,7 +75,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
var printedLevels []int
|
||||
var persistedLevels []int
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := context.WithValue(context.Background(), logger.BlockKey, true)
|
||||
|
||||
// track printed logs
|
||||
originalPrintLog := printLog
|
||||
|
||||
+27
-13
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
@@ -1333,7 +1334,9 @@ func (m Record) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(
|
||||
m.PublicExport(),
|
||||
json.Deterministic(true),
|
||||
// to preserve the old jsonv1 behavior in case of invalid data
|
||||
// for compliance with old data (and slightly better performance)
|
||||
jsontext.AllowDuplicateNames(true),
|
||||
// preserve the old jsonv1 behavior in case of invalid data
|
||||
jsontext.AllowInvalidUTF8(true),
|
||||
)
|
||||
}
|
||||
@@ -1532,12 +1535,14 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
continue // skip missing or view collections
|
||||
}
|
||||
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
refTableName := inflector.Columnify(refCollection.Name)
|
||||
|
||||
for _, field := range fields {
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
|
||||
prefixedFieldName := refTableName + "." + inflector.Columnify(field.GetName())
|
||||
|
||||
query := app.RecordQuery(refCollection)
|
||||
// fetch only the related ids because they will be queried anyway right
|
||||
// before delete to ensure that we are working with fresh record data
|
||||
query := app.DB().Select(refTableName + ".id").From(refTableName)
|
||||
|
||||
if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() {
|
||||
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
|
||||
@@ -1551,23 +1556,24 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
}
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{refTableName + ".id": mainRecord.Id}))
|
||||
}
|
||||
|
||||
// trigger cascade for each batchSize rel items until there is none
|
||||
batchSize := 4000
|
||||
rows := make([]*Record, 0, batchSize)
|
||||
batchSize := 8000
|
||||
refIds := make([]string, 0, batchSize)
|
||||
for {
|
||||
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||
err := query.Limit(int64(batchSize)).Column(&refIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(rows)
|
||||
total := len(refIds)
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
err := deleteRefRecords(app, mainRecord, rows, field)
|
||||
err = deleteRefRecords(app, mainRecord, refCollection, refIds, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1576,7 +1582,7 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
break // no more items
|
||||
}
|
||||
|
||||
rows = rows[:0] // keep allocated memory
|
||||
refIds = refIds[:0] // keep allocated memory
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1589,13 +1595,21 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
// just unset the record id from any relation field values (if they are not required).
|
||||
//
|
||||
// NB! This method is expected to be called from inside of a transaction.
|
||||
func deleteRefRecords(app App, mainRecord *Record, refRecords []*Record, field Field) error {
|
||||
func deleteRefRecords(app App, mainRecord *Record, refCollection *Collection, refIds []string, field Field) error {
|
||||
relField, _ := field.(*RelationField)
|
||||
if relField == nil {
|
||||
return errors.New("only RelationField is supported at the moment, got " + field.Type())
|
||||
}
|
||||
|
||||
for _, refRecord := range refRecords {
|
||||
for _, refId := range refIds {
|
||||
refRecord, err := app.FindRecordById(refCollection, refId)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue // already deleted
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ids := refRecord.GetStringSlice(relField.Name)
|
||||
|
||||
// unset the record id
|
||||
|
||||
+106
-14
@@ -1413,14 +1413,15 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}}
|
||||
f4 := &core.TextField{Name: "field4", Hidden: true}
|
||||
f5 := &core.TextField{Name: "field5", Hidden: true}
|
||||
f6 := &core.JSONField{Name: "field6"}
|
||||
|
||||
colBase := core.NewBaseCollection("test_base")
|
||||
colBase.Id = "_pbc_base_123"
|
||||
colBase.Fields.Add(f1, f2, f3, f4, f5)
|
||||
colBase.Fields.Add(f1, f2, f3, f4, f5, f6)
|
||||
|
||||
colAuth := core.NewAuthCollection("test_auth")
|
||||
colAuth.Id = "_pbc_auth_123"
|
||||
colAuth.Fields.Add(f1, f2, f3, f4, f5)
|
||||
colAuth.Fields.Add(f1, f2, f3, f4, f5, f6)
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -1439,7 +1440,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
|
||||
},
|
||||
{
|
||||
"[base] with email visibility",
|
||||
@@ -1448,7 +1449,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
|
||||
},
|
||||
{
|
||||
"[base] with custom data",
|
||||
@@ -1457,7 +1458,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[base] with explicit hide and unhide fields",
|
||||
@@ -1466,7 +1467,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "tokenKey", "unknown"},
|
||||
[]string{"field4", "@pbInternalAbc"},
|
||||
`{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","id":"test_id","password":"test_passwordHash","verified":true}`,
|
||||
`{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[base] trying to unhide custom fields without explicit WithCustomData",
|
||||
@@ -1475,7 +1476,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
nil,
|
||||
[]string{"field5", "@pbInternalAbc", "email", "tokenKey", "unknown"},
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
|
||||
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
|
||||
},
|
||||
|
||||
// auth
|
||||
@@ -1486,7 +1487,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[auth] with email visibility",
|
||||
@@ -1495,7 +1496,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[auth] with custom data",
|
||||
@@ -1504,7 +1505,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","verified":true}`,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[auth] with explicit hide and unhide fields",
|
||||
@@ -1513,7 +1514,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "unknown"},
|
||||
[]string{"field4", "@pbInternalAbc"},
|
||||
`{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","id":"test_id","verified":true}`,
|
||||
`{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
|
||||
},
|
||||
{
|
||||
"[auth] trying to unhide custom fields without explicit WithCustomData",
|
||||
@@ -1522,7 +1523,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
true,
|
||||
nil,
|
||||
[]string{"field5", "@pbInternalAbc", "tokenKey", "unknown", "email"}, // emailVisibility:false has higher priority
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","unknown":"test_unknown","verified":true}`,
|
||||
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1533,6 +1534,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
"field3": []string{"test1", "test2"},
|
||||
"field4": "field_4",
|
||||
"field5": "field_5",
|
||||
"field6": types.JSONRaw(`{"a":1,"a":2}`), // intentionally duplicated to check serialization
|
||||
"expand": map[string]any{"test": 123},
|
||||
"collectionId": "m_id", // should be always ignored
|
||||
"collectionName": "m_name", // should be always ignored
|
||||
@@ -1558,6 +1560,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
exportResult, err := json.Marshal(
|
||||
m.PublicExport(),
|
||||
json.Deterministic(true),
|
||||
jsontext.AllowDuplicateNames(true),
|
||||
jsontext.AllowInvalidUTF8(true),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -2324,16 +2327,105 @@ func TestRecordDelete(t *testing.T) {
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
|
||||
expectedRelManyPart := "SELECT `demo1`.`id` FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyPart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
|
||||
}
|
||||
expectedRelOnePart := "SELECT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`='"
|
||||
expectedRelOnePart := "SELECT `demo1`.`id` FROM `demo1` WHERE (`demo1`.`rel_one`='"
|
||||
if !strings.Contains(joinedQueries, expectedRelOnePart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDeleteWithMultipleRelationCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create a mock collection with self referencing multiple relation field
|
||||
// ---
|
||||
collection := core.NewBaseCollection("test")
|
||||
err := app.Save(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// for simpler mocks
|
||||
collection.Fields.GetByName("id").(*core.TextField).Min = 1
|
||||
|
||||
collection.Fields.Add(&core.RelationField{
|
||||
Name: "rels",
|
||||
CollectionId: collection.Id,
|
||||
MaxSelect: 99,
|
||||
CascadeDelete: true,
|
||||
})
|
||||
|
||||
err = app.Save(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create mock records
|
||||
// ---
|
||||
relsData := map[string][]string{
|
||||
"a": nil,
|
||||
"b": {"a"},
|
||||
"c": {"a", "b"},
|
||||
"d": {},
|
||||
"e": {"c", "d"},
|
||||
}
|
||||
for id, rels := range relsData {
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("id", id)
|
||||
record.Set("rels", rels)
|
||||
err = app.SaveNoValidate(record) // map is not ordered
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create mock record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// trigger cascade delete for the top record
|
||||
// ---
|
||||
aRecord, err := app.FindRecordById(collection, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = app.Delete(aRecord)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// verify cascade delete
|
||||
// ---
|
||||
expectedRels := map[string][]string{
|
||||
"d": {},
|
||||
"e": {"d"},
|
||||
}
|
||||
|
||||
allRecords, err := app.FindAllRecords(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(allRecords) != len(expectedRels) {
|
||||
t.Fatalf("Expected %d remaining records, got %d", len(expectedRels), len(allRecords))
|
||||
}
|
||||
|
||||
for _, r := range allRecords {
|
||||
expected, ok := expectedRels[r.Id]
|
||||
if !ok {
|
||||
t.Fatalf("Record %q wasn't found in %v", r.Id, expectedRels)
|
||||
}
|
||||
|
||||
rels := r.GetStringSlice("rels")
|
||||
if !slices.Equal(rels, expected) {
|
||||
t.Fatalf("Record %q expected rels\n%v\ngot\n%v", r.Id, expected, rels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDeleteBatchProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestSendSystemAlert(t *testing.T) {
|
||||
testApp := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer testApp.ResetBootstrapState()
|
||||
defer testApp.ClearBootstrap()
|
||||
|
||||
if err := testApp.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -72,7 +72,7 @@ func TestSendSystemAlertToAllSuperusers(t *testing.T) {
|
||||
testApp := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer testApp.ResetBootstrapState()
|
||||
defer testApp.ClearBootstrap()
|
||||
|
||||
if err := testApp.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -17,11 +17,11 @@ require (
|
||||
github.com/pocketbase/tygoja v0.1.0
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sync v0.23.0
|
||||
modernc.org/sqlite v1.57.0
|
||||
)
|
||||
|
||||
@@ -40,9 +40,9 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/testify v1.8.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -88,27 +88,27 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
|
||||
@@ -1613,7 +1613,7 @@ func TestHooksBindsCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
hooksBinds(app, vm, nil)
|
||||
|
||||
testBindsCount(vm, "this", 82, t)
|
||||
testBindsCount(vm, "this", 83, t)
|
||||
}
|
||||
|
||||
func TestHooksBinds(t *testing.T) {
|
||||
|
||||
+3992
-3965
File diff suppressed because it is too large
Load Diff
@@ -129,7 +129,7 @@ type excludeHooks<Type> = {
|
||||
type CoreApp = excludeHooks<ORIGINAL_CORE_APP>
|
||||
|
||||
// pocketbase.PocketBase without the on* hook methods
|
||||
type PocketBase = excludeHooks<ORIGINAL_POCKETBASE>
|
||||
interface PocketBase extends excludeHooks<ORIGINAL_POCKETBASE>{}
|
||||
|
||||
/**
|
||||
* ` + "`$app`" + ` is the current running PocketBase instance that is globally
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -667,22 +666,11 @@ func init() {
|
||||
}
|
||||
|
||||
func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) {
|
||||
raw, err := json.Marshal(v,
|
||||
json.Deterministic(true),
|
||||
return json.Marshal(v,
|
||||
jsontext.WithIndentPrefix(prefix),
|
||||
jsontext.WithIndent(indent),
|
||||
json.Deterministic(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// unescape escaped unicode characters
|
||||
unescaped, err := strconv.Unquote(strings.ReplaceAll(strconv.Quote(string(raw)), `\\u`, `\u`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []byte(unescaped), nil
|
||||
}
|
||||
|
||||
func escapeBacktick(v string) string {
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ func (pb *PocketBase) Execute() error {
|
||||
event := new(core.TerminateEvent)
|
||||
event.App = pb
|
||||
return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
|
||||
return errors.Join(e.App.ResetBootstrapState(), execErr)
|
||||
return errors.Join(e.App.ClearBootstrap(), execErr)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -37,9 +37,9 @@ func (t *TestApp) Cleanup() {
|
||||
event.App = t
|
||||
|
||||
t.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
|
||||
t.ClearBootstrap()
|
||||
t.TestMailer.Reset()
|
||||
t.ResetEventCalls()
|
||||
t.ResetBootstrapState()
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
@@ -144,6 +144,14 @@ func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
|
||||
Priority: -99999,
|
||||
})
|
||||
|
||||
t.OnBootstrapClear().Bind(&hook.Handler[*core.BootstrapEvent]{
|
||||
Func: func(e *core.BootstrapEvent) error {
|
||||
t.registerEventCall("OnBootstrapClear")
|
||||
return e.Next()
|
||||
},
|
||||
Priority: -99999,
|
||||
})
|
||||
|
||||
t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Func: func(e *core.ServeEvent) error {
|
||||
t.registerEventCall("OnServe")
|
||||
|
||||
+2
-2
@@ -131,9 +131,9 @@ func (p *OIDC) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
|
||||
}
|
||||
|
||||
func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
|
||||
idToken := token.Extra("id_token").(string)
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
if idToken == "" {
|
||||
return nil, errors.New("empty id_token")
|
||||
return nil, errors.New("empty or invalid id_token")
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`)
|
||||
indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`)
|
||||
indexRegex = regexp.MustCompile(`(?i)\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s+where\s+([\s\S]*?))?\s*$`)
|
||||
indexColumnRegex = regexp.MustCompile(`(?i)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$`)
|
||||
)
|
||||
|
||||
// IndexColumn represents a single parsed SQL index column.
|
||||
@@ -187,6 +187,11 @@ func ParseIndex(createIndexExpr string) Index {
|
||||
})
|
||||
}
|
||||
|
||||
if len(rawColumns) != len(result.Columns) {
|
||||
// unset to trigger validation error
|
||||
result.Columns = []IndexColumn{}
|
||||
}
|
||||
|
||||
// WHERE expression
|
||||
// ---
|
||||
result.Where = strings.TrimSpace(matches[6])
|
||||
|
||||
+14
-14
@@ -3,7 +3,6 @@ package dbutils_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,27 +11,28 @@ import (
|
||||
|
||||
func TestParseIndex(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
index string
|
||||
expected dbutils.Index
|
||||
}{
|
||||
// invalid
|
||||
{
|
||||
"invalid",
|
||||
`invalid`,
|
||||
dbutils.Index{},
|
||||
},
|
||||
// no names
|
||||
{
|
||||
"no names",
|
||||
`create index on ()`,
|
||||
dbutils.Index{},
|
||||
},
|
||||
// invalid index name
|
||||
{
|
||||
"invalid index name",
|
||||
`create index a.b.c on ()`,
|
||||
dbutils.Index{},
|
||||
},
|
||||
// simple (multiple spaces between the table and columns list)
|
||||
{
|
||||
`create index indexname on tablename (col1)`,
|
||||
"simple (multiple spaces as prefix, suffix, between the table and columns list)",
|
||||
` create index indexname on tablename (col1) `,
|
||||
dbutils.Index{
|
||||
IndexName: "indexname",
|
||||
TableName: "tablename",
|
||||
@@ -41,8 +41,8 @@ func TestParseIndex(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// simple (no space between the table and the columns list)
|
||||
{
|
||||
"simple (no space between the table and the columns list)",
|
||||
`create index indexname on tablename(col1)`,
|
||||
dbutils.Index{
|
||||
IndexName: "indexname",
|
||||
@@ -52,15 +52,15 @@ func TestParseIndex(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// all fields
|
||||
{
|
||||
"all fields",
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "schemaname".[indexname] on 'tablename' (
|
||||
col0,
|
||||
` + "`" + `col1` + "`" + `,
|
||||
json_extract("col2", "$.a") asc,
|
||||
json_extract("col2_multiline",` + "\n" + ` "$.a") asc,
|
||||
"col3" collate NOCASE,
|
||||
"col4" collate RTRIM desc
|
||||
) where test = 1`,
|
||||
) where cast(test1 as ` + "\n" + `int) = 1 and test2 != ''`,
|
||||
dbutils.Index{
|
||||
Unique: true,
|
||||
Optional: true,
|
||||
@@ -70,17 +70,17 @@ func TestParseIndex(t *testing.T) {
|
||||
Columns: []dbutils.IndexColumn{
|
||||
{Name: "col0"},
|
||||
{Name: "col1"},
|
||||
{Name: `json_extract("col2", "$.a")`, Sort: "ASC"},
|
||||
{Name: `json_extract("col2_multiline",` + "\n" + ` "$.a")`, Sort: "ASC"},
|
||||
{Name: `col3`, Collate: "NOCASE"},
|
||||
{Name: `col4`, Collate: "RTRIM", Sort: "DESC"},
|
||||
},
|
||||
Where: "test = 1",
|
||||
Where: "cast(test1 as \nint) = 1 and test2 != ''",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("scenario_%d", i), func(t *testing.T) {
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result := dbutils.ParseIndex(s.index)
|
||||
|
||||
resultRaw, err := json.Marshal(result)
|
||||
|
||||
@@ -11,6 +11,13 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// contextKey is an alias type to prevent collisions with other log context keys.
|
||||
type contextKey int
|
||||
|
||||
// BlockKey is a context key usually used to indicate that the
|
||||
// batched logs write should block until writes are completed.
|
||||
var BlockKey contextKey
|
||||
|
||||
var _ slog.Handler = (*BatchHandler)(nil)
|
||||
|
||||
// BatchOptions are options for the BatchHandler.
|
||||
|
||||
+28
-6
@@ -3,12 +3,16 @@ package picker
|
||||
import (
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/tokenizer"
|
||||
)
|
||||
|
||||
var ErrInvalidModifierData = errors.New("failed to apply some of the field modifiers for the provided data")
|
||||
|
||||
// Pick converts data into a []any, map[string]any, etc. (using json marshal->unmarshal)
|
||||
// containing only the fields from the parsed rawFields expression.
|
||||
//
|
||||
@@ -16,6 +20,10 @@ import (
|
||||
// Nested fields should be listed with dot-notation.
|
||||
// Fields value modifiers are also supported using the `:modifier(args)` format (see Modifiers).
|
||||
//
|
||||
// In case some data fails to apply against the registered modifiers
|
||||
// a wrapped [ErrInvalidModifierData] is returned that you can inspect and
|
||||
// decide to ignore or propagate further up the execution chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}}
|
||||
@@ -33,15 +41,20 @@ func Pick(data any, rawFields string) (any, error) {
|
||||
// Note that invalid UTF8 characters are mangled for compatibility
|
||||
// with earlier versions and to prevent unnecessary causing an error.
|
||||
//
|
||||
// Duplicated keys are also enabled for just in case of old jsonv1 data
|
||||
// (new db entries are usually validated against the jsonv2 semantics).
|
||||
//
|
||||
// @todo research other approaches to avoid the double serialization
|
||||
// ---
|
||||
encoded, err := json.Marshal(data, jsontext.AllowInvalidUTF8(true))
|
||||
encoded, err := json.Marshal(data, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
|
||||
err = json.Unmarshal(encoded, &decoded, jsontext.AllowDuplicateNames(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ---
|
||||
@@ -55,10 +68,16 @@ func Pick(data any, rawFields string) (any, error) {
|
||||
|
||||
if isSearchResult {
|
||||
if decodedMap, ok := decoded.(map[string]any); ok {
|
||||
pickParsedFields(decodedMap["items"], parsedFields)
|
||||
err = pickParsedFields(decodedMap["items"], parsedFields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pickParsedFields(decoded, parsedFields)
|
||||
err = pickParsedFields(decoded, parsedFields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err)
|
||||
}
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
@@ -94,7 +113,10 @@ func parseFields(rawFields string) (map[string]Modifier, error) {
|
||||
func pickParsedFields(data any, fields map[string]Modifier) error {
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
pickMapFields(v, fields)
|
||||
err := pickMapFields(v, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range v {
|
||||
if err := pickMapFields(item, fields); err != nil {
|
||||
@@ -112,7 +134,7 @@ func pickParsedFields(data any, fields map[string]Modifier) error {
|
||||
|
||||
for _, item := range v {
|
||||
if err := pickMapFields(item.(map[string]any), fields); err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
package picker_test
|
||||
|
||||
import (
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/picker"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
type brokenModifier struct {
|
||||
}
|
||||
|
||||
func (m *brokenModifier) Modify(val any) (any, error) {
|
||||
return nil, errors.New("test_error")
|
||||
}
|
||||
|
||||
func init() {
|
||||
picker.Modifiers["broken"] = func(args ...string) (picker.Modifier, error) {
|
||||
return &brokenModifier{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickFields(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -223,6 +238,13 @@ func TestPickFields(t *testing.T) {
|
||||
false,
|
||||
`{"id":"123","rel":{"id":"456","sub":{"id":"789"},"title":"rel_title"}}`,
|
||||
},
|
||||
{
|
||||
"with modifer.Modify error",
|
||||
map[string]any{"a": 1},
|
||||
"*:broken",
|
||||
true,
|
||||
`{"a":1}`,
|
||||
},
|
||||
{
|
||||
"invalid excerpt modifier",
|
||||
map[string]any{"a": 1, "b": 2, "c": "test"},
|
||||
@@ -248,6 +270,13 @@ func TestPickFields(t *testing.T) {
|
||||
false,
|
||||
`{"id":"12","rel":{"title":"rel..."},"title":"lo"}`,
|
||||
},
|
||||
{
|
||||
"jsonv1 values",
|
||||
map[string]any{"a": "test\xc3", "b": jsontext.Value(`{"a":1,"a":2}`)},
|
||||
"a, b",
|
||||
false,
|
||||
`{"a":"test�","b":{"a":2}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -263,7 +292,12 @@ func TestPickFields(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(result, json.Deterministic(true))
|
||||
serialized, err := json.Marshal(
|
||||
result,
|
||||
json.Deterministic(true),
|
||||
jsontext.AllowInvalidUTF8(true),
|
||||
jsontext.AllowDuplicateNames(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+22
-14
@@ -189,23 +189,31 @@ const jsonFieldsParam = "fields"
|
||||
// Note that invalid UTF8 characters are mangled for compatibility
|
||||
// with earlier versions and to prevent unnecessary causing a response error.
|
||||
func (e *Event) JSON(status int, data any) error {
|
||||
// try to pick only the requested fields (currently allowed only for "success" responses)
|
||||
rawFields := e.Request.URL.Query().Get(jsonFieldsParam)
|
||||
if rawFields != "" && status >= 200 && status <= 299 {
|
||||
modified, err := picker.Pick(data, rawFields)
|
||||
if err == nil {
|
||||
data = modified
|
||||
} else if !errors.Is(err, picker.ErrInvalidModifierData) {
|
||||
// @todo ignore for now modifier data errors to avoid introducing
|
||||
// breaking changes but once the router is merged with core consider
|
||||
// at least logging for dev purposes
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
e.setResponseHeaderIfEmpty(headerContentType, "application/json")
|
||||
e.Response.WriteHeader(status)
|
||||
|
||||
rawFields := e.Request.URL.Query().Get(jsonFieldsParam)
|
||||
|
||||
// error response or no fields to pick
|
||||
if rawFields == "" || status < 200 || status > 299 {
|
||||
return json.MarshalWrite(e.Response, data, jsontext.AllowInvalidUTF8(true))
|
||||
}
|
||||
|
||||
// pick only the requested fields
|
||||
modified, err := picker.Pick(data, rawFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.MarshalWrite(e.Response, modified, jsontext.AllowInvalidUTF8(true))
|
||||
return json.MarshalWrite(
|
||||
e.Response,
|
||||
data,
|
||||
// for compliance with old data (and slightly better performance)
|
||||
jsontext.AllowDuplicateNames(true),
|
||||
// preserve the old jsonv1 behavior in case of invalid data
|
||||
jsontext.AllowInvalidUTF8(true),
|
||||
)
|
||||
}
|
||||
|
||||
// XML writes an XML response.
|
||||
|
||||
+136
-4
@@ -3,6 +3,7 @@ package router_test
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/picker"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type unwrapTester struct {
|
||||
@@ -220,6 +223,8 @@ func TestEventRemoteIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindUploadedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
filename string
|
||||
expectedPattern string
|
||||
@@ -273,6 +278,8 @@ func TestFindUploadedFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindUploadedFilesMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
mp := multipart.NewWriter(body)
|
||||
mp.Close()
|
||||
@@ -293,6 +300,8 @@ func TestFindUploadedFilesMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventSetGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := router.Event{}
|
||||
|
||||
// get before any set (ensures that doesn't panic)
|
||||
@@ -324,6 +333,8 @@ func TestEventSetGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventSetAllGetAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := map[string]any{
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
@@ -351,6 +362,8 @@ func TestEventSetAllGetAll(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "no explicit content-type",
|
||||
@@ -380,6 +393,8 @@ func TestEventString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "no explicit content-type",
|
||||
@@ -409,14 +424,17 @@ func TestEventHTML(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := map[string]any{
|
||||
"a": 123,
|
||||
"b": true,
|
||||
"c": "test",
|
||||
"d": "\xc3", /* invalid utf8 char to test mangling */
|
||||
"d": "\xc3", // invalid utf8 char to test mangling
|
||||
"e": types.JSONRaw(`{"a":1,"a":2}`), // duplicated keys to ensure compliance with old data
|
||||
}
|
||||
expectedPickedBody := `{"a":123,"c":"test"}`
|
||||
expectedFullBody := `{"a":123,"b":true,"c":"test","d":"�"}`
|
||||
expectedFullBody := `{"a":123,"b":true,"c":"test","d":"�","e":{"a":2}}`
|
||||
|
||||
scenarios := []testResponseWriteScenario[any]{
|
||||
{
|
||||
@@ -456,7 +474,93 @@ func TestEventJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventJSONPickError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/?fields=a:excerpt(-1)", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
event := &router.Event{
|
||||
Request: req,
|
||||
Response: &router.ResponseWriter{ResponseWriter: rec},
|
||||
}
|
||||
|
||||
err = event.JSON(200, map[string]any{"a": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("Expected JSON to return modifier args err, got nil")
|
||||
}
|
||||
|
||||
// ensure that no explicit status code was written yet by attempting to write one
|
||||
// (should do nothing if it was already written)
|
||||
rec.WriteHeader(567)
|
||||
|
||||
result := rec.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
if result.StatusCode != 567 {
|
||||
t.Fatalf("Expected custom status code, got %d", result.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
type brokenModifier struct {
|
||||
}
|
||||
|
||||
func (m *brokenModifier) Modify(val any) (any, error) {
|
||||
return nil, errors.New("test_error")
|
||||
}
|
||||
|
||||
func TestEventJSONIgnoredError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
picker.Modifiers["broken_ignore"] = func(args ...string) (picker.Modifier, error) {
|
||||
return &brokenModifier{}, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/?fields=a:broken_ignore", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
event := &router.Event{
|
||||
Request: req,
|
||||
Response: &router.ResponseWriter{ResponseWriter: rec},
|
||||
}
|
||||
|
||||
err = event.JSON(200, map[string]any{"a": "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil JSON result, got error %v", err)
|
||||
}
|
||||
|
||||
// (should do nothing if it was already written)
|
||||
rec.WriteHeader(567)
|
||||
|
||||
result := rec.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected %d status code, got %d", http.StatusOK, result.StatusCode)
|
||||
}
|
||||
|
||||
expected := `{"a":"test"}`
|
||||
if string(rawBody) != expected {
|
||||
t.Fatalf("Expected json\n%s\ngot\n%s", expected, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventXML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "no explicit content-type",
|
||||
@@ -486,6 +590,8 @@ func TestEventXML(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[string]{
|
||||
{
|
||||
name: "stream",
|
||||
@@ -506,6 +612,8 @@ func TestEventStream(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[[]byte]{
|
||||
{
|
||||
name: "blob",
|
||||
@@ -526,6 +634,8 @@ func TestEventBlob(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventNoContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testResponseWriteScenario[any]{
|
||||
name: "no content",
|
||||
status: 234,
|
||||
@@ -542,6 +652,8 @@ func TestEventNoContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventFlush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
event := &router.Event{
|
||||
@@ -556,6 +668,8 @@ func TestEventFlush(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []testResponseWriteScenario[any]{
|
||||
{
|
||||
name: "non-30x status",
|
||||
@@ -580,6 +694,8 @@ func TestEventRedirect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventFileFS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// stub test files
|
||||
// ---
|
||||
dir, err := os.MkdirTemp("", "EventFileFS")
|
||||
@@ -676,6 +792,8 @@ func TestEventFileFS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -687,6 +805,8 @@ func TestEventError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventBadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -698,6 +818,8 @@ func TestEventBadRequestError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventNotFoundError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -709,6 +831,8 @@ func TestEventNotFoundError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventForbiddenError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -720,6 +844,8 @@ func TestEventForbiddenError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventUnauthorizedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -731,6 +857,8 @@ func TestEventUnauthorizedError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventTooManyRequestsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -742,6 +870,8 @@ func TestEventTooManyRequestsError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventInternalServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"})
|
||||
|
||||
result, _ := json.Marshal(err, json.Deterministic(true))
|
||||
@@ -753,6 +883,8 @@ func TestEventInternalServerError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventBindBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testDstStruct struct {
|
||||
A int `json:"a" xml:"a" form:"a"`
|
||||
B int `json:"b" xml:"b" form:"b"`
|
||||
@@ -945,9 +1077,9 @@ func testEventResponseWrite[T any](
|
||||
// try to deserialize into a map and then serialize again in a
|
||||
// deterministic manner in case it is json
|
||||
var jsonBody any
|
||||
err = json.Unmarshal(rawBody, &jsonBody)
|
||||
err = json.Unmarshal(rawBody, &jsonBody, jsontext.AllowDuplicateNames(true))
|
||||
if err == nil {
|
||||
normalized, err := json.Marshal(jsonBody, json.Deterministic(true))
|
||||
normalized, err := json.Marshal(jsonBody, json.Deterministic(true), jsontext.AllowDuplicateNames(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to deterministicly serialize json body\n%s\ngot\n%v", jsonBody, err)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
"geoDistance function",
|
||||
"geoDistance(1,2,3,4) < 567",
|
||||
false,
|
||||
"(6371 * acos(cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))) < {:TEST}",
|
||||
"(6371 * acos(min(1, max(-1, cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))))) < {:TEST}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,14 @@ var TokenFunctions = map[string]func(
|
||||
|
||||
return &ResolverResult{
|
||||
NullFallback: NullFallbackDisabled,
|
||||
Identifier: `(6371 * acos(` +
|
||||
// the clamping is to prevent floating point rounding errors for values like
|
||||
// "1.0002" that could occur for example when comparing identical points
|
||||
// (see the NULL note for arccosine in https://sqlite.org/lang_mathfunc.html#overview)
|
||||
Identifier: `(6371 * acos(min(1, max(-1, ` +
|
||||
`cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` +
|
||||
`cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` +
|
||||
`sin(radians(` + latA + `)) * sin(radians(` + latB + `))` +
|
||||
`))`,
|
||||
`))))`,
|
||||
Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params),
|
||||
}, nil
|
||||
},
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestTokenFunctionsGeoDistance(t *testing.T) {
|
||||
baseTokenResolver,
|
||||
&ResolverResult{
|
||||
NullFallback: NullFallbackDisabled,
|
||||
Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`,
|
||||
Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`,
|
||||
Params: map[string]any{
|
||||
"lonA": 1,
|
||||
"latA": 2,
|
||||
@@ -138,7 +138,7 @@ func TestTokenFunctionsGeoDistance(t *testing.T) {
|
||||
baseTokenResolver,
|
||||
&ResolverResult{
|
||||
NullFallback: NullFallbackDisabled,
|
||||
Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`,
|
||||
Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`,
|
||||
Params: map[string]any{
|
||||
"lonA": "null",
|
||||
"latA": 2,
|
||||
@@ -178,34 +178,64 @@ func TestTokenFunctionsGeoDistanceExec(t *testing.T) {
|
||||
t.Error("Expected geoDistance token function to be registered.")
|
||||
}
|
||||
|
||||
result, err := fn(
|
||||
func(t fexpr.Token) (*ResolverResult, error) {
|
||||
placeholder := "t" + security.PseudorandomString(5)
|
||||
return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil
|
||||
scenarios := []struct {
|
||||
name string
|
||||
coords []string // [lonA, latA, lonB, latB]
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"distinct points",
|
||||
[]string{
|
||||
"23.23033854945808",
|
||||
"42.713146090563384",
|
||||
"23.44920680886216",
|
||||
"42.7078484153991",
|
||||
},
|
||||
"17.89",
|
||||
},
|
||||
{
|
||||
"identical points (rounding check with 8 lat)",
|
||||
[]string{"0", "8", "0", "8"}, // 8 is an example latitude where rounding happens to result in > 1.0
|
||||
"0.00",
|
||||
},
|
||||
{
|
||||
"identical points (rounding check with 45 lat)",
|
||||
[]string{"0", "45", "0", "45"}, // 45 is an example latitude where rounding happens to result in > 1.0
|
||||
"0.00",
|
||||
},
|
||||
fexpr.Token{Literal: "23.23033854945808", Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: "42.713146090563384", Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: "23.44920680886216", Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: "42.7078484153991", Type: fexpr.TokenNumber},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
column := []float64{}
|
||||
err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, err := fn(
|
||||
func(t fexpr.Token) (*ResolverResult, error) {
|
||||
placeholder := "t" + security.PseudorandomString(5)
|
||||
return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil
|
||||
},
|
||||
fexpr.Token{Literal: s.coords[0], Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: s.coords[1], Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: s.coords[2], Type: fexpr.TokenNumber},
|
||||
fexpr.Token{Literal: s.coords[3], Type: fexpr.TokenNumber},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(column) != 1 {
|
||||
t.Fatalf("Expected exactly 1 column value as result, got %v", column)
|
||||
}
|
||||
column := []float64{}
|
||||
err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "17.89"
|
||||
distance := fmt.Sprintf("%.2f", column[0])
|
||||
if distance != expected {
|
||||
t.Fatalf("Expected distance value %s, got %s", expected, distance)
|
||||
if len(column) != 1 {
|
||||
t.Fatalf("Expected exactly 1 column value as result, got %v", column)
|
||||
}
|
||||
|
||||
distance := fmt.Sprintf("%.2f", column[0])
|
||||
if distance != s.expected {
|
||||
t.Fatalf("Expected distance value %s, got %s", s.expected, distance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// @todo consider deprecating in favour of jsontext.Value
|
||||
|
||||
// JSONRaw defines a json value type that is safe for db read/write.
|
||||
type JSONRaw []byte
|
||||
|
||||
|
||||
@@ -12,4 +12,4 @@ 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.40.2"
|
||||
PB_VERSION = "v0.40.4"
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.newEmail||!a.collectionId){app.toasts.error(`Invalid or expired email change token.`),window.location.hash=`#/`;return}app.store.title=`Confirm email change`;let o=store({password:``,isSubmitting:!1,isSuccess:!1,showPassword:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmEmailChange(i,o.password),o.isSuccess=!0}catch(e){app.checkApiError(e),o.isSuccess=!1}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmEmailChange`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmEmailChangeAlert`,className:`alert success txt-center`},t.p(null,`The email was successfully changed.`),t.p(null,`You can go back and sign in with your new email address.`)):t.form({pbEvent:`confirmEmailChangeForm`,className:`grid confirm-email-change-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your password to confirm changing your email address to `,t.strong(null,a.newEmail),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`password_confirm`},`Password`),t.input({id:`password_confirm`,name:`password`,required:!0,autofocus:!0,type:()=>o.showPassword?`text`:`password`,value:()=>o.password,oninput:e=>o.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showPassword?`Hide password`:`Show password`),onclick:()=>o.showPassword=!o.showPassword},t.i({className:()=>o.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Confirm new email`)))))}export{r as pageConfirmEmailChange};
|
||||
import{n as e,t as n}from"./index-BeROT1qQ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.newEmail||!a.collectionId){app.toasts.error(`Invalid or expired email change token.`),window.location.hash=`#/`;return}app.store.title=`Confirm email change`;let o=store({password:``,isSubmitting:!1,isSuccess:!1,showPassword:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmEmailChange(i,o.password),o.isSuccess=!0}catch(e){app.checkApiError(e),o.isSuccess=!1}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmEmailChange`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmEmailChangeAlert`,className:`alert success txt-center`},t.p(null,`The email was successfully changed.`),t.p(null,`You can go back and sign in with your new email address.`)):t.form({pbEvent:`confirmEmailChangeForm`,className:`grid confirm-email-change-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your password to confirm changing your email address to `,t.strong(null,a.newEmail),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`password_confirm`},`Password`),t.input({id:`password_confirm`,name:`password`,required:!0,autofocus:!0,type:()=>o.showPassword?`text`:`password`,value:()=>o.password,oninput:e=>o.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showPassword?`Hide password`:`Show password`),onclick:()=>o.showPassword=!o.showPassword},t.i({className:()=>o.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Confirm new email`)))))}export{r as pageConfirmEmailChange};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired password reset token.`),window.location.hash=`#/`;return}app.store.title=`Confirm password reset`;let o=store({newPassword:``,newPasswordConfirm:``,showNewPassword:!1,showNewPasswordConfirm:!1,isSubmitting:!1,isSuccess:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmPasswordReset(i,o.newPassword,o.newPasswordConfirm),o.isSuccess=!0}catch(e){app.checkApiError(e)}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmPasswordReset`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmPasswordResetAlert`,className:`alert success txt-center`},t.p(null,`The password was successfully changed.`),t.p(null,`You can go back to sign in with your new password.`)):t.form({pbEvent:`confirmPasswordResetForm`,className:`grid confirm-password-reset-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your new password for `,t.strong(null,a.email),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPassword`},`New password`),t.input({id:`newPassword`,name:`password`,required:!0,autofocus:!0,autocomplete:`new-password`,type:()=>o.showNewPassword?`text`:`password`,value:()=>o.newPassword,oninput:e=>o.newPassword=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPassword?`Hide password`:`Show password`),onclick:()=>o.showNewPassword=!o.showNewPassword},t.i({className:()=>o.showNewPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPasswordConfirm`},`New password confirm`),t.input({id:`newPasswordConfirm`,name:`passwordConfirm`,required:!0,autocomplete:`new-password`,type:()=>o.showNewPasswordConfirm?`text`:`password`,value:()=>o.newPasswordConfirm,oninput:e=>o.newPasswordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPasswordConfirm?`Hide password`:`Show password`),onclick:()=>o.showNewPasswordConfirm=!o.showNewPasswordConfirm},t.i({className:()=>o.showNewPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Set new password`)))))}export{r as pageConfirmPasswordReset};
|
||||
import{n as e,t as n}from"./index-BeROT1qQ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired password reset token.`),window.location.hash=`#/`;return}app.store.title=`Confirm password reset`;let o=store({newPassword:``,newPasswordConfirm:``,showNewPassword:!1,showNewPasswordConfirm:!1,isSubmitting:!1,isSuccess:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmPasswordReset(i,o.newPassword,o.newPasswordConfirm),o.isSuccess=!0}catch(e){app.checkApiError(e)}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmPasswordReset`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmPasswordResetAlert`,className:`alert success txt-center`},t.p(null,`The password was successfully changed.`),t.p(null,`You can go back to sign in with your new password.`)):t.form({pbEvent:`confirmPasswordResetForm`,className:`grid confirm-password-reset-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your new password for `,t.strong(null,a.email),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPassword`},`New password`),t.input({id:`newPassword`,name:`password`,required:!0,autofocus:!0,autocomplete:`new-password`,type:()=>o.showNewPassword?`text`:`password`,value:()=>o.newPassword,oninput:e=>o.newPassword=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPassword?`Hide password`:`Show password`),onclick:()=>o.showNewPassword=!o.showNewPassword},t.i({className:()=>o.showNewPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPasswordConfirm`},`New password confirm`),t.input({id:`newPasswordConfirm`,name:`passwordConfirm`,required:!0,autocomplete:`new-password`,type:()=>o.showNewPasswordConfirm?`text`:`password`,value:()=>o.newPasswordConfirm,oninput:e=>o.newPasswordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPasswordConfirm?`Hide password`:`Show password`),onclick:()=>o.showNewPasswordConfirm=!o.showNewPasswordConfirm},t.i({className:()=>o.showNewPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Set new password`)))))}export{r as pageConfirmPasswordReset};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired verification token.`),window.location.hash=`#/`;return}app.store.title=`Confirm verification`;let o=store({isConfirming:!1,isConfirmSuccess:!1,isResending:!1,isResendSuccess:!1});s();async function s(){if(o.isConfirming)return;o.isConfirming=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmVerification(i),o.isConfirmSuccess=!0}catch{o.isConfirmSuccess=!1}o.isConfirming=!1}async function c(){if(o.isResending)return;o.isResending=!0;let e=new n(`../`);try{await e.collection(a.collectionId).requestVerification(a.email),o.isResendSuccess=!0}catch(e){app.checkApiError(e),o.isResendSuccess=!1}o.isResending=!1}return t.div({pbEvent:`pageConfirmVerification`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isConfirming?t.div({className:`block txt-center`},t.span({className:`loader`},`Please wait...`)):o.isConfirmSuccess?t.div({pbEvent:`confirmVerificationSuccessAlert`,className:`alert success txt-center`},t.p(null,`Successfully verified `,t.strong(null,a.email),`.`)):o.isResendSuccess?t.div({pbEvent:`confirmVerificationResendAlert`,className:`alert success txt-center`},t.p(null,`Please check your email for the new verification link.`)):[t.div({pbEvent:`confirmVerificationErrorAlert`,className:`alert danger txt-center m-b-base`},t.p(null,`Invalid or expired verification token.`)),t.button({type:`button`,className:()=>`btn transparent lg block ${o.isResending?`loading`:``}`,disabled:()=>o.isResending,onclick:()=>c()},t.span({className:`txt`},`Resend`))])}export{r as pageConfirmVerification};
|
||||
import{n as e,t as n}from"./index-BeROT1qQ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired verification token.`),window.location.hash=`#/`;return}app.store.title=`Confirm verification`;let o=store({isConfirming:!1,isConfirmSuccess:!1,isResending:!1,isResendSuccess:!1});s();async function s(){if(o.isConfirming)return;o.isConfirming=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmVerification(i),o.isConfirmSuccess=!0}catch{o.isConfirmSuccess=!1}o.isConfirming=!1}async function c(){if(o.isResending)return;o.isResending=!0;let e=new n(`../`);try{await e.collection(a.collectionId).requestVerification(a.email),o.isResendSuccess=!0}catch(e){app.checkApiError(e),o.isResendSuccess=!1}o.isResending=!1}return t.div({pbEvent:`pageConfirmVerification`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isConfirming?t.div({className:`block txt-center`},t.span({className:`loader`},`Please wait...`)):o.isConfirmSuccess?t.div({pbEvent:`confirmVerificationSuccessAlert`,className:`alert success txt-center`},t.p(null,`Successfully verified `,t.strong(null,a.email),`.`)):o.isResendSuccess?t.div({pbEvent:`confirmVerificationResendAlert`,className:`alert success txt-center`},t.p(null,`Please check your email for the new verification link.`)):[t.div({pbEvent:`confirmVerificationErrorAlert`,className:`alert danger txt-center m-b-base`},t.p(null,`Invalid or expired verification token.`)),t.button({type:`button`,className:()=>`btn transparent lg block ${o.isResending?`loading`:``}`,disabled:()=>o.isResending,onclick:()=>c()},t.span({className:`txt`},`Resend`))])}export{r as pageConfirmVerification};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,r as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``;if(e(i).type!=`auth`||n(i)){app.toasts.error(`The installer token is invalid or has expired.`),window.location.hash=`#/`;return}app.store.title=`Setup your PocketBase instance`;let a=store({email:``,password:``,passwordConfirm:``,showPassword:!1,showPasswordConfirm:!1,isSubmitting:!1,isUploading:!1,get isBusy(){return a.isSubmitting||a.isUploading}});async function o(){if(!a.isBusy){a.isSubmitting=!0;try{await app.pb.collection(`_superusers`).create({email:a.email,password:a.password,passwordConfirm:a.passwordConfirm},{headers:{Authorization:i}}),await app.pb.collection(`_superusers`).authWithPassword(a.email,a.password),window.location.hash=`#/`}catch(e){app.checkApiError(e)}a.isSubmitting=!1}}let s=`backupFileInput`;function c(){let e=document.getElementById(s);e&&(e.value=``)}function l(e){e&&app.modals.confirm(t.h6(null,`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\nDo you really want to upload and initialize "${e.name}"?`),()=>{u(e)},()=>{c()})}async function u(e){if(!(!e||a.isBusy)){a.isUploading=!0;try{await app.pb.backups.upload({file:e},{headers:{Authorization:i}}),await app.pb.backups.restore(e.name,{headers:{Authorization:i}}),app.toasts.info(`Please wait while extracting the uploaded archive!`),await new Promise(e=>setTimeout(e,3e3)),window.location.href=`#/`}catch(e){app.checkApiError(e)}c(),a.isUploading=!1}}return t.div({pbEvent:`pageInstaller`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),t.form({pbEvent:`installerForm`,className:`grid installer-form`,onsubmit:e=>{e.preventDefault(),o(a)}},t.div({className:`col-12 txt-center`},`Create your first superuser account in order to continue:`),t.div({className:`col-12`},t.div({className:`field`},t.label({htmlFor:`superuser_email`},`Email`),t.input({id:`superuser_email`,name:`email`,type:`email`,required:!0,autofocus:!0,autocomplete:`off`,disabled:()=>a.isBusy,value:()=>a.email,oninput:e=>a.email=e.target.value}))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password`},`Password`),t.input({id:`superuser_password`,name:`password`,min:10,required:!0,disabled:()=>a.isBusy,type:()=>a.showPassword?`text`:`password`,value:()=>a.password,oninput:e=>a.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPassword?`Hide password`:`Show password`),onclick:()=>a.showPassword=!a.showPassword},t.i({className:()=>a.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0})))),t.div({className:`field-help`},`Recommended at least 10 characters.`)),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password_confirm`},`Password confirm`),t.input({id:`superuser_password_confirm`,name:`passwordConfirm`,required:!0,disabled:()=>a.isBusy,type:()=>a.showPasswordConfirm?`text`:`password`,value:()=>a.passwordConfirm,oninput:e=>a.passwordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPasswordConfirm?`Hide password`:`Show password`),onclick:()=>a.showPasswordConfirm=!a.showPasswordConfirm},t.i({className:()=>a.showPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg next block ${a.isSubmitting?`loading`:``}`,disabled:()=>a.isBusy},t.span({className:`txt`},`Create superuser and login`),t.i({className:`ri-arrow-right-line`,ariaHidden:!0})))),t.hr(),t.label({htmlFor:s,className:()=>`btn secondary transparent lg block ${a.isBusy?`disabled`:``} ${a.isUploading?`loading`:``}`},t.i({className:`ri-upload-cloud-line`,ariaHidden:!0}),t.span({className:`txt`},`Or initialize from backup`)),t.input({id:s,type:`file`,className:`hidden`,accept:`.zip`,onchange:e=>{l(e.target?.files?.[0])}}))}export{r as pageInstaller};
|
||||
import{n as e,r as n}from"./index-BeROT1qQ.js";function r(r){let i=r.params?.token||``;if(e(i).type!=`auth`||n(i)){app.toasts.error(`The installer token is invalid or has expired.`),window.location.hash=`#/`;return}app.store.title=`Setup your PocketBase instance`;let a=store({email:``,password:``,passwordConfirm:``,showPassword:!1,showPasswordConfirm:!1,isSubmitting:!1,isUploading:!1,get isBusy(){return a.isSubmitting||a.isUploading}});async function o(){if(!a.isBusy){a.isSubmitting=!0;try{await app.pb.collection(`_superusers`).create({email:a.email,password:a.password,passwordConfirm:a.passwordConfirm},{headers:{Authorization:i}}),await app.pb.collection(`_superusers`).authWithPassword(a.email,a.password),window.location.hash=`#/`}catch(e){app.checkApiError(e)}a.isSubmitting=!1}}let s=`backupFileInput`;function c(){let e=document.getElementById(s);e&&(e.value=``)}function l(e){e&&app.modals.confirm(t.h6(null,`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\nDo you really want to upload and initialize "${e.name}"?`),()=>{u(e)},()=>{c()})}async function u(e){if(!(!e||a.isBusy)){a.isUploading=!0;try{await app.pb.backups.upload({file:e},{headers:{Authorization:i}}),await app.pb.backups.restore(e.name,{headers:{Authorization:i}}),app.toasts.info(`Please wait while extracting the uploaded archive!`),await new Promise(e=>setTimeout(e,3e3)),window.location.href=`#/`}catch(e){app.checkApiError(e)}c(),a.isUploading=!1}}return t.div({pbEvent:`pageInstaller`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),t.form({pbEvent:`installerForm`,className:`grid installer-form`,onsubmit:e=>{e.preventDefault(),o(a)}},t.div({className:`col-12 txt-center`},`Create your first superuser account in order to continue:`),t.div({className:`col-12`},t.div({className:`field`},t.label({htmlFor:`superuser_email`},`Email`),t.input({id:`superuser_email`,name:`email`,type:`email`,required:!0,autofocus:!0,autocomplete:`off`,disabled:()=>a.isBusy,value:()=>a.email,oninput:e=>a.email=e.target.value}))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password`},`Password`),t.input({id:`superuser_password`,name:`password`,min:10,required:!0,disabled:()=>a.isBusy,type:()=>a.showPassword?`text`:`password`,value:()=>a.password,oninput:e=>a.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPassword?`Hide password`:`Show password`),onclick:()=>a.showPassword=!a.showPassword},t.i({className:()=>a.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0})))),t.div({className:`field-help`},`Recommended at least 10 characters.`)),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password_confirm`},`Password confirm`),t.input({id:`superuser_password_confirm`,name:`passwordConfirm`,required:!0,disabled:()=>a.isBusy,type:()=>a.showPasswordConfirm?`text`:`password`,value:()=>a.passwordConfirm,oninput:e=>a.passwordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPasswordConfirm?`Hide password`:`Show password`),onclick:()=>a.showPasswordConfirm=!a.showPasswordConfirm},t.i({className:()=>a.showPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg next block ${a.isSubmitting?`loading`:``}`,disabled:()=>a.isBusy},t.span({className:`txt`},`Create superuser and login`),t.i({className:`ri-arrow-right-line`,ariaHidden:!0})))),t.hr(),t.label({htmlFor:s,className:()=>`btn secondary transparent lg block ${a.isBusy?`disabled`:``} ${a.isUploading?`loading`:``}`},t.i({className:`ri-upload-cloud-line`,ariaHidden:!0}),t.span({className:`txt`},`Or initialize from backup`)),t.input({id:s,type:`file`,className:`hidden`,accept:`.zip`,onchange:e=>{l(e.target?.files?.[0])}}))}export{r as pageInstaller};
|
||||
Vendored
+2
-2
@@ -13,8 +13,8 @@
|
||||
|
||||
<!-- prism -->
|
||||
<script src="./libs/prism/prism.js" data-manual></script>
|
||||
<script type="module" crossorigin src="./assets/index-DoeyJa3f.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CZinEv5W.css">
|
||||
<script type="module" crossorigin src="./assets/index-BeROT1qQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-kt0ysE3i.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "PocketBase Superuser UI",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"pocketbase": "^0.28.0"
|
||||
"pocketbase": "^0.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dprint": "^0.52.0",
|
||||
@@ -865,9 +865,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.28.0.tgz",
|
||||
"integrity": "sha512-aCrab5XPPQ40iKIKicQLdUvrAvJQCHZ3cwZD5i22A8PVM/EcemUta7WFw0x4G6WQEOBMa/8kioqCR62ozk4KvQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.28.1.tgz",
|
||||
"integrity": "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"pocketbase": "^0.28.0"
|
||||
"pocketbase": "^0.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dprint": "^0.52.0",
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
--secondaryAlt1Color: var(--surfaceAlt3Color);
|
||||
--secondaryAlt2Color: var(--surfaceAlt4Color);
|
||||
|
||||
--primaryColor: #121212;
|
||||
--primaryColor: var(--surfaceAlt4Color);
|
||||
--primaryTxtColor: var(--surfaceTxtColor);
|
||||
--primaryAlt1Color: color-mix(in srgb, var(--primaryColor), black 12%);
|
||||
--primaryAlt2Color: color-mix(in srgb, var(--primaryColor), black 25%);
|
||||
|
||||
@@ -153,6 +153,7 @@ window.app.components.recordsList = function(propsArg = {}) {
|
||||
});
|
||||
|
||||
if (result.page == 1) {
|
||||
props.suggestReset = false;
|
||||
clearList();
|
||||
}
|
||||
|
||||
@@ -318,7 +319,14 @@ window.app.components.recordsList = function(propsArg = {}) {
|
||||
|
||||
clearTimeout(deleteRefreshTimeoutId);
|
||||
deleteRefreshTimeoutId = setTimeout(() => {
|
||||
if (!data.records?.length) {
|
||||
if (
|
||||
// no more client-side records
|
||||
!data.records?.length
|
||||
// has cascade self-reference field
|
||||
|| !!props.collection?.fields?.find((f) => {
|
||||
return f.type == "relation" && f.cascadeDelete && f.collectionId == props.collection.id;
|
||||
})
|
||||
) {
|
||||
loadRecords(true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
+4
-4
@@ -1667,14 +1667,14 @@ const utils = {
|
||||
};
|
||||
|
||||
const indexRegex =
|
||||
/create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?/gim;
|
||||
/\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s*where\s+([\s\S]*?))?\s*$/gi;
|
||||
const matches = indexRegex.exec((idx || "").trim());
|
||||
|
||||
if (matches?.length != 7) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const sqlQuoteRegex = /^[\"\'\`\[\{}]|[\"\'\`\]\}]$/gm;
|
||||
const sqlQuoteRegex = /^[\"\'\`\[\{}]|[\"\'\`\]\}]$/g;
|
||||
|
||||
// unique
|
||||
result.unique = matches[1]?.trim().toLowerCase() === "unique";
|
||||
@@ -1697,13 +1697,13 @@ const utils = {
|
||||
|
||||
// columns
|
||||
const rawColumns = (matches[5] || "")
|
||||
.replace(/,(?=[^\(]*\))/gim, "{PB_TEMP}") // temporary replace comma within expressions for easier splitting
|
||||
.replace(/,(?=[^\(]*\))/gi, "{PB_TEMP}") // temporary replace comma within expressions for easier splitting
|
||||
.split(","); // split columns
|
||||
|
||||
for (let col of rawColumns) {
|
||||
col = col.trim().replaceAll("{PB_TEMP}", ","); // revert temp replacement
|
||||
|
||||
const colRegex = /^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$/gim;
|
||||
const colRegex = /^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$/gi;
|
||||
const colMatches = colRegex.exec(col);
|
||||
if (colMatches?.length != 4) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user