diff --git a/apis/backup.go b/apis/backup.go
index bee1d63c..2e0401e0 100644
--- a/apis/backup.go
+++ b/apis/backup.go
@@ -4,12 +4,14 @@ import (
"context"
"log"
"net/http"
+ "net/url"
"path/filepath"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -23,28 +25,22 @@ func bindBackupApi(app core.App, rg *echo.Group) {
subGroup := rg.Group("/backups", ActivityLogger(app))
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuth())
- subGroup.GET("/:name", api.download)
- subGroup.DELETE("/:name", api.delete, RequireAdminAuth())
- subGroup.POST("/:name/restore", api.restore, RequireAdminAuth())
+ subGroup.GET("/:key", api.download)
+ subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
+ subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
}
type backupApi struct {
app core.App
}
-type backupItem struct {
- Name string `json:"name"`
- Size int64 `json:"size"`
- Modified types.DateTime `json:"modified"`
-}
-
func (api *backupApi) list(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem", err)
+ return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
@@ -55,13 +51,13 @@ func (api *backupApi) list(c echo.Context) error {
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
}
- result := make([]backupItem, len(backups))
+ result := make([]models.BackupFileInfo, len(backups))
for i, obj := range backups {
modified, _ := types.ParseDateTime(obj.ModTime)
- result[i] = backupItem{
- Name: obj.Key,
+ result[i] = models.BackupFileInfo{
+ Key: obj.Key,
Size: obj.Size,
Modified: modified,
}
@@ -71,7 +67,7 @@ func (api *backupApi) list(c echo.Context) error {
}
func (api *backupApi) create(c echo.Context) error {
- if cast.ToString(api.app.Cache().Get(core.CacheActiveBackupsKey)) != "" {
+ if api.app.Cache().Has(core.CacheKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}
@@ -83,9 +79,11 @@ func (api *backupApi) create(c echo.Context) error {
return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
return func(name string) error {
if err := next(name); err != nil {
- return NewBadRequestError("Failed to create backup", err)
+ return NewBadRequestError("Failed to create backup.", err)
}
+ // we don't retrieve the generated backup file because it may not be
+ // available yet due to the eventually consistent nature of some S3 providers
return c.NoContent(http.StatusNoContent)
}
})
@@ -107,15 +105,15 @@ func (api *backupApi) download(c echo.Context) error {
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem", err)
+ return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
- name := c.PathParam("name")
+ key := c.PathParam("key")
- br, err := fsys.GetFile(name)
+ br, err := fsys.GetFile(key)
if err != nil {
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
}
@@ -124,42 +122,43 @@ func (api *backupApi) download(c echo.Context) error {
return fsys.Serve(
c.Response(),
c.Request(),
- name,
- filepath.Base(name), // without the path prefix (if any)
+ key,
+ filepath.Base(key), // without the path prefix (if any)
)
}
func (api *backupApi) restore(c echo.Context) error {
- if cast.ToString(api.app.Cache().Get(core.CacheActiveBackupsKey)) != "" {
- return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
+ if api.app.Cache().Has(core.CacheKeyActiveBackup) {
+ return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
}
- name := c.PathParam("name")
+ // @todo remove the extra unescape after https://github.com/labstack/echo/issues/2447
+ key, _ := url.PathUnescape(c.PathParam("key"))
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem", err)
+ return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(existsCtx)
- if exists, err := fsys.Exists(name); !exists {
- return NewNotFoundError("Missing or invalid backup file", err)
+ if exists, err := fsys.Exists(key); !exists {
+ return NewBadRequestError("Missing or invalid backup file.", err)
}
go func() {
- // wait max 10 minutes
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ // wait max 15 minutes to fetch the backup
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// give some optimistic time to write the response
time.Sleep(1 * time.Second)
- if err := api.app.RestoreBackup(ctx, name); err != nil && api.app.IsDebug() {
+ if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
log.Println(err)
}
}()
@@ -173,15 +172,19 @@ func (api *backupApi) delete(c echo.Context) error {
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem", err)
+ return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
- name := c.PathParam("name")
+ key := c.PathParam("key")
- if err := fsys.Delete(name); err != nil {
+ if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
+ return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
+ }
+
+ if err := fsys.Delete(key); err != nil {
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
}
diff --git a/apis/backup_test.go b/apis/backup_test.go
new file mode 100644
index 00000000..926547ab
--- /dev/null
+++ b/apis/backup_test.go
@@ -0,0 +1,571 @@
+package apis_test
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "gocloud.dev/blob"
+)
+
+func TestBackupsList(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodGet,
+ Url: "/api/backups",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as auth record",
+ Method: http.MethodGet,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (empty list)",
+ Method: http.MethodGet,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `[]`,
+ },
+ },
+ {
+ Name: "authorized as admin",
+ Method: http.MethodGet,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"test1.zip"`,
+ `"test2.zip"`,
+ `"test3.zip"`,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestBackupsCreate(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureNoBackups(t, app)
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as auth record",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureNoBackups(t, app)
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (pending backup)",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ app.Cache().Set(core.CacheKeyActiveBackup, "")
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureNoBackups(t, app)
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (autogenerated name)",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ files, err := getBackupFiles(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(files); total != 1 {
+ t.Fatalf("Expected 1 backup file, got %d", total)
+ }
+
+ expected := "pb_backup_"
+ if !strings.HasPrefix(files[0].Key, expected) {
+ t.Fatalf("Expected backup file with prefix %q, got %q", expected, files[0].Key)
+ }
+ },
+ ExpectedStatus: 204,
+ },
+ {
+ Name: "authorized as admin (invalid name)",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ Body: strings.NewReader(`{"name":"!test.zip"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ ensureNoBackups(t, app)
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"name":{"code":"validation_match_invalid"`,
+ },
+ },
+ {
+ Name: "authorized as admin (valid name)",
+ Method: http.MethodPost,
+ Url: "/api/backups",
+ Body: strings.NewReader(`{"name":"test.zip"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ files, err := getBackupFiles(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(files); total != 1 {
+ t.Fatalf("Expected 1 backup file, got %d", total)
+ }
+
+ expected := "test.zip"
+ if files[0].Key != expected {
+ t.Fatalf("Expected backup file %q, got %q", expected, files[0].Key)
+ }
+ },
+ ExpectedStatus: 204,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestBackupsDownload(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with record auth header",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with admin auth header",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with empty or invalid token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with valid record auth token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with valid record file token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with valid admin auth token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with expired admin file token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with valid admin file token but missing backup name",
+ Method: http.MethodGet,
+ Url: "/api/backups/mizzing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "with valid admin file token",
+ Method: http.MethodGet,
+ Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `storage/`,
+ `data.db`,
+ `logs.db`,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestBackupsDelete(t *testing.T) {
+ noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
+ files, err := getBackupFiles(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := 3
+ if total := len(files); total != expected {
+ t.Fatalf("Expected %d backup(s), got %d", expected, total)
+ }
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodDelete,
+ Url: "/api/backups/test1.zip",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ noTestBackupFilesChanges(t, app)
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as auth record",
+ Method: http.MethodDelete,
+ Url: "/api/backups/test1.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ noTestBackupFilesChanges(t, app)
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (missing file)",
+ Method: http.MethodDelete,
+ Url: "/api/backups/missing.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ noTestBackupFilesChanges(t, app)
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (existing file with matching active backup)",
+ Method: http.MethodDelete,
+ Url: "/api/backups/test1.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+
+ // mock active backup with the same name to delete
+ app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
+
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ noTestBackupFilesChanges(t, app)
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (existing file and no matching active backup)",
+ Method: http.MethodDelete,
+ Url: "/api/backups/test1.zip",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+
+ // mock active backup with different name
+ app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
+ },
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ files, err := getBackupFiles(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(files); total != 2 {
+ t.Fatalf("Expected 2 backup files, got %d", total)
+ }
+
+ deletedFile := "test1.zip"
+
+ for _, f := range files {
+ if f.Key == deletedFile {
+ t.Fatalf("Expected backup %q to be deleted", deletedFile)
+ }
+ }
+ },
+ ExpectedStatus: 204,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestBackupsRestore(t *testing.T) {
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ Url: "/api/backups/test1.zip/restore",
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as auth record",
+ Method: http.MethodPost,
+ Url: "/api/backups/test1.zip/restore",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (missing file)",
+ Method: http.MethodPost,
+ Url: "/api/backups/missing.zip/restore",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "authorized as admin (active backup process)",
+ Method: http.MethodPost,
+ Url: "/api/backups/test1.zip/restore",
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ if err := createTestBackups(app); err != nil {
+ t.Fatal(err)
+ }
+
+ app.Cache().Set(core.CacheKeyActiveBackup, "")
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+// -------------------------------------------------------------------
+
+func createTestBackups(app core.App) error {
+ ctx := context.Background()
+
+ if err := app.CreateBackup(ctx, "test1.zip"); err != nil {
+ return err
+ }
+
+ if err := app.CreateBackup(ctx, "test2.zip"); err != nil {
+ return err
+ }
+
+ if err := app.CreateBackup(ctx, "test3.zip"); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
+ fsys, err := app.NewBackupsFilesystem()
+ if err != nil {
+ return nil, err
+ }
+ defer fsys.Close()
+
+ return fsys.List("")
+}
+
+func ensureNoBackups(t *testing.T, app *tests.TestApp) {
+ files, err := getBackupFiles(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(files); total != 0 {
+ t.Fatalf("Expected 0 backup files, got %d", total)
+ }
+}
diff --git a/apis/base.go b/apis/base.go
index 4ee64445..c2b9a225 100644
--- a/apis/base.go
+++ b/apis/base.go
@@ -113,8 +113,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
bindFileApi(app, api)
bindRealtimeApi(app, api)
bindLogsApi(app, api)
- bindBackupApi(app, api)
bindHealthApi(app, api)
+ bindBackupApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
diff --git a/apis/health.go b/apis/health.go
index ab1df6fd..fef22c39 100644
--- a/apis/health.go
+++ b/apis/health.go
@@ -19,12 +19,20 @@ type healthApi struct {
app core.App
}
+type healthCheckResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data struct {
+ CanBackup bool `json:"canBackup"`
+ } `json:"data"`
+}
+
// healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error {
- payload := map[string]any{
- "code": http.StatusOK,
- "message": "API is healthy.",
- }
+ resp := new(healthCheckResponse)
+ resp.Code = http.StatusOK
+ resp.Message = "API is healthy."
+ resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
- return c.JSON(http.StatusOK, payload)
+ return c.JSON(http.StatusOK, resp)
}
diff --git a/apis/health_test.go b/apis/health_test.go
index 1f747d94..dc1c491d 100644
--- a/apis/health_test.go
+++ b/apis/health_test.go
@@ -16,6 +16,8 @@ func TestHealthAPI(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"code":200`,
+ `"data":{`,
+ `"canBackup":true`,
},
},
}
diff --git a/apis/settings.go b/apis/settings.go
index a3cf60cd..b6efbd12 100644
--- a/apis/settings.go
+++ b/apis/settings.go
@@ -1,7 +1,6 @@
package apis
import (
- "fmt"
"log"
"net/http"
@@ -10,7 +9,6 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tools/security"
)
// bindSettingsApi registers the settings api endpoints.
@@ -86,27 +84,22 @@ func (api *settingsApi) set(c echo.Context) error {
}
func (api *settingsApi) testS3(c echo.Context) error {
- if !api.app.Settings().S3.Enabled {
- return NewBadRequestError("S3 storage is not enabled.", nil)
+ form := forms.NewTestS3Filesystem(api.app)
+
+ // load request
+ if err := c.Bind(form); err != nil {
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
- fs, err := api.app.NewFilesystem()
- if err != nil {
- return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
- }
- defer fs.Close()
+ // send
+ if err := form.Submit(); err != nil {
+ // form error
+ if fErr, ok := err.(validation.Errors); ok {
+ return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
+ }
- testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
- testFileKey := testPrefix + "/test.txt"
-
- // try to upload a test file
- if err := fs.Upload([]byte("test"), testFileKey); err != nil {
- return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
- }
-
- // test prefix deletion (ensures that both bucket list and delete works)
- if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
- return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
+ // mailer error
+ return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
diff --git a/apis/settings_test.go b/apis/settings_test.go
index 22b74644..314e48d9 100644
--- a/apis/settings_test.go
+++ b/apis/settings_test.go
@@ -47,6 +47,7 @@ func TestSettingsList(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
+ `"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
@@ -125,6 +126,7 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
+ `"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
@@ -190,6 +192,7 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
+ `"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
@@ -255,14 +258,44 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin (no s3)",
+ Name: "authorized as admin (missing body + no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"filesystem":{`,
+ },
+ },
+ {
+ Name: "authorized as admin (invalid filesystem)",
+ Method: http.MethodPost,
+ Url: "/api/settings/test/s3",
+ Body: strings.NewReader(`{"filesystem":"invalid"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"filesystem":{`,
+ },
+ },
+ {
+ Name: "authorized as admin (valid filesystem and no s3)",
+ Method: http.MethodPost,
+ Url: "/api/settings/test/s3",
+ Body: strings.NewReader(`{"filesystem":"storage"}`),
+ RequestHeaders: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
},
}
diff --git a/core/base.go b/core/base.go
index 24b5db5f..69d8c782 100644
--- a/core/base.go
+++ b/core/base.go
@@ -1152,4 +1152,8 @@ func (app *BaseApp) registerDefaultHooks() {
app.ResetBootstrapState()
return nil
})
+
+ if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
+ log.Println(err)
+ }
}
diff --git a/core/base_backup.go b/core/base_backup.go
index 01b81012..2fb0b50a 100644
--- a/core/base_backup.go
+++ b/core/base_backup.go
@@ -9,16 +9,18 @@ import (
"os"
"path/filepath"
"runtime"
+ "sort"
"time"
"github.com/pocketbase/pocketbase/daos"
+ "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/archive"
+ "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
- "github.com/spf13/cast"
)
-const CacheActiveBackupsKey string = "@activeBackup"
+const CacheKeyActiveBackup string = "@activeBackup"
// CreateBackup creates a new backup of the current app pb_data directory.
//
@@ -36,9 +38,8 @@ const CacheActiveBackupsKey string = "@activeBackup"
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
- canBackup := cast.ToString(app.Cache().Get(CacheActiveBackupsKey)) != ""
- if canBackup {
- return errors.New("try again later - another backup/restore process has already been started")
+ if app.Cache().Has(CacheKeyActiveBackup) {
+ return errors.New("try again later - another backup/restore operation has already been started")
}
// auto generate backup name
@@ -49,8 +50,8 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
)
}
- app.Cache().Set(CacheActiveBackupsKey, name)
- defer app.Cache().Remove(CacheActiveBackupsKey)
+ app.Cache().Set(CacheKeyActiveBackup, name)
+ defer app.Cache().Remove(CacheKeyActiveBackup)
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
//
@@ -121,19 +122,18 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
// 6. Restart the app (on successfull app bootstap it will also remove the old pb_data).
//
// If a failure occure during the restore process the dir changes are reverted.
-// It for whatever reason the revert is not possible, it panics.
+// If for whatever reason the revert is not possible, it panics.
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
if runtime.GOOS == "windows" {
return errors.New("restore is not supported on windows")
}
- canBackup := cast.ToString(app.Cache().Get(CacheActiveBackupsKey)) != ""
- if canBackup {
- return errors.New("try again later - another backup/restore process has already been started")
+ if app.Cache().Has(CacheKeyActiveBackup) {
+ return errors.New("try again later - another backup/restore operation has already been started")
}
- app.Cache().Set(CacheActiveBackupsKey, name)
- defer app.Cache().Remove(CacheActiveBackupsKey)
+ app.Cache().Set(CacheKeyActiveBackup, name)
+ defer app.Cache().Remove(CacheKeyActiveBackup)
fsys, err := app.NewBackupsFilesystem()
if err != nil {
@@ -227,7 +227,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// restore the local pb_data/backups dir (if any)
if _, err := os.Stat(oldLocalBackupsDir); err == nil {
if err := os.Rename(oldLocalBackupsDir, newLocalBackupsDir); err != nil {
- if err := revertDataDirChanges(true); err != nil && app.IsDebug() {
+ if err := revertDataDirChanges(false); err != nil && app.IsDebug() {
log.Println(err)
}
@@ -237,7 +237,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// restart the app
if err := app.Restart(); err != nil {
- if err := revertDataDirChanges(false); err != nil {
+ if err := revertDataDirChanges(true); err != nil {
panic(err)
}
@@ -246,3 +246,106 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return nil
}
+
+// initAutobackupHooks registers the autobackup app serve hooks.
+// @todo add tests
+func (app *BaseApp) initAutobackupHooks() error {
+ c := cron.New()
+
+ loadJob := func() {
+ c.Stop()
+
+ rawSchedule := app.Settings().Backups.Cron
+ if rawSchedule == "" || !app.IsBootstrapped() {
+ return
+ }
+
+ c.Add("@autobackup", rawSchedule, func() {
+ autoPrefix := "@auto_pb_backup_"
+
+ name := fmt.Sprintf(
+ "%s%s.zip",
+ autoPrefix,
+ time.Now().UTC().Format("20060102150405"),
+ )
+
+ if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
+ // @todo replace after logs generalization
+ log.Println(err)
+ }
+
+ maxKeep := app.Settings().Backups.CronMaxKeep
+
+ if maxKeep == 0 {
+ return // no explicit limit
+ }
+
+ fsys, err := app.NewBackupsFilesystem()
+ if err != nil && app.IsDebug() {
+ // @todo replace after logs generalization
+ log.Println(err)
+ return
+ }
+ defer fsys.Close()
+
+ files, err := fsys.List(autoPrefix)
+ if err != nil && app.IsDebug() {
+ // @todo replace after logs generalization
+ log.Println(err)
+ return
+ }
+
+ if maxKeep >= len(files) {
+ return // nothing to remove
+ }
+
+ // sort desc
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].ModTime.After(files[j].ModTime)
+ })
+
+ // keep only the most recent n auto backup files
+ toRemove := files[maxKeep:]
+
+ for _, f := range toRemove {
+ if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
+ // @todo replace after logs generalization
+ log.Println(err)
+ }
+ }
+ })
+
+ // restart the ticker
+ c.Start()
+ }
+
+ // load on app serve
+ app.OnBeforeServe().Add(func(e *ServeEvent) error {
+ loadJob()
+ return nil
+ })
+
+ // stop the ticker on app termination
+ app.OnTerminate().Add(func(e *TerminateEvent) error {
+ c.Stop()
+ return nil
+ })
+
+ // reload on app settings change
+ app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
+ if !c.HasStarted() {
+ return nil // no need to reload as it hasn't been started yet
+ }
+
+ p := e.Model.(*models.Param)
+ if p == nil || p.Key != models.ParamAppSettings {
+ return nil
+ }
+
+ loadJob()
+
+ return nil
+ })
+
+ return nil
+}
diff --git a/core/base_backup_test.go b/core/base_backup_test.go
new file mode 100644
index 00000000..073d652f
--- /dev/null
+++ b/core/base_backup_test.go
@@ -0,0 +1,153 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/archive"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func TestCreateBackup(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // test pending error
+ app.Cache().Set(core.CacheKeyActiveBackup, "")
+ if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
+ t.Fatal("Expected pending error, got nil")
+ }
+ app.Cache().Remove(core.CacheKeyActiveBackup)
+
+ // create with auto generated name
+ if err := app.CreateBackup(context.Background(), ""); err != nil {
+ t.Fatal("Failed to create a backup with autogenerated name")
+ }
+
+ // create with custom name
+ if err := app.CreateBackup(context.Background(), "custom"); err != nil {
+ t.Fatal("Failed to create a backup with custom name")
+ }
+
+ // create new with the same name (aka. replace)
+ if err := app.CreateBackup(context.Background(), "custom"); err != nil {
+ t.Fatal("Failed to create and replace a backup with the same name")
+ }
+
+ backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
+
+ entries, err := os.ReadDir(backupsDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectedFiles := []string{
+ `^pb_backup_\w+\.zip$`,
+ `^pb_backup_\w+\.zip.attrs$`,
+ "custom",
+ "custom.attrs",
+ }
+
+ if len(entries) != len(expectedFiles) {
+ names := getEntryNames(entries)
+ t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
+ }
+
+ for i, entry := range entries {
+ if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
+ t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
+ }
+
+ if strings.HasSuffix(entry.Name(), ".attrs") {
+ continue
+ }
+
+ path := filepath.Join(backupsDir, entry.Name())
+
+ if err := verifyBackupContent(app, path); err != nil {
+ t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
+ }
+ }
+}
+
+func TestRestoreBackup(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // create test backup
+ if err := app.CreateBackup(context.Background(), "test"); err != nil {
+ t.Fatal("Failed to create test backup")
+ }
+
+ // test pending error
+ app.Cache().Set(core.CacheKeyActiveBackup, "")
+ if err := app.RestoreBackup(context.Background(), "test"); err == nil {
+ t.Fatal("Expected pending error, got nil")
+ }
+ app.Cache().Remove(core.CacheKeyActiveBackup)
+
+ // missing backup
+ if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
+ t.Fatal("Expected missing error, got nil")
+ }
+}
+
+// -------------------------------------------------------------------
+
+func verifyBackupContent(app core.App, path string) error {
+ dir, err := os.MkdirTemp("", "backup_test")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ if err := archive.Extract(path, dir); err != nil {
+ return err
+ }
+
+ expectedRootEntries := []string{
+ "storage",
+ "data.db",
+ "data.db-shm",
+ "data.db-wal",
+ "logs.db",
+ "logs.db-shm",
+ "logs.db-wal",
+ ".gitignore",
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+
+ if len(entries) != len(expectedRootEntries) {
+ names := getEntryNames(entries)
+ return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
+ }
+
+ for _, entry := range entries {
+ if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
+ return fmt.Errorf("Didn't expect %q entry", entry.Name())
+ }
+ }
+
+ return nil
+}
+
+func getEntryNames(entries []fs.DirEntry) []string {
+ names := make([]string, len(entries))
+
+ for i, entry := range entries {
+ names[i] = entry.Name()
+ }
+
+ return names
+}
diff --git a/forms/backup_create.go b/forms/backup_create.go
index 0cabaabb..d7737027 100644
--- a/forms/backup_create.go
+++ b/forms/backup_create.go
@@ -45,6 +45,9 @@ func (form *BackupCreate) Validate() error {
func (form *BackupCreate) checkUniqueName(value any) error {
v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
diff --git a/forms/backup_create_test.go b/forms/backup_create_test.go
new file mode 100644
index 00000000..c52f3417
--- /dev/null
+++ b/forms/backup_create_test.go
@@ -0,0 +1,102 @@
+package forms_test
+
+import (
+ "strings"
+ "testing"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestBackupCreateValidateAndSubmit(t *testing.T) {
+ scenarios := []struct {
+ name string
+ backupName string
+ expectedErrors []string
+ }{
+ {
+ "invalid length",
+ strings.Repeat("a", 97) + ".zip",
+ []string{"name"},
+ },
+ {
+ "valid length + invalid format",
+ strings.Repeat("a", 96),
+ []string{"name"},
+ },
+ {
+ "valid length + valid format",
+ strings.Repeat("a", 96) + ".zip",
+ []string{},
+ },
+ {
+ "auto generated name",
+ "",
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ func() {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ fsys, err := app.NewBackupsFilesystem()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fsys.Close()
+
+ form := forms.NewBackupCreate(app)
+ form.Name = s.backupName
+
+ result := form.Submit()
+
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Errorf("[%s] Failed to parse errors %v", s.name, result)
+ return
+ }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
+ }
+ }
+
+ // retrieve all created backup files
+ files, err := fsys.List("")
+ if err != nil {
+ t.Errorf("[%s] Failed to retrieve backup files", s.name)
+ return
+ }
+
+ if result != nil {
+ if total := len(files); total != 0 {
+ t.Errorf("[%s] Didn't expected backup files, found %d", s.name, total)
+ }
+ return
+ }
+
+ if total := len(files); total != 1 {
+ t.Errorf("[%s] Expected 1 backup file, got %d", s.name, total)
+ return
+ }
+
+ if s.backupName == "" {
+ prefix := "pb_backup_"
+ if !strings.HasPrefix(files[0].Key, prefix) {
+ t.Errorf("[%s] Expected the backup file, to have prefix %q: %q", s.name, prefix, files[0].Key)
+ }
+ } else if s.backupName != files[0].Key {
+ t.Errorf("[%s] Expected backup file %q, got %q", s.name, s.backupName, files[0].Key)
+ }
+ }()
+ }
+}
diff --git a/forms/test_email_send_test.go b/forms/test_email_send_test.go
index 60406098..5d0b7bc2 100644
--- a/forms/test_email_send_test.go
+++ b/forms/test_email_send_test.go
@@ -24,56 +24,58 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
}
for i, s := range scenarios {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ func() {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
- form := forms.NewTestEmailSend(app)
- form.Email = s.email
- form.Template = s.template
+ form := forms.NewTestEmailSend(app)
+ form.Email = s.email
+ form.Template = s.template
- result := form.Submit()
+ result := form.Submit()
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- continue
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- continue
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Errorf("(%d) Failed to parse errors %v", i, result)
+ return
}
- }
- expectedEmails := 1
- if len(s.expectedErrors) > 0 {
- expectedEmails = 0
- }
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
+ return
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
+ return
+ }
+ }
- if app.TestMailer.TotalSend != expectedEmails {
- t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
- }
+ expectedEmails := 1
+ if len(s.expectedErrors) > 0 {
+ expectedEmails = 0
+ }
- if len(s.expectedErrors) > 0 {
- continue
- }
+ if app.TestMailer.TotalSend != expectedEmails {
+ t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
+ }
- expectedContent := "Verify"
- if s.template == "password-reset" {
- expectedContent = "Reset password"
- } else if s.template == "email-change" {
- expectedContent = "Confirm new email"
- }
+ if len(s.expectedErrors) > 0 {
+ return
+ }
- if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
- t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
- }
+ expectedContent := "Verify"
+ if s.template == "password-reset" {
+ expectedContent = "Reset password"
+ } else if s.template == "email-change" {
+ expectedContent = "Confirm new email"
+ }
+
+ if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
+ t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
+ }
+ }()
}
}
diff --git a/forms/test_s3_filesystem.go b/forms/test_s3_filesystem.go
new file mode 100644
index 00000000..c2e26e59
--- /dev/null
+++ b/forms/test_s3_filesystem.go
@@ -0,0 +1,88 @@
+package forms
+
+import (
+ "errors"
+ "fmt"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/models/settings"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+const (
+ s3FilesystemStorage = "storage"
+ s3FilesystemBackups = "backups"
+)
+
+// TestS3Filesystem defines a S3 filesystem connection test.
+type TestS3Filesystem struct {
+ app core.App
+
+ // The name of the filesystem - storage or backups
+ Filesystem string `form:"filesystem" json:"filesystem"`
+}
+
+// NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
+func NewTestS3Filesystem(app core.App) *TestS3Filesystem {
+ return &TestS3Filesystem{app: app}
+}
+
+// Validate makes the form validatable by implementing [validation.Validatable] interface.
+func (form *TestS3Filesystem) Validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Filesystem,
+ validation.Required,
+ validation.In(s3FilesystemStorage, s3FilesystemBackups),
+ ),
+ )
+}
+
+// Submit validates and performs a S3 filesystem connection test.
+func (form *TestS3Filesystem) Submit() error {
+ if err := form.Validate(); err != nil {
+ return err
+ }
+
+ var s3Config settings.S3Config
+
+ if form.Filesystem == s3FilesystemBackups {
+ s3Config = form.app.Settings().Backups.S3
+ } else {
+ s3Config = form.app.Settings().S3
+ }
+
+ if !s3Config.Enabled {
+ return errors.New("S3 storage filesystem is not enabled")
+ }
+
+ fsys, err := filesystem.NewS3(
+ s3Config.Bucket,
+ s3Config.Region,
+ s3Config.Endpoint,
+ s3Config.AccessKey,
+ s3Config.Secret,
+ s3Config.ForcePathStyle,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to initialize the S3 filesystem: %w", err)
+ }
+ defer fsys.Close()
+
+ testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
+ testFileKey := testPrefix + "/test.txt"
+
+ // try to upload a test file
+ if err := fsys.Upload([]byte("test"), testFileKey); err != nil {
+ return fmt.Errorf("failed to upload a test file: %w", err)
+ }
+
+ // test prefix deletion (ensures that both bucket list and delete works)
+ if errs := fsys.DeletePrefix(testPrefix); len(errs) > 0 {
+ return fmt.Errorf("failed to delete a test file: %w", errs[0])
+ }
+
+ return nil
+}
diff --git a/forms/test_s3_filesystem_test.go b/forms/test_s3_filesystem_test.go
new file mode 100644
index 00000000..79091db4
--- /dev/null
+++ b/forms/test_s3_filesystem_test.go
@@ -0,0 +1,103 @@
+package forms_test
+
+import (
+ "testing"
+
+ validation "github.com/go-ozzo/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/forms"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestS3FilesystemValidate(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ filesystem string
+ expectedErrors []string
+ }{
+ {
+ "empty filesystem",
+ "",
+ []string{"filesystem"},
+ },
+ {
+ "invalid filesystem",
+ "something",
+ []string{"filesystem"},
+ },
+ {
+ "backups filesystem",
+ "backups",
+ []string{},
+ },
+ {
+ "storage filesystem",
+ "storage",
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ form := forms.NewTestS3Filesystem(app)
+ form.Filesystem = s.filesystem
+
+ result := form.Validate()
+
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Errorf("[%s] Failed to parse errors %v", s.name, result)
+ continue
+ }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
+ continue
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
+ }
+ }
+ }
+}
+
+func TestS3FilesystemSubmitFailure(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // check if validate was called
+ {
+ form := forms.NewTestS3Filesystem(app)
+ form.Filesystem = ""
+
+ result := form.Submit()
+
+ if result == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if _, ok := result.(validation.Errors); !ok {
+ t.Fatalf("Expected validation.Error, got %v", result)
+ }
+ }
+
+ // check with valid storage and disabled s3
+ {
+ form := forms.NewTestS3Filesystem(app)
+ form.Filesystem = "storage"
+
+ result := form.Submit()
+
+ if result == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if _, ok := result.(validation.Error); ok {
+ t.Fatalf("Didn't expect validation.Error, got %v", result)
+ }
+ }
+}
diff --git a/models/backup_file_info.go b/models/backup_file_info.go
new file mode 100644
index 00000000..794900f3
--- /dev/null
+++ b/models/backup_file_info.go
@@ -0,0 +1,9 @@
+package models
+
+import "github.com/pocketbase/pocketbase/tools/types"
+
+type BackupFileInfo struct {
+ Key string `json:"key"`
+ Size int64 `json:"size"`
+ Modified types.DateTime `json:"modified"`
+}
diff --git a/models/settings/settings.go b/models/settings/settings.go
index c7bff499..409824d7 100644
--- a/models/settings/settings.go
+++ b/models/settings/settings.go
@@ -10,6 +10,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
@@ -23,12 +24,10 @@ const SecretMask string = "******"
type Settings struct {
mux sync.RWMutex
- Meta MetaConfig `form:"meta" json:"meta"`
- Logs LogsConfig `form:"logs" json:"logs"`
- Smtp SmtpConfig `form:"smtp" json:"smtp"`
- S3 S3Config `form:"s3" json:"s3"`
-
- // @todo update tests
+ Meta MetaConfig `form:"meta" json:"meta"`
+ Logs LogsConfig `form:"logs" json:"logs"`
+ Smtp SmtpConfig `form:"smtp" json:"smtp"`
+ S3 S3Config `form:"s3" json:"s3"`
Backups BackupsConfig `form:"backups" json:"backups"`
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
@@ -87,6 +86,9 @@ func New() *Settings {
Password: "",
Tls: false,
},
+ Backups: BackupsConfig{
+ CronMaxKeep: 3,
+ },
AdminAuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1209600, // 14 days
@@ -194,6 +196,7 @@ func (s *Settings) Validate() error {
validation.Field(&s.RecordFileToken),
validation.Field(&s.Smtp),
validation.Field(&s.S3),
+ validation.Field(&s.Backups),
validation.Field(&s.GoogleAuth),
validation.Field(&s.FacebookAuth),
validation.Field(&s.GithubAuth),
@@ -248,6 +251,7 @@ func (s *Settings) RedactClone() (*Settings, error) {
sensitiveFields := []*string{
&clone.Smtp.Password,
&clone.S3.Secret,
+ &clone.Backups.S3.Secret,
&clone.AdminAuthToken.Secret,
&clone.AdminPasswordResetToken.Secret,
&clone.AdminFileToken.Secret,
@@ -397,28 +401,46 @@ func (c S3Config) Validate() error {
// -------------------------------------------------------------------
type BackupsConfig struct {
- AutoInterval BackupInterval `form:"autoInterval" json:"autoInterval"`
- AutoMaxRetention int `form:"autoMaxRetention" json:"autoMaxRetention"`
- S3 S3Config `form:"s3" json:"s3"`
+ // Cron is a cron expression to schedule auto backups, eg. "* * * * *".
+ //
+ // Leave it empty to disable the auto backups functionality.
+ Cron string `form:"cron" json:"cron"`
+
+ // CronMaxKeep is the the max number of cron generated backups to
+ // keep before removing older entries.
+ //
+ // This field works only when the cron config has valid cron expression.
+ CronMaxKeep int `form:"cronMaxKeep" json:"cronMaxKeep"`
+
+ // S3 is an optional S3 storage config specifying where to store the app backups.
+ S3 S3Config `form:"s3" json:"s3"`
}
// Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface.
func (c BackupsConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.S3),
+ validation.Field(&c.Cron, validation.By(checkCronExpression)),
+ validation.Field(
+ &c.CronMaxKeep,
+ validation.When(c.Cron != "", validation.Required),
+ validation.Min(1),
+ ),
)
}
-// @todo
-type BackupInterval struct {
- Day int
-}
+func checkCronExpression(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
-// Validate makes BackupInterval validatable by implementing [validation.Validatable] interface.
-func (c BackupInterval) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.Day),
- )
+ _, err := cron.NewSchedule(v)
+ if err != nil {
+ return validation.NewError("validation_invalid_cron", err.Error())
+ }
+
+ return nil
}
// -------------------------------------------------------------------
diff --git a/models/settings/settings_test.go b/models/settings/settings_test.go
index 738f07fa..1e268017 100644
--- a/models/settings/settings_test.go
+++ b/models/settings/settings_test.go
@@ -127,6 +127,7 @@ func TestSettingsMerge(t *testing.T) {
s2.Smtp.Enabled = true
s2.S3.Enabled = true
s2.S3.Endpoint = "test"
+ s2.Backups.Cron = "* * * * *"
s2.AdminAuthToken.Duration = 1
s2.AdminPasswordResetToken.Duration = 2
s2.AdminFileToken.Duration = 2
@@ -231,6 +232,7 @@ func TestSettingsRedactClone(t *testing.T) {
// secrets
s1.Smtp.Password = testSecret
s1.S3.Secret = testSecret
+ s1.Backups.S3.Secret = testSecret
s1.AdminAuthToken.Secret = testSecret
s1.AdminPasswordResetToken.Secret = testSecret
s1.AdminFileToken.Secret = testSecret
@@ -610,6 +612,74 @@ func TestMetaConfigValidate(t *testing.T) {
}
}
+func TestBackupsConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config settings.BackupsConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ settings.BackupsConfig{},
+ []string{},
+ },
+ {
+ "invalid cron",
+ settings.BackupsConfig{
+ Cron: "invalid",
+ CronMaxKeep: 0,
+ },
+ []string{"cron", "cronMaxKeep"},
+ },
+ {
+ "invalid enabled S3",
+ settings.BackupsConfig{
+ S3: settings.S3Config{
+ Enabled: true,
+ },
+ },
+ []string{"s3"},
+ },
+ {
+ "valid data",
+ settings.BackupsConfig{
+ S3: settings.S3Config{
+ Enabled: true,
+ Endpoint: "example.com",
+ Bucket: "test",
+ Region: "test",
+ AccessKey: "test",
+ Secret: "test",
+ },
+ Cron: "*/10 * * * *",
+ CronMaxKeep: 1,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ result := s.config.Validate()
+
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Errorf("[%s] Failed to parse errors %v", s.name, result)
+ continue
+ }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
+ }
+ }
+ }
+}
+
func TestEmailTemplateValidate(t *testing.T) {
scenarios := []struct {
emailTemplate settings.EmailTemplate
diff --git a/tests/app.go b/tests/app.go
index e140857f..62fe61f1 100644
--- a/tests/app.go
+++ b/tests/app.go
@@ -44,6 +44,7 @@ func (t *TestApp) Cleanup() {
}
}
+// NewMailClient initializes test app mail client.
func (t *TestApp) NewMailClient() mailer.Mailer {
t.mux.Lock()
defer t.mux.Unlock()
diff --git a/tools/cron/cron.go b/tools/cron/cron.go
index 1fe009f9..ef8cb9ab 100644
--- a/tools/cron/cron.go
+++ b/tools/cron/cron.go
@@ -150,6 +150,14 @@ func (c *Cron) Start() {
}()
}
+// HasStarted checks whether the current Cron ticker has been started.
+func (c *Cron) HasStarted() bool {
+ c.RLock()
+ defer c.RUnlock()
+
+ return c.ticker != nil
+}
+
// runDue runs all registered jobs that are scheduled for the provided time.
func (c *Cron) runDue(t time.Time) {
c.RLock()
diff --git a/tools/cron/schedule_test.go b/tools/cron/schedule_test.go
index 4653307f..14536f85 100644
--- a/tools/cron/schedule_test.go
+++ b/tools/cron/schedule_test.go
@@ -101,7 +101,7 @@ func TestNewSchedule(t *testing.T) {
`{"minutes":{"1":{},"2":{},"40":{},"42":{},"44":{},"46":{},"48":{},"5":{},"50":{},"7":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"10":{},"11":{},"12":{},"2":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
- // hour hour segment
+ // hour segment
{
"* -1 * * *",
true,
@@ -182,7 +182,7 @@ func TestNewSchedule(t *testing.T) {
`{"minutes":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"32":{},"33":{},"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"4":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"5":{},"50":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"6":{},"7":{},"8":{},"9":{}},"hours":{"0":{},"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"3":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"days":{"1":{},"10":{},"11":{},"12":{},"13":{},"14":{},"15":{},"16":{},"17":{},"18":{},"19":{},"2":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"3":{},"30":{},"31":{},"4":{},"5":{},"6":{},"7":{},"8":{},"9":{}},"months":{"1":{},"4":{},"5":{},"7":{},"9":{}},"daysOfWeek":{"0":{},"1":{},"2":{},"3":{},"4":{},"5":{},"6":{}}}`,
},
- // day of week
+ // day of week segment
{
"* * * * -1",
true,
diff --git a/tools/filesystem/filesystem.go b/tools/filesystem/filesystem.go
index d39b638b..59601efa 100644
--- a/tools/filesystem/filesystem.go
+++ b/tools/filesystem/filesystem.go
@@ -82,7 +82,6 @@ func NewLocal(dirPath string) (*System, error) {
return &System{ctx: ctx, bucket: bucket}, nil
}
-// @todo add test
// SetContext assigns the specified context to the current filesystem.
func (s *System) SetContext(ctx context.Context) {
s.ctx = ctx
diff --git a/tools/hook/hook.go b/tools/hook/hook.go
index d2e039eb..b9393423 100644
--- a/tools/hook/hook.go
+++ b/tools/hook/hook.go
@@ -37,6 +37,8 @@ func (h *Hook[T]) Add(fn Handler[T]) {
}
// Reset removes all registered handlers.
+//
+// @todo for consistency with other Go methods consider renaming it to Clear.
func (h *Hook[T]) Reset() {
h.mux.Lock()
defer h.mux.Unlock()
diff --git a/ui/.env b/ui/.env
index f00a211d..03dfa05f 100644
--- a/ui/.env
+++ b/ui/.env
@@ -9,4 +9,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.15.3"
+PB_VERSION = "v0.16.0-WIP"
diff --git a/ui/dist/assets/AuthMethodsDocs-21c3715f.js b/ui/dist/assets/AuthMethodsDocs-9cb92810.js
similarity index 73%
rename from ui/dist/assets/AuthMethodsDocs-21c3715f.js
rename to ui/dist/assets/AuthMethodsDocs-9cb92810.js
index e7cff7a2..de127fd3 100644
--- a/ui/dist/assets/AuthMethodsDocs-21c3715f.js
+++ b/ui/dist/assets/AuthMethodsDocs-9cb92810.js
@@ -1,4 +1,4 @@
-import{S as ke,i as be,s as ge,e as r,w as g,b as w,c as _e,f as k,g as h,h as n,m as me,x as H,N as re,O as we,k as ve,P as Ce,n as Pe,t as L,a as Y,o as _,d as pe,T as Me,C as Se,p as $e,r as Q,u as je,M as Ae}from"./index-38223559.js";import{S as Be}from"./SdkTabs-9d46fa03.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",m,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),m=g(s),f=w(),k(o,"class","tab-item"),Q(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,m),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&H(m,s),C&6&&Q(o,"active",l[1]===l[5].code)},d(v){v&&_(o),i=!1,u()}}}function he(a,l){let o,s,m,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),_e(s.$$.fragment),m=w(),k(o,"class","tab-item"),Q(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),me(s,o,null),n(o,m),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&Q(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&_(o),pe(s)}}}function Oe(a){var ae,ne;let l,o,s=a[0].name+"",m,f,i,u,d,v,C,F=a[0].name+"",U,X,q,P,D,j,W,M,K,R,y,A,Z,V,z=a[0].name+"",E,x,I,B,J,S,O,b=[],ee=new Map,te,T,p=[],le=new Map,$;P=new Be({props:{js:`
+import{S as ke,i as be,s as ge,e as r,w as g,b as w,c as _e,f as k,g as h,h as n,m as me,x as G,N as re,P as we,k as ve,Q as Ce,n as Pe,t as L,a as Y,o as _,d as pe,T as Me,C as Se,p as $e,r as H,u as je,M as Ae}from"./index-077c413f.js";import{S as Be}from"./SdkTabs-9bbe3355.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",m,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),m=g(s),f=w(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,m),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&G(m,s),C&6&&H(o,"active",l[1]===l[5].code)},d(v){v&&_(o),i=!1,u()}}}function he(a,l){let o,s,m,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),_e(s.$$.fragment),m=w(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),me(s,o,null),n(o,m),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&H(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&_(o),pe(s)}}}function Te(a){var ae,ne;let l,o,s=a[0].name+"",m,f,i,u,d,v,C,F=a[0].name+"",U,X,q,P,D,j,W,M,K,R,Q,A,Z,V,y=a[0].name+"",E,x,I,B,J,S,T,b=[],ee=new Map,te,O,p=[],le=new Map,$;P=new Be({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[3]}');
@@ -14,7 +14,7 @@ import{S as ke,i as be,s as ge,e as r,w as g,b as w,c as _e,f as k,g as h,h as n
...
final result = await pb.collection('${(ne=a[0])==null?void 0:ne.name}').listAuthMethods();
- `}});let G=a[2];const oe=e=>e[5].code;for(let e=0;e
This method is usually called by users on page/screen reload to ensure that the previously stored
- data in pb.authStore
is still valid and up-to-date.
Authorization:TOKEN
header",Q=p(),D=a("div"),D.textContent="Query parameters",W=p(),T=a("table"),G=a("thead"),G.innerHTML=`pb.authStore
is still valid and up-to-date.`,v=p(),ae(g.$$.fragment),w=p(),A=a("h6"),A.textContent="API details",J=p(),$=a("div"),F=a("strong"),F.textContent="POST",ce=p(),L=a("div"),B=a("p"),de=k("/api/collections/"),K=a("strong"),Q=k(N),ue=k("/auth-refresh"),pe=p(),V=a("p"),V.innerHTML="Requires record Authorization:TOKEN
header",I=p(),D=a("div"),D.textContent="Query parameters",W=p(),T=a("table"),G=a("thead"),G.innerHTML=`For more details please check the OAuth2 integration documentation - .
`,_=h(),re(v.$$.fragment),A=h(),D=s("h6"),D.textContent="API details",z=h(),S=s("div"),j=s("strong"),j.textContent="POST",he=h(),F=s("div"),M=s("p"),pe=g("/api/collections/"),I=s("strong"),K=g(V),be=g("/auth-with-oauth2"),Q=h(),P=s("div"),P.textContent="Body Parameters",G=h(),R=s("table"),R.innerHTML=`Authorization:TOKEN
header",h(e,"class","txt-hint txt-sm txt-right")},m(l,s){r(l,e,s)},d(l){l&&d(e)}}}function Tt(o){let e,l,s,b,p,c,f,v,T,w,M,g,D,E,L,I,j,R,S,N,q,C,_;function O(u,$){var ee,Q;return(Q=(ee=u[0])==null?void 0:ee.options)!=null&&Q.requireEmail?Jt:Vt}let K=O(o),P=K(o);return{c(){e=a("tr"),e.innerHTML='Authorization:TOKEN
header",h(e,"class","txt-hint txt-sm txt-right")},m(l,s){r(l,e,s)},d(l){l&&d(e)}}}function Tt(o){let e,l,s,b,p,c,f,v,T,w,M,g,D,E,L,I,j,R,S,N,q,C,_;function O(u,$){var ee,K;return(K=(ee=u[0])==null?void 0:ee.options)!=null&&K.requireEmail?Jt:Vt}let z=O(o),P=z(o);return{c(){e=a("tr"),e.innerHTML='Authorization:TOKEN
header",m(l,"class","txt-hint txt-sm txt-right")},m(s,a){f(s,l,a)},d(s){s&&u(l)}}}function ye(o,l){let s,a=l[6].code+"",v,i,r,p;function w(){return l[5](l[6])}return{key:o,first:null,c(){s=c("button"),v=$(a),i=h(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(b,g){f(b,s,g),n(s,v),n(s,i),r||(p=Se(s,"click",w),r=!0)},p(b,g){l=b,g&20&&N(s,"active",l[2]===l[6].code)},d(b){b&&u(s),r=!1,p()}}}function De(o,l){let s,a,v,i;return a=new qe({props:{content:l[6].body}}),{key:o,first:null,c(){s=c("div"),$e(a.$$.fragment),v=h(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(r,p){f(r,s,p),we(a,s,null),n(s,v),i=!0},p(r,p){l=r,(!i||p&20)&&N(s,"active",l[2]===l[6].code)},i(r){i||(ee(a.$$.fragment,r),i=!0)},o(r){te(a.$$.fragment,r),i=!1},d(r){r&&u(s),ge(a)}}}function Le(o){var ue,pe;let l,s,a=o[0].name+"",v,i,r,p,w,b,g,q=o[0].name+"",z,le,F,C,K,T,G,y,H,se,L,E,oe,J,U=o[0].name+"",Q,ae,V,ne,W,O,X,B,Y,I,Z,R,M,D=[],ie=new Map,re,A,_=[],ce=new Map,P;C=new He({props:{js:`
+import{S as Ce,i as Re,s as Pe,e as c,w as $,b as h,c as $e,f as m,g as f,h as n,m as we,x,N as _e,P as Ee,k as Te,Q as Be,n as Oe,t as ee,a as te,o as u,d as ge,T as Ie,C as Me,p as Ae,r as N,u as Se,M as qe}from"./index-077c413f.js";import{S as He}from"./SdkTabs-9bbe3355.js";function ke(o,l,s){const a=o.slice();return a[6]=l[s],a}function he(o,l,s){const a=o.slice();return a[6]=l[s],a}function ve(o){let l;return{c(){l=c("p"),l.innerHTML="Requires admin Authorization:TOKEN
header",m(l,"class","txt-hint txt-sm txt-right")},m(s,a){f(s,l,a)},d(s){s&&u(l)}}}function ye(o,l){let s,a=l[6].code+"",v,i,r,p;function w(){return l[5](l[6])}return{key:o,first:null,c(){s=c("button"),v=$(a),i=h(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(b,g){f(b,s,g),n(s,v),n(s,i),r||(p=Se(s,"click",w),r=!0)},p(b,g){l=b,g&20&&N(s,"active",l[2]===l[6].code)},d(b){b&&u(s),r=!1,p()}}}function De(o,l){let s,a,v,i;return a=new qe({props:{content:l[6].body}}),{key:o,first:null,c(){s=c("div"),$e(a.$$.fragment),v=h(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(r,p){f(r,s,p),we(a,s,null),n(s,v),i=!0},p(r,p){l=r,(!i||p&20)&&N(s,"active",l[2]===l[6].code)},i(r){i||(ee(a.$$.fragment,r),i=!0)},o(r){te(a.$$.fragment,r),i=!1},d(r){r&&u(s),ge(a)}}}function Le(o){var ue,pe;let l,s,a=o[0].name+"",v,i,r,p,w,b,g,q=o[0].name+"",z,le,F,C,K,T,Q,y,H,se,L,E,oe,G,U=o[0].name+"",J,ae,V,ne,W,B,X,O,Y,I,Z,R,M,D=[],ie=new Map,re,A,_=[],ce=new Map,P;C=new He({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${o[3]}');
@@ -14,12 +14,12 @@ import{S as Ce,i as Re,s as Pe,e as c,w as $,b as h,c as $e,f as m,g as f,h as n
...
await pb.collection('${(pe=o[0])==null?void 0:pe.name}').delete('RECORD_ID');
- `}});let k=o[1]&&ve(),j=o[4];const de=e=>e[6].code;for(let e=0;eOPERAND
OPERATOR
OPERAND
, where:`,o=s(),a=e("ul"),p=e("li"),p.innerHTML=`OPERAND
- could be any of the above field literal, string (single
or double quoted), number, null, true, false`,b=s(),d=e("li"),h=e("code"),h.textContent="OPERATOR",C=w(` - is one of:
- `),x=e("br"),_=s(),f=e("ul"),et=e("li"),kt=e("code"),kt.textContent="=",jt=s(),S=e("span"),S.textContent="Equal",Qt=s(),D=e("li"),ct=e("code"),ct.textContent="!=",O=s(),lt=e("span"),lt.textContent="NOT equal",le=s(),U=e("li"),j=e("code"),j.textContent=">",se=s(),dt=e("span"),dt.textContent="Greater than",vt=s(),st=e("li"),yt=e("code"),yt.textContent=">=",ne=s(),ft=e("span"),ft.textContent="Greater than or equal",pt=s(),nt=e("li"),E=e("code"),E.textContent="<",zt=s(),Ft=e("span"),Ft.textContent="Less than",T=s(),ot=e("li"),Lt=e("code"),Lt.textContent="<=",Jt=s(),At=e("span"),At.textContent="Less than or equal",Q=s(),it=e("li"),Tt=e("code"),Tt.textContent="~",Kt=s(),Pt=e("span"),Pt.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
- wildcard match)`,L=s(),ut=e("li"),Ot=e("code"),Ot.textContent="!~",oe=s(),mt=e("span"),mt.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
- wildcard match)`,ie=s(),H=e("li"),Rt=e("code"),Rt.textContent="?=",at=s(),St=e("em"),St.textContent="Any/At least one of",R=s(),bt=e("span"),bt.textContent="Equal",ae=s(),z=e("li"),Et=e("code"),Et.textContent="?!=",Vt=s(),Nt=e("em"),Nt.textContent="Any/At least one of",re=s(),N=e("span"),N.textContent="NOT equal",Wt=s(),J=e("li"),ht=e("code"),ht.textContent="?>",ce=s(),I=e("em"),I.textContent="Any/At least one of",de=s(),B=e("span"),B.textContent="Greater than",fe=s(),P=e("li"),qt=e("code"),qt.textContent="?>=",K=s(),_t=e("em"),_t.textContent="Any/At least one of",pe=s(),xt=e("span"),xt.textContent="Greater than or equal",ue=s(),$=e("li"),Mt=e("code"),Mt.textContent="?<",rt=s(),Dt=e("em"),Dt.textContent="Any/At least one of",me=s(),Ht=e("span"),Ht.textContent="Less than",Xt=s(),V=e("li"),wt=e("code"),wt.textContent="?<=",be=s(),It=e("em"),It.textContent="Any/At least one of",he=s(),Ct=e("span"),Ct.textContent="Less than or equal",_e=s(),G=e("li"),W=e("code"),W.textContent="?~",Yt=s(),q=e("em"),q.textContent="Any/At least one of",gt=s(),A=e("span"),A.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
+ `),x=e("br"),_=s(),f=e("ul"),et=e("li"),kt=e("code"),kt.textContent="=",jt=s(),S=e("span"),S.textContent="Equal",Qt=s(),D=e("li"),ct=e("code"),ct.textContent="!=",R=s(),lt=e("span"),lt.textContent="NOT equal",le=s(),U=e("li"),j=e("code"),j.textContent=">",se=s(),dt=e("span"),dt.textContent="Greater than",vt=s(),st=e("li"),yt=e("code"),yt.textContent=">=",ne=s(),ft=e("span"),ft.textContent="Greater than or equal",pt=s(),nt=e("li"),E=e("code"),E.textContent="<",zt=s(),Ft=e("span"),Ft.textContent="Less than",T=s(),ot=e("li"),Lt=e("code"),Lt.textContent="<=",Jt=s(),At=e("span"),At.textContent="Less than or equal",Q=s(),it=e("li"),Tt=e("code"),Tt.textContent="~",Kt=s(),Pt=e("span"),Pt.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
+ wildcard match)`,L=s(),ut=e("li"),Rt=e("code"),Rt.textContent="!~",oe=s(),mt=e("span"),mt.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
+ wildcard match)`,ie=s(),H=e("li"),Ot=e("code"),Ot.textContent="?=",at=s(),St=e("em"),St.textContent="Any/At least one of",O=s(),bt=e("span"),bt.textContent="Equal",ae=s(),z=e("li"),Et=e("code"),Et.textContent="?!=",Vt=s(),Nt=e("em"),Nt.textContent="Any/At least one of",re=s(),N=e("span"),N.textContent="NOT equal",Wt=s(),J=e("li"),ht=e("code"),ht.textContent="?>",ce=s(),I=e("em"),I.textContent="Any/At least one of",de=s(),B=e("span"),B.textContent="Greater than",fe=s(),P=e("li"),qt=e("code"),qt.textContent="?>=",K=s(),_t=e("em"),_t.textContent="Any/At least one of",pe=s(),xt=e("span"),xt.textContent="Greater than or equal",ue=s(),$=e("li"),Mt=e("code"),Mt.textContent="?<",rt=s(),Dt=e("em"),Dt.textContent="Any/At least one of",me=s(),Ht=e("span"),Ht.textContent="Less than",Xt=s(),V=e("li"),wt=e("code"),wt.textContent="?<=",be=s(),It=e("em"),It.textContent="Any/At least one of",he=s(),Ct=e("span"),Ct.textContent="Less than or equal",_e=s(),G=e("li"),W=e("code"),W.textContent="?~",Yt=s(),q=e("em"),q.textContent="Any/At least one of",gt=s(),A=e("span"),A.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
wildcard match)`,xe=s(),X=e("li"),Y=e("code"),Y.textContent="?!~",y=s(),Bt=e("em"),Bt.textContent="Any/At least one of",Z=s(),v=e("span"),v.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
wildcard match)`,tt=s(),k=e("p"),k.innerHTML=`To group and combine several expressions you could use brackets
- (...)
, &&
(AND) and ||
(OR) tokens.`,i(h,"class","txt-danger"),i(kt,"class","filter-op svelte-1w7s5nw"),i(S,"class","txt"),i(ct,"class","filter-op svelte-1w7s5nw"),i(lt,"class","txt"),i(j,"class","filter-op svelte-1w7s5nw"),i(dt,"class","txt"),i(yt,"class","filter-op svelte-1w7s5nw"),i(ft,"class","txt"),i(E,"class","filter-op svelte-1w7s5nw"),i(Ft,"class","txt"),i(Lt,"class","filter-op svelte-1w7s5nw"),i(At,"class","txt"),i(Tt,"class","filter-op svelte-1w7s5nw"),i(Pt,"class","txt"),i(Ot,"class","filter-op svelte-1w7s5nw"),i(mt,"class","txt"),i(Rt,"class","filter-op svelte-1w7s5nw"),i(St,"class","txt-hint"),i(bt,"class","txt"),i(Et,"class","filter-op svelte-1w7s5nw"),i(Nt,"class","txt-hint"),i(N,"class","txt"),i(ht,"class","filter-op svelte-1w7s5nw"),i(I,"class","txt-hint"),i(B,"class","txt"),i(qt,"class","filter-op svelte-1w7s5nw"),i(_t,"class","txt-hint"),i(xt,"class","txt"),i(Mt,"class","filter-op svelte-1w7s5nw"),i(Dt,"class","txt-hint"),i(Ht,"class","txt"),i(wt,"class","filter-op svelte-1w7s5nw"),i(It,"class","txt-hint"),i(Ct,"class","txt"),i(W,"class","filter-op svelte-1w7s5nw"),i(q,"class","txt-hint"),i(A,"class","txt"),i(Y,"class","filter-op svelte-1w7s5nw"),i(Bt,"class","txt-hint"),i(v,"class","txt")},m(F,$t){u(F,n,$t),u(F,o,$t),u(F,a,$t),t(a,p),t(a,b),t(a,d),t(d,h),t(d,C),t(d,x),t(d,_),t(d,f),t(f,et),t(et,kt),t(et,jt),t(et,S),t(f,Qt),t(f,D),t(D,ct),t(D,O),t(D,lt),t(f,le),t(f,U),t(U,j),t(U,se),t(U,dt),t(f,vt),t(f,st),t(st,yt),t(st,ne),t(st,ft),t(f,pt),t(f,nt),t(nt,E),t(nt,zt),t(nt,Ft),t(f,T),t(f,ot),t(ot,Lt),t(ot,Jt),t(ot,At),t(f,Q),t(f,it),t(it,Tt),t(it,Kt),t(it,Pt),t(f,L),t(f,ut),t(ut,Ot),t(ut,oe),t(ut,mt),t(f,ie),t(f,H),t(H,Rt),t(H,at),t(H,St),t(H,R),t(H,bt),t(f,ae),t(f,z),t(z,Et),t(z,Vt),t(z,Nt),t(z,re),t(z,N),t(f,Wt),t(f,J),t(J,ht),t(J,ce),t(J,I),t(J,de),t(J,B),t(f,fe),t(f,P),t(P,qt),t(P,K),t(P,_t),t(P,pe),t(P,xt),t(f,ue),t(f,$),t($,Mt),t($,rt),t($,Dt),t($,me),t($,Ht),t(f,Xt),t(f,V),t(V,wt),t(V,be),t(V,It),t(V,he),t(V,Ct),t(f,_e),t(f,G),t(G,W),t(G,Yt),t(G,q),t(G,gt),t(G,A),t(f,xe),t(f,X),t(X,Y),t(X,y),t(X,Bt),t(X,Z),t(X,v),u(F,tt,$t),u(F,k,$t)},d(F){F&&m(n),F&&m(o),F&&m(a),F&&m(tt),F&&m(k)}}}function cl(c){let n,o,a,p,b;function d(_,f){return _[0]?rl:al}let h=d(c),C=h(c),x=c[0]&&Ie();return{c(){n=e("button"),C.c(),o=s(),x&&x.c(),a=Ye(),i(n,"class","btn btn-sm btn-secondary m-t-10")},m(_,f){u(_,n,f),C.m(n,null),u(_,o,f),x&&x.m(_,f),u(_,a,f),p||(b=Xe(n,"click",c[1]),p=!0)},p(_,[f]){h!==(h=d(_))&&(C.d(1),C=h(_),C&&(C.c(),C.m(n,null))),_[0]?x||(x=Ie(),x.c(),x.m(a.parentNode,a)):x&&(x.d(1),x=null)},i:De,o:De,d(_){_&&m(n),C.d(),_&&m(o),x&&x.d(_),_&&m(a),p=!1,b()}}}function dl(c,n,o){let a=!1;function p(){o(0,a=!a)}return[a,p]}class fl extends Ke{constructor(n){super(),Ve(this,n,dl,cl,We,{})}}function Be(c,n,o){const a=c.slice();return a[7]=n[o],a}function Ge(c,n,o){const a=c.slice();return a[7]=n[o],a}function Ue(c,n,o){const a=c.slice();return a[12]=n[o],a[14]=o,a}function je(c){let n;return{c(){n=e("p"),n.innerHTML="Requires admin Authorization:TOKEN
header",i(n,"class","txt-hint txt-sm txt-right")},m(o,a){u(o,n,a)},d(o){o&&m(n)}}}function Qe(c){let n,o=c[12]+"",a,p=c[14]&&
(AND) and ||
(OR) tokens.`,i(h,"class","txt-danger"),i(kt,"class","filter-op svelte-1w7s5nw"),i(S,"class","txt"),i(ct,"class","filter-op svelte-1w7s5nw"),i(lt,"class","txt"),i(j,"class","filter-op svelte-1w7s5nw"),i(dt,"class","txt"),i(yt,"class","filter-op svelte-1w7s5nw"),i(ft,"class","txt"),i(E,"class","filter-op svelte-1w7s5nw"),i(Ft,"class","txt"),i(Lt,"class","filter-op svelte-1w7s5nw"),i(At,"class","txt"),i(Tt,"class","filter-op svelte-1w7s5nw"),i(Pt,"class","txt"),i(Rt,"class","filter-op svelte-1w7s5nw"),i(mt,"class","txt"),i(Ot,"class","filter-op svelte-1w7s5nw"),i(St,"class","txt-hint"),i(bt,"class","txt"),i(Et,"class","filter-op svelte-1w7s5nw"),i(Nt,"class","txt-hint"),i(N,"class","txt"),i(ht,"class","filter-op svelte-1w7s5nw"),i(I,"class","txt-hint"),i(B,"class","txt"),i(qt,"class","filter-op svelte-1w7s5nw"),i(_t,"class","txt-hint"),i(xt,"class","txt"),i(Mt,"class","filter-op svelte-1w7s5nw"),i(Dt,"class","txt-hint"),i(Ht,"class","txt"),i(wt,"class","filter-op svelte-1w7s5nw"),i(It,"class","txt-hint"),i(Ct,"class","txt"),i(W,"class","filter-op svelte-1w7s5nw"),i(q,"class","txt-hint"),i(A,"class","txt"),i(Y,"class","filter-op svelte-1w7s5nw"),i(Bt,"class","txt-hint"),i(v,"class","txt")},m(F,$t){u(F,n,$t),u(F,o,$t),u(F,a,$t),t(a,p),t(a,b),t(a,d),t(d,h),t(d,C),t(d,x),t(d,_),t(d,f),t(f,et),t(et,kt),t(et,jt),t(et,S),t(f,Qt),t(f,D),t(D,ct),t(D,R),t(D,lt),t(f,le),t(f,U),t(U,j),t(U,se),t(U,dt),t(f,vt),t(f,st),t(st,yt),t(st,ne),t(st,ft),t(f,pt),t(f,nt),t(nt,E),t(nt,zt),t(nt,Ft),t(f,T),t(f,ot),t(ot,Lt),t(ot,Jt),t(ot,At),t(f,Q),t(f,it),t(it,Tt),t(it,Kt),t(it,Pt),t(f,L),t(f,ut),t(ut,Rt),t(ut,oe),t(ut,mt),t(f,ie),t(f,H),t(H,Ot),t(H,at),t(H,St),t(H,O),t(H,bt),t(f,ae),t(f,z),t(z,Et),t(z,Vt),t(z,Nt),t(z,re),t(z,N),t(f,Wt),t(f,J),t(J,ht),t(J,ce),t(J,I),t(J,de),t(J,B),t(f,fe),t(f,P),t(P,qt),t(P,K),t(P,_t),t(P,pe),t(P,xt),t(f,ue),t(f,$),t($,Mt),t($,rt),t($,Dt),t($,me),t($,Ht),t(f,Xt),t(f,V),t(V,wt),t(V,be),t(V,It),t(V,he),t(V,Ct),t(f,_e),t(f,G),t(G,W),t(G,Yt),t(G,q),t(G,gt),t(G,A),t(f,xe),t(f,X),t(X,Y),t(X,y),t(X,Bt),t(X,Z),t(X,v),u(F,tt,$t),u(F,k,$t)},d(F){F&&m(n),F&&m(o),F&&m(a),F&&m(tt),F&&m(k)}}}function cl(c){let n,o,a,p,b;function d(_,f){return _[0]?rl:al}let h=d(c),C=h(c),x=c[0]&&Ie();return{c(){n=e("button"),C.c(),o=s(),x&&x.c(),a=Ye(),i(n,"class","btn btn-sm btn-secondary m-t-10")},m(_,f){u(_,n,f),C.m(n,null),u(_,o,f),x&&x.m(_,f),u(_,a,f),p||(b=Xe(n,"click",c[1]),p=!0)},p(_,[f]){h!==(h=d(_))&&(C.d(1),C=h(_),C&&(C.c(),C.m(n,null))),_[0]?x||(x=Ie(),x.c(),x.m(a.parentNode,a)):x&&(x.d(1),x=null)},i:De,o:De,d(_){_&&m(n),C.d(),_&&m(o),x&&x.d(_),_&&m(a),p=!1,b()}}}function dl(c,n,o){let a=!1;function p(){o(0,a=!a)}return[a,p]}class fl extends Ke{constructor(n){super(),Ve(this,n,dl,cl,We,{})}}function Be(c,n,o){const a=c.slice();return a[7]=n[o],a}function Ge(c,n,o){const a=c.slice();return a[7]=n[o],a}function Ue(c,n,o){const a=c.slice();return a[12]=n[o],a[14]=o,a}function je(c){let n;return{c(){n=e("p"),n.innerHTML="Requires admin Authorization:TOKEN
header",i(n,"class","txt-hint txt-sm txt-right")},m(o,a){u(o,n,a)},d(o){o&&m(n)}}}function Qe(c){let n,o=c[12]+"",a,p=c[14]Enter the email associated with your account and we’ll send you a recovery link:
`,n=g(),R(l.$$.fragment),t=g(),i=_("button"),f=_("i"),m=g(),o=_("span"),o.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(o,"class","txt"),p(i,"type","submit"),p(i,"class","btn btn-lg btn-block"),i.disabled=c[1],F(i,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),S(l,e,null),d(e,t),d(e,i),d(i,f),d(i,m),d(i,o),a=!0,b||(u=H(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(i.disabled=r[1]),(!a||$&2)&&F(i,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),E(l),b=!1,u()}}}function O(c){let e,s,n,l,t,i,f,m,o;return{c(){e=_("div"),s=_("div"),s.innerHTML='',n=g(),l=_("div"),t=_("p"),i=h("Check "),f=_("strong"),m=h(c[0]),o=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,i),d(t,f),d(f,m),d(t,o)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,i,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",i=c[5]),t.required=!0,t.autofocus=!0},m(o,a){k(o,e,a),d(e,s),k(o,l,a),k(o,t,a),L(t,c[0]),t.focus(),f||(m=H(t,"input",c[4]),f=!0)},p(o,a){a&32&&n!==(n=o[5])&&p(e,"for",n),a&32&&i!==(i=o[5])&&p(t,"id",i),a&1&&t.value!==o[0]&&L(t,o[0])},d(o){o&&v(e),o&&v(l),o&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,i,f,m;const o=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=o[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),i=!0,f||(m=A(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=o[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){i||(w(s),i=!0)},o(u){y(s),i=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new z({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){R(e.$$.fragment)},m(n,l){S(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){E(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function i(){if(!l){s(1,l=!0);try{await C.admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.error(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,i,f]}class Y extends M{constructor(e){super(),T(this,e,W,V,j,{})}}export{Y as default}; diff --git a/ui/dist/assets/PageAdminRequestPasswordReset-40671a2b.js b/ui/dist/assets/PageAdminRequestPasswordReset-40671a2b.js deleted file mode 100644 index 126b2fc8..00000000 --- a/ui/dist/assets/PageAdminRequestPasswordReset-40671a2b.js +++ /dev/null @@ -1,2 +0,0 @@ -import{S as M,i as T,s as j,F as z,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as A,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as E,v as I,w as h,x as J,y as P,z as R}from"./index-38223559.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`Enter the email associated with your account and we’ll send you a recovery link:
`,n=g(),H(l.$$.fragment),t=g(),o=_("button"),f=_("i"),m=g(),i=_("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(i,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=c[1],F(o,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),L(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=E(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(o.disabled=r[1]),(!a||$&2)&&F(o,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),S(l),b=!1,u()}}}function O(c){let e,s,n,l,t,o,f,m,i;return{c(){e=_("div"),s=_("div"),s.innerHTML='',n=g(),l=_("div"),t=_("p"),o=h("Check "),f=_("strong"),m=h(c[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,f),d(f,m),d(t,i)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,o,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",o=c[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),R(t,c[0]),t.focus(),f||(m=E(t,"input",c[4]),f=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&o!==(o=i[5])&&p(t,"id",o),a&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&v(e),i&&v(l),i&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,o,f,m;const i=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=i[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),o=!0,f||(m=A(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=i[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){o||(w(s),o=!0)},o(u){y(s),o=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new z({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){H(e.$$.fragment)},m(n,l){L(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){S(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.errorResponseHandler(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,o,f]}class Y extends M{constructor(e){super(),T(this,e,W,V,j,{})}}export{Y as default}; diff --git a/ui/dist/assets/PageOAuth2Redirect-c20f1fc5.js b/ui/dist/assets/PageOAuth2Redirect-455a84f6.js similarity index 87% rename from ui/dist/assets/PageOAuth2Redirect-c20f1fc5.js rename to ui/dist/assets/PageOAuth2Redirect-455a84f6.js index 2dc8422c..03c1121c 100644 --- a/ui/dist/assets/PageOAuth2Redirect-c20f1fc5.js +++ b/ui/dist/assets/PageOAuth2Redirect-455a84f6.js @@ -1,2 +1,2 @@ -import{S as o,i,s as c,e as r,f as l,g as u,y as s,o as d,H as h}from"./index-38223559.js";function f(n){let t;return{c(){t=r("div"),t.innerHTML=`Successfully changed the user email address.
+You can now sign in with your new email address.
Successfully changed the user email address.
-You can now sign in with your new email address.
Successfully changed the user password.
-You can now sign in with your new password.
Successfully changed the user password.
+You can now sign in with your new password.
Invalid or expired verification token.
Successfully verified email address.
Successfully verified email address.
google
, twitter
,
- github
, etc.Authorization:TOKEN
header",T(t,"class","txt-hint txt-sm txt-right")},m(l,s){a(l,t,s)},d(l){l&&o(t)}}}function kt(f){let t,l,s,b,u,d,p,k,C,w,O,R,A,j,M,E,B;return{c(){t=r("tr"),t.innerHTML='Authorization:TOKEN
header",T(t,"class","txt-hint txt-sm txt-right")},m(l,s){a(l,t,s)},d(l){l&&o(t)}}}function kt(f){let t,l,s,b,u,d,p,k,C,w,O,R,A,j,M,E,B;return{c(){t=r("tr"),t.innerHTML='Authorization:TOKEN
header",_(s,"class","txt-hint txt-sm txt-right")},m(n,a){r(n,s,a)},d(n){n&&d(s)}}}function We(i,s){let n,a=s[6].code+"",y,c,f,b;function F(){return s[5](s[6])}return{key:i,first:null,c(){n=o("button"),y=m(a),c=u(),_(n,"class","tab-item"),K(n,"active",s[2]===s[6].code),this.first=n},m(h,R){r(h,n,R),l(n,y),l(n,c),f||(b=rt(n,"click",F),f=!0)},p(h,R){s=h,R&20&&K(n,"active",s[2]===s[6].code)},d(h){h&&d(n),f=!1,b()}}}function Xe(i,s){let n,a,y,c;return a=new Ye({props:{content:s[6].body}}),{key:i,first:null,c(){n=o("div"),_e(a.$$.fragment),y=u(),_(n,"class","tab-item"),K(n,"active",s[2]===s[6].code),this.first=n},m(f,b){r(f,n,b),ke(a,n,null),l(n,y),c=!0},p(f,b){s=f,(!c||b&20)&&K(n,"active",s[2]===s[6].code)},i(f){c||(G(a.$$.fragment,f),c=!0)},o(f){J(a.$$.fragment,f),c=!1},d(f){f&&d(n),he(a)}}}function ct(i){var Ne,Ue;let s,n,a=i[0].name+"",y,c,f,b,F,h,R,N=i[0].name+"",Q,ve,W,g,X,B,Y,$,U,we,j,E,ye,Z,V=i[0].name+"",ee,$e,te,Ce,le,M,se,x,ne,A,oe,O,ie,Fe,ae,T,re,Re,de,ge,k,Oe,S,Te,De,Pe,ce,Ee,fe,Se,Be,Me,pe,xe,ue,I,be,D,H,C=[],Ae=new Map,Ie,q,v=[],He=new Map,P;g=new dt({props:{js:`
+import{S as Ze,i as et,s as tt,M as Ye,e as o,w as m,b as u,c as _e,f as _,g as r,h as l,m as ke,x as me,N as Ve,P as lt,k as st,Q as nt,n as ot,t as z,a as G,o as d,d as he,T as it,C as ze,p as at,r as J,u as rt}from"./index-077c413f.js";import{S as dt}from"./SdkTabs-9bbe3355.js";function Ge(i,s,n){const a=i.slice();return a[6]=s[n],a}function Je(i,s,n){const a=i.slice();return a[6]=s[n],a}function Ke(i){let s;return{c(){s=o("p"),s.innerHTML="Requires admin Authorization:TOKEN
header",_(s,"class","txt-hint txt-sm txt-right")},m(n,a){r(n,s,a)},d(n){n&&d(s)}}}function We(i,s){let n,a=s[6].code+"",y,c,f,b;function F(){return s[5](s[6])}return{key:i,first:null,c(){n=o("button"),y=m(a),c=u(),_(n,"class","tab-item"),J(n,"active",s[2]===s[6].code),this.first=n},m(h,R){r(h,n,R),l(n,y),l(n,c),f||(b=rt(n,"click",F),f=!0)},p(h,R){s=h,R&20&&J(n,"active",s[2]===s[6].code)},d(h){h&&d(n),f=!1,b()}}}function Xe(i,s){let n,a,y,c;return a=new Ye({props:{content:s[6].body}}),{key:i,first:null,c(){n=o("div"),_e(a.$$.fragment),y=u(),_(n,"class","tab-item"),J(n,"active",s[2]===s[6].code),this.first=n},m(f,b){r(f,n,b),ke(a,n,null),l(n,y),c=!0},p(f,b){s=f,(!c||b&20)&&J(n,"active",s[2]===s[6].code)},i(f){c||(z(a.$$.fragment,f),c=!0)},o(f){G(a.$$.fragment,f),c=!1},d(f){f&&d(n),he(a)}}}function ct(i){var Ne,Ue;let s,n,a=i[0].name+"",y,c,f,b,F,h,R,N=i[0].name+"",K,ve,W,g,X,B,Y,$,U,we,j,E,ye,Z,Q=i[0].name+"",ee,$e,te,Ce,le,M,se,x,ne,A,oe,O,ie,Fe,ae,T,re,Re,de,ge,k,Oe,S,Te,De,Pe,ce,Ee,fe,Se,Be,Me,pe,xe,ue,I,be,D,H,C=[],Ae=new Map,Ie,q,v=[],He=new Map,P;g=new dt({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${i[3]}');
@@ -18,7 +18,7 @@ import{S as Ze,i as et,s as tt,M as Ye,e as o,w as m,b as u,c as _e,f as _,g as
final record = await pb.collection('${(Ue=i[0])==null?void 0:Ue.name}').getOne('RECORD_ID',
expand: 'relField1,relField2.subRelField',
);
- `}});let w=i[1]&&Qe();S=new Ye({props:{content:"?expand=relField1,relField2.subRelField"}});let z=i[4];const qe=e=>e[6].code;for(let e=0;e