added superuser ips whitelist

This commit is contained in:
Gani Georgiev
2026-05-01 17:42:55 +03:00
parent fe2d90641c
commit 21a5524fed
34 changed files with 1224 additions and 47 deletions

View File

@@ -70,8 +70,10 @@ func backupDownload(e *core.RequestEvent) error {
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
}
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
@@ -79,6 +81,9 @@ func backupDownload(e *core.RequestEvent) error {
}
defer fsys.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys.SetContext(ctx)
key := e.Request.PathValue("key")

View File

@@ -528,6 +528,58 @@ func TestBackupsDownload(t *testing.T) {
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid superuser file token AND whitelisted IP",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
"storage/",
"data.db",
"auxiliary.db",
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid superuser file token BUT non-whitelisted IP",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {

View File

@@ -31,6 +31,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(panicRecover())
pbRouter.Bind(rateLimit())
pbRouter.Bind(loadAuthToken())
pbRouter.Bind(superuserIPsWhitelist())
pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))

View File

@@ -224,7 +224,7 @@ func TestBatchRequest(t *testing.T) {
},
},
{
Name: "mixed create/update/delete (rules failure)",
Name: "mixed create/update/delete (non-superuser rule failure)",
Method: http.MethodPost,
URL: "/api/batch",
Body: strings.NewReader(`{
@@ -284,6 +284,70 @@ func TestBatchRequest(t *testing.T) {
}
},
},
{
Name: "mixed create/update/delete (superuser rule failure)",
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, clients
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
Body: strings.NewReader(`{
"requests": [
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}},
{"method":"POST", "url":"/api/collections/_superusers/records", "body": {"email":"test_batch@example.com","password":"1234567890"}}
]
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"requests":{`,
`"2":{"code":"batch_request_failed"`,
`403`,
},
NotExpectedContent: []string{
`"0":`,
`"1":`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnBatchRequest": 1,
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateError": 1,
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteError": 1,
"OnModelValidate": 1,
"OnRecordUpdateRequest": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateError": 1,
"OnRecordDeleteRequest": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteError": 1,
"OnRecordEnrich": 1,
"OnRecordValidate": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err = app.FindRecordById("demo2", "achvryl401bhse3")
if err != nil {
t.Fatal("Expected record to not be deleted")
}
_, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
if err == nil {
t.Fatal("Expected record to not be updated")
}
_, err = app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test_batch@example.com")
if err == nil {
t.Fatal("Expected superuser to not be created")
}
},
},
{
Name: "mixed create/update/delete (rules success)",
Method: http.MethodPost,

View File

@@ -60,6 +60,7 @@ type fileApi struct {
}
func (api *fileApi) fileToken(e *core.RequestEvent) error {
// extra check for just in case the handler is called in a different context
if e.Auth == nil {
return e.UnauthorizedError("Missing auth context.", nil)
}
@@ -114,6 +115,15 @@ func (api *fileApi) download(e *core.RequestEvent) error {
token := e.Request.URL.Query().Get("token")
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
// reset the auth state if it is superuser and it is not whitelisted
// (not critical because file tokens are short-lived but checked nonetheless as an extra precaution)
if authRecord != nil && authRecord.IsSuperuser() {
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
authRecord = nil
}
}
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
requestInfo := *originalRequestInfo
requestInfo.Context = core.RequestInfoContextProtectedFile

View File

@@ -353,6 +353,50 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - superuser with non-whitelisted IP",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
err := app.Save(app.Settings())
if err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "protected file - superuser with whitelisted IP",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"*": 0,
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,

View File

@@ -42,6 +42,9 @@ const (
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
@@ -299,6 +302,28 @@ func securityHeaders() *hook.Handler[*core.RequestEvent] {
}
}
// superuserIPsWhitelist middleware checks the current authenticated superuser IP
// against the configured SuperuserIPs whitelist setting.
//
// This middleware is registered by default for all routes.
func superuserIPsWhitelist() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: DefaultSuperuserIPsWhitelistMiddlewareId,
Priority: DefaultSuperuserIPsWhitelistMiddlewarePriority,
Func: func(e *core.RequestEvent) error {
if e.HasSuperuserAuth() {
ips := e.App.Settings().SuperuserIPs
if len(ips) > 0 && !isIPInList(ips, e.RealIP()) {
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
}
}
return e.Next()
},
}
}
// SkipSuccessActivityLog is a helper middleware that instructs the global
// activity logger to log only requests that have failed/returned an error.
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {

View File

@@ -2,6 +2,7 @@ package apis
import (
"errors"
"net/netip"
"sync"
"time"
@@ -106,6 +107,41 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
return nil
}
// isIPInList checks if the specified IP is in a list of other individual IPs or subnets.
func isIPInList(ipsOrSubnets []string, ip string) bool {
if ip == "" || len(ipsOrSubnets) == 0 {
return false
}
// normalize
searchAddr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
for _, item := range ipsOrSubnets {
// subnet?
prefix, err := netip.ParsePrefix(item)
if err == nil {
if prefix.Contains(searchAddr) {
return true
}
continue
}
// individual ip?
addr, err := netip.ParseAddr(item)
if err == nil {
if addr == searchAddr {
return true
}
continue
}
}
return false
}
// -------------------------------------------------------------------
// @todo consider exporting as helper?

View File

@@ -553,3 +553,96 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
scenario.Test(t)
}
}
func TestSuperuserIPsWhitelist(t *testing.T) {
t.Parallel()
setupWhitelist := func(superuserIPs ...string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// allow loading a mock IP from the test scenario
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = superuserIPs
err := app.Save(app.Settings())
if err != nil {
t.Fatal(err)
}
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
})
}
}
scenarios := []tests.ApiScenario{
{
Name: "guest with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "regular user with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist("0.0.0.0", "127.0.0.1"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with no whitelisted IPs",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist(),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -43,6 +43,13 @@ func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMetho
}
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error {
if authRecord.IsSuperuser() {
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
}
}
originalRequestInfo, err := e.RequestInfo()
if err != nil {
return err

View File

@@ -759,3 +759,39 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
}
})
}
func TestRecordAuthResponseSuperuserIPsWhitelistCheck(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
event := new(core.RequestEvent)
event.App = app
event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
event.Request.Header.Set("x-test-ip", "127.0.0.1")
event.Response = httptest.NewRecorder()
t.Run("non-whitelisted", func(t *testing.T) {
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
err = apis.RecordAuthResponse(event, superuser, "example", nil)
if err == nil {
t.Fatal("Expected response error, got nil")
}
})
t.Run("whitelisted", func(t *testing.T) {
app.Settings().SuperuserIPs = []string{"0.0.0.0", "127.0.0.1"}
err = apis.RecordAuthResponse(event, superuser, "example", nil)
if err != nil {
t.Fatal(err)
}
})
}

View File

@@ -24,6 +24,7 @@ func NewSuperuserCommand(app core.App) *cobra.Command {
command.AddCommand(superuserUpdateCommand(app))
command.AddCommand(superuserDeleteCommand(app))
command.AddCommand(superuserOTPCommand(app))
command.AddCommand(superuserIPsCommand(app))
return command
}
@@ -209,3 +210,38 @@ func superuserOTPCommand(app core.App) *cobra.Command {
return command
}
func superuserIPsCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "ips",
Example: "superuser ips 127.0.0.1 10.0.0.0/24",
Short: "Updates the superuser IPs whitelist setting (the IPs/subnets arguments must be space separated; leave empty to clear the whitelist restriction)",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
settings := app.Settings()
settings.SuperuserIPs = args
if err := app.Save(settings); err != nil {
return err
}
if len(args) == 0 {
color.Green("Successfully cleared SuperuserIPs setting!")
} else {
color.New(color.BgGreen, color.FgBlack).Println("Successfully updated SuperuserIPs setting:")
superuserIPs := app.Settings().SuperuserIPs
for i, ip := range superuserIPs {
if i == len(superuserIPs)-1 {
color.Green("└─ %s", ip)
} else {
color.Green("├─ %s", ip)
}
}
}
return nil
},
}
return command
}

View File

@@ -1,6 +1,7 @@
package cmd_test
import (
"slices"
"testing"
"github.com/pocketbase/pocketbase/cmd"
@@ -401,3 +402,63 @@ func TestSuperuserOTPCommand(t *testing.T) {
})
}
}
func TestSuperuserIPsCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
ips []string
expectError bool
}{
{
"no ips",
nil,
false,
},
{
"invalid ips",
[]string{"127.0.0.1", "invalid"},
true,
},
{
"valid ips",
[]string{"127.0.0.1", "::1", "127.0.0.1/24"},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
args := []string{"ips"}
args = append(args, s.ips...)
command := cmd.NewSuperuserCommand(app)
command.SetArgs(args)
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
settingIPs := app.Settings().SuperuserIPs
if len(settingIPs) != len(s.ips) {
t.Fatalf("Expected %d ips, got %d (%v)", len(s.ips), len(settingIPs), settingIPs)
}
for _, ip := range settingIPs {
if !slices.Contains(s.ips, ip) {
t.Fatalf("Missing expected ip %q (%v)", ip, settingIPs)
}
}
})
}
}

View File

@@ -38,8 +38,9 @@ const (
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
LocalNotifyDirName string = ".notify" // optional watched directory that is used as a cross-platform workaround for synchronizing various runtime states between multiple PocketBase instances pointing to the same pb_data
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
// @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found"
@@ -1382,6 +1383,7 @@ func (app *BaseApp) registerBaseHooks() {
app.registerMFAHooks()
app.registerOTPHooks()
app.registerAuthOriginHooks()
app.registerNotifyWatcherHooks()
}
// getLoggerMinLevel returns the logger min level based on the

210
core/notify_watcher.go Normal file
View File

@@ -0,0 +1,210 @@
package core
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/security"
)
const systemHookIdNotifyWatcher = "__pbNotifyWatcherSystemHook__"
func (app *BaseApp) registerNotifyWatcherHooks() {
var notifyWatcher *fsnotify.Watcher
instanceId := "@" + security.PseudorandomString(10)
localNotifyDirPath := filepath.Join(app.DataDir(), LocalNotifyDirName)
settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
// init
app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
if err != nil {
e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
}
return nil
},
Priority: -998,
})
// cleanup
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *TerminateEvent) error {
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
_ = os.Remove(settingsFile)
_ = os.Remove(collectionsFile)
return e.Next()
},
Priority: -998,
})
// ---------------------------------------------------------------
settingsNotify := func(e *ModelEvent) error {
err := e.Next()
if err != nil || e.Model.PK() != paramsKeySettings {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(settingsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
}
_ = os.Remove(settingsFile)
}
return nil
}
app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
// ---------------------------------------------------------------
collectionsNotify := func(e *CollectionEvent) error {
if err := e.Next(); err != nil {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(collectionsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
}
_ = os.Remove(collectionsFile)
}
return nil
}
app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
}
func createNotifyDirWatcher(app App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
// create the notify dir (if not already)
err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
if err != nil {
return nil, fmt.Errorf("failed to create a notify dir: %w", err)
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
}
err = watcher.Add(localNotifyDirPath)
if err != nil {
_ = watcher.Close()
return nil, fmt.Errorf("unable to watch notify dir: %w", err)
}
var debounceTimer *time.Timer
stopDebounceTimer := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
}
// watch
go func() {
defer stopDebounceTimer()
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// modified from within the current app instance or cleanup event
if strings.HasSuffix(event.Name, instanceId) || event.Has(fsnotify.Remove) || !app.IsBootstrapped() {
continue
}
stopDebounceTimer()
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
filename := filepath.Base(event.Name)
// settings changed
if strings.HasPrefix(filename, "settings@") {
app.Logger().Debug("Reloading settings after notify event")
err := app.ReloadSettings()
if err != nil {
app.Logger().Warn("Failed to reload app settings after notify", "error", err)
}
return
}
// collections changed
if strings.HasPrefix(filename, "collections@") {
app.Logger().Debug("Reloading cached collections after notify event")
err := app.ReloadCachedCollections()
if err != nil {
app.Logger().Warn("Failed to reload cached collections after notify", "error", err)
}
return
}
})
case err, ok := <-watcher.Errors:
if app.IsDev() && err != nil {
color.Red("Notify dir watch error:", err)
}
if !ok {
return
}
}
}
}()
return watcher, err
}

185
core/notify_watcher_test.go Normal file
View File

@@ -0,0 +1,185 @@
package core_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/sync/semaphore"
)
func TestNotifyWatcher_SettingsUpdate(t *testing.T) {
t.Parallel()
testEvents := store.New[core.App, int](nil)
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
app1 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app1.Bootstrap(); err != nil {
t.Fatal(err)
}
app2 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app2.Bootstrap(); err != nil {
t.Fatal(err)
}
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelCtx()
sem := semaphore.NewWeighted(1)
sem.Acquire(ctx, 1)
app1.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
testEvents.SetFunc(app1, func(old int) int {
return old + 1
})
return e.Next()
})
app2.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
testEvents.SetFunc(app2, func(old int) int {
sem.Release(1)
return old + 1
})
return e.Next()
})
// updating app1 settings should trigger a reload in app2
app1.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app1.Save(app1.Settings()); err != nil {
t.Fatal(err)
}
// block until released or timeouted
sem.Acquire(ctx, 1)
if app1Total := testEvents.Get(app1); app1Total != 1 {
t.Fatalf("Expected 1 app1 event, got %d", app1Total)
}
if app2Total := testEvents.Get(app2); app2Total != 1 {
t.Fatalf("Expected 1 app2 event, got %d", app2Total)
}
app2SuperuserIPs := app2.Settings().SuperuserIPs
if len(app2SuperuserIPs) != 1 || app2SuperuserIPs[0] != "127.0.0.1" {
t.Fatalf("Expected exactly 127.0.0.1 superuser IP in app2 settings event, got %v", app2SuperuserIPs)
}
}
func TestNotifyWatcher_CollectionsUpdate(t *testing.T) {
t.Parallel()
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
app1 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app1.Bootstrap(); err != nil {
t.Fatal(err)
}
app2 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app2.Bootstrap(); err != nil {
t.Fatal(err)
}
testQueries := store.New[string, []string](nil)
app2.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
testQueries.SetFunc("concurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
testQueries.SetFunc("concurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
return append(old, sql)
})
}
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelCtx()
sem := semaphore.NewWeighted(1)
sem.Acquire(ctx, 1)
// currently there is no hook for the collections cache reload so we pool instead
done := make(chan bool, 1)
ticker := time.NewTicker(100 * time.Millisecond)
go func() {
for {
select {
case <-ticker.C:
if len(testQueries.Get("concurrent")) == 1 {
sem.Release(1)
return
}
case <-done:
return
}
}
}()
// create/update/delete app1 collections should trigger a reload in app2
dummyCollection := core.NewBaseCollection("test")
if err := app1.Save(dummyCollection); err != nil {
t.Fatal(err)
}
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
if err := app1.Save(dummyCollection); err != nil {
t.Fatal(err)
}
if err := app1.Delete(dummyCollection); err != nil {
}
// block until released or timeouted
sem.Acquire(ctx, 1)
ticker.Stop()
done <- true
nonconcurrentQueries := testQueries.Get("nonconcurrent")
concurrentQueries := testQueries.Get("concurrent")
if len(nonconcurrentQueries) != 0 {
t.Fatalf("Expected 0 concurrent queries, got %d (%v)", len(nonconcurrentQueries), nonconcurrentQueries)
}
if len(concurrentQueries) != 1 {
t.Fatalf("Expected 1 concurrent query, got %d (%v)", len(concurrentQueries), concurrentQueries)
}
expectedQuery := "SELECT {{_collections}}.* FROM `_collections` ORDER BY `rowid` ASC"
if concurrentQueries[0] != expectedQuery {
t.Fatalf("Expected query\n%s\ngot\n%s", expectedQuery, concurrentQueries[0])
}
}

View File

@@ -120,6 +120,10 @@ var (
)
type settings struct {
// SuperuserIPs defines an optional list of the superuser allowed
// individual IPs and subnets (in CIDR notation).
SuperuserIPs []string `form:"superuserIPs" json:"superuserIPs"`
SMTP SMTPConfig `form:"smtp" json:"smtp"`
Backups BackupsConfig `form:"backups" json:"backups"`
S3 S3Config `form:"s3" json:"s3"`
@@ -253,6 +257,12 @@ func (s *Settings) DBExport(app App) (map[string]any, error) {
}
result["updated"] = now
// @todo remove with encoding/json/2
// serialize as empty array
if s.settings.SuperuserIPs == nil {
s.settings.SuperuserIPs = []string{}
}
encoded, err := json.Marshal(s.settings)
if err != nil {
return nil, err
@@ -280,6 +290,7 @@ func (s *Settings) PostValidate(ctx context.Context, app App) error {
defer s.mu.RUnlock()
return validation.ValidateStructWithContext(ctx, s,
validation.Field(&s.SuperuserIPs, validation.Each(validation.By(validators.IPOrSubnet))),
validation.Field(&s.Meta),
validation.Field(&s.Logs),
validation.Field(&s.SMTP),
@@ -343,6 +354,12 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
}
}
// @todo remove with encoding/json/2
// serialize as empty array
if copy.SuperuserIPs == nil {
copy.SuperuserIPs = []string{}
}
return json.Marshal(copy)
}

View File

@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
valueStr = string(export["value"].([]byte))
}
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
}
@@ -180,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
}
rawStr := string(raw)
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -196,6 +196,7 @@ func TestSettingsValidate(t *testing.T) {
s := app.Settings()
// set invalid settings data
s.SuperuserIPs = []string{"127.0.0.1", "invalid"}
s.Meta.AppName = ""
s.Logs.MaxDays = -10
s.SMTP.Enabled = true
@@ -217,6 +218,7 @@ func TestSettingsValidate(t *testing.T) {
}
expectations := []string{
`"superuserIPs":{`,
`"meta":{`,
`"logs":{`,
`"smtp":{`,

View File

@@ -31,7 +31,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
"validation_file_size_limit",
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
).SetParams(map[string]any{
"file": v.OriginalName,
"file": cutStr(v.OriginalName, 300),
"maxSize": maxBytes,
})
}
@@ -60,7 +60,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
baseErr := validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
fmt.Sprintf("Failed to upload %q due to unsupported file type.", cutStr(v.OriginalName, 300)),
)
if len(validTypes) == 0 {

View File

@@ -1,6 +1,7 @@
package validators
import (
"net/netip"
"regexp"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -27,3 +28,30 @@ func IsRegex(value any) error {
return nil
}
// IPOrSubnet checks whether the validated value is an individual
// IPv4/IPv6 or CIDR subnet.
func IPOrSubnet(value any) error {
v, ok := value.(string)
if !ok {
return ErrUnsupportedValueType
}
if v == "" {
return nil // nothing to check
}
// subnet
_, err := netip.ParsePrefix(v)
if err == nil {
return nil
}
// individual IP
_, err = netip.ParseAddr(v)
if err == nil {
return nil
}
return validation.NewError("validation_invlaid_ip_or_subnet", "invalid IP or CIDR subnet")
}

View File

@@ -31,3 +31,32 @@ func TestIsRegex(t *testing.T) {
})
}
}
func TestIPOrSubnet(t *testing.T) {
t.Parallel()
scenarios := []struct {
val string
expectError bool
}{
{"", false},
{`invalid`, true},
{`127.0`, true}, // incomplete
{`127.0.0.1`, false},
{`::1`, false},
{`0000:0000:0000:0000:0000:0000:0000:0001`, false},
{`127.0.0.1/24`, false},
{`::/128`, false},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) {
err := validators.IPOrSubnet(s.val)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}

View File

@@ -38,3 +38,10 @@ func JoinValidationErrors(errA, errB error) error {
return errors.Join(errA, errB)
}
func cutStr(str string, max int) string {
if len(str) > max {
return str[:max] + "..."
}
return str
}

27
ui/.env
View File

@@ -1,14 +1,15 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.6-dev"
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_SUPERUSER_IPS_RESET_DOCS = "https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets"
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.37.6-dev"

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

4
ui/dist/index.html vendored
View File

@@ -13,9 +13,9 @@
<!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-CsbGEmKA.js"></script>
<script type="module" crossorigin src="./assets/index-B5eik2f8.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-e0-DEGQo.css">
<link rel="stylesheet" crossorigin href="./assets/index-ZC1aK-6Z.css">
</head>
<body>
</body>

View File

@@ -832,7 +832,7 @@ function deleteDropdownItem(data, modalSettings) {
try {
await app.pb.collections.delete(data.originalCollection.name);
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
modalSettings?.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);

View File

@@ -513,7 +513,9 @@ hr {
text-decoration: none;
border-radius: var(--borderRadius);
color: var(--linkColor);
transition: color var(--animationSpeed);
transition:
color var(--animationSpeed),
opacity var(--animationSpeed);
user-select: none;
&:hover,
&:focus-visible {

View File

@@ -235,12 +235,4 @@ window.app.checkApiError = function(err, showToast = true) {
app.pb.cancelAllRequests();
return app.pb.authStore.clear();
}
// forbidden
if (statusCode === 403) {
app.pb.cancelAllRequests();
if (window.location.hash != LOGIN_PATH) {
window.location.hash = LOGIN_PATH;
}
}
};

View File

@@ -8,7 +8,7 @@ export function batchAccordion(pageData) {
t.summary(
null,
t.i({ className: "ri-archive-stack-line", ariaHidden: true }),
t.span({ className: "txt" }, "Batch API"),
t.span({ className: "txt" }, "Batch Web API"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings.batch.enabled) {

View File

@@ -1,6 +1,7 @@
import { settingsSidebar } from "../settingsSidebar";
import { batchAccordion } from "./batchAccordion";
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
import { superuserAccordion } from "./superuserAccordion";
import { trustedProxyAccordion } from "./trustedProxyAccordion";
export function pageApplicationSettings() {
@@ -37,6 +38,53 @@ export function pageApplicationSettings() {
}
}
function hasSuperuserIPsChanged() {
return JSON.stringify(data.formSettings?.superuserIPs)
!= JSON.stringify(data.originalFormSettings?.superuserIPs);
}
async function saveWithConfirm() {
const superuserIPs = app.utils.toArray(data.formSettings?.superuserIPs);
if (!superuserIPs.length) {
return save();
}
return app.modals.confirm(
t.div(
{ className: "txt-center" },
t.h6(
null,
"The ONLY allowed superuser IPs will change to: ",
t.br(),
t.strong(null, superuserIPs.join(", ")),
),
t.p(null, "Please make sure that your IP is in the list or you'll be locked."),
t.p(
{ className: "txt-hint" },
"In case of lockout, you can reset the setting with the ",
t.a(
{
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
target: "_blank",
rel: "noopener noreferrer",
className: "link-primary txt-bold txt-sm",
},
t.code(
null,
"superuser ips",
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
),
),
" console command.",
),
),
() => save(),
null,
{ yesButton: "Yes, save changes" },
);
}
async function save() {
if (data.isSaving || !data.hasChanges) {
return;
@@ -48,8 +96,19 @@ export function pageApplicationSettings() {
try {
const redacted = app.utils.filterRedactedProps(data.formSettings);
const settings = await app.pb.settings.update(redacted);
init(settings);
const updatedSettings = await app.pb.settings.update(redacted);
// reauthenticate to ensure that the superuser has still access
if (hasSuperuserIPsChanged()) {
try {
await app.pb.collection("_superusers").authRefresh();
} catch (_) {
app.pb.authStore.clear();
}
}
init(updatedSettings);
app.toasts.success("Successfully saved application settings.");
} catch (err) {
@@ -73,6 +132,7 @@ export function pageApplicationSettings() {
}
data.originalFormSettings = {
superuserIPs: settings.superuserIPs || [],
meta: settings.meta || {},
batch: settings.batch || {},
trustedProxy: settings.trustedProxy || { headers: [] },
@@ -118,7 +178,7 @@ export function pageApplicationSettings() {
inert: () => data.isSaving,
onsubmit: (e) => {
e.preventDefault();
save();
saveWithConfirm();
},
},
t.div(
@@ -158,9 +218,10 @@ export function pageApplicationSettings() {
),
t.div(
{ className: "col-lg-12" },
() => batchAccordion(data),
() => trustedProxyAccordion(data),
() => rateLimitAccordion(data),
() => batchAccordion(data),
() => superuserAccordion(data),
),
t.div(
{ className: "col-lg-12" },

View File

@@ -249,7 +249,6 @@ export function rateLimitAccordion(pageData) {
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span({ className: "txt" }, "Enable"),
t.small({ className: "txt-hint" }, " (experimental)"),
),
),
),

View File

@@ -0,0 +1,158 @@
export function superuserAccordion(pageData) {
const info = store({
isLoading: false,
realIP: "",
});
async function loadInfo() {
info.isLoading = true;
try {
const health = await app.pb.health.check({ requestKey: "loadSuperuserIPsInfo" });
info.realIP = health.data?.realIP || "";
info.isLoading = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
info.isLoading = false;
}
}
}
return t.details(
{
pbEvent: "superuserAccordion",
className: "accordion superuser-accordion",
name: "settingsAccordion",
onmount: (el) => {
el._ipwatcher?.unwatch();
el._ipwatcher = watch(
() => JSON.stringify(app.store.settings?.trustedProxy?.headers),
(newHash, oldHash) => {
if (newHash != oldHash) {
loadInfo();
}
},
);
},
onunmount: (el) => {
el._ipwatcher?.unwatch();
},
},
t.summary(
null,
t.i({ className: "ri-fingerprint-2-line", ariaHidden: true }),
t.span({ className: "txt" }, "Superuser IPs"),
t.div({ className: "flex-fill" }),
() => {
if (pageData.formSettings?.superuserIPs?.length) {
return t.span({ className: "label success" }, "Enabled");
}
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.batch)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
}
},
),
t.div(
{ className: "content m-b-sm" },
t.p(null, "A comma separated list of superusers allowed IPs and subnets."),
t.p(
null,
"Enabling this option greatly helps hardening the security of your application because even if someone manage to get their hands on a superuser auth token they will not be able to use it.",
),
t.p(
null,
"In case your IP changes, you can always reset the field value with the ",
t.a(
{
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
target: "_blank",
rel: "noopener noreferrer",
className: "link-primary txt-bold txt-sm",
},
t.code(
null,
"superuser ips",
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
),
),
" console command.",
),
),
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "superuserIPs" },
t.span({ className: "txt" }, "Superuser IPs and subnets"),
),
t.input({
id: "superuserIPs",
name: "superuserIPs",
type: "text",
placeholder: "Leave empty for no restriction",
value: () => app.utils.joinNonEmpty(pageData.formSettings.superuserIPs),
oninput: (e) => {
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
const newStr = app.utils.joinNonEmpty(newValue);
const oldStr = app.utils.joinNonEmpty(pageData.formSettings.superuserIPs);
// has an actual change
if (oldStr != newStr) {
pageData.formSettings.superuserIPs = newValue;
}
},
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
className: () =>
`btn sm secondary transparent ${
app.utils.isEmpty(pageData.formSettings.superuserIPs) ? "hidden" : ""
}`,
onclick: () => {
pageData.formSettings.superuserIPs = [];
if (app.store.errors?.superuserIPs) {
delete app.store.errors.superuserIPs;
}
},
},
t.span({ className: "txt" }, "Clear"),
),
),
),
t.div(
{ className: "field-help" },
"Comma separated list of IPs and subnets such as: ",
t.div(
{ className: "inline-flex gap-5" },
t.div({
role: "button",
className: "label sm link-primary",
onclick: () => {
if (info.isLoading) {
return;
}
const ips = app.utils.toArray(pageData.formSettings.superuserIPs);
app.utils.pushUnique(ips, info.realIP);
pageData.formSettings.superuserIPs = ips;
},
textContent: () => info.isLoading ? "..." : (info.realIP + " (you)"),
}),
),
),
);
}

View File

@@ -375,7 +375,20 @@ watch(
removeErrorState(input, container);
const errMsg = app.utils.getByPath(errs, name)?.message;
const errData = app.utils.getByPath(errs, name);
let errMsg = errData?.message || "";
// merge one level nested errors into a single message
if (!errMsg && !app.utils.isEmpty(errData)) {
const combinedErrs = [];
for (let key in errData) {
if (errData[key]?.message) {
combinedErrs.push(`${key}: ${errData[key]?.message}`);
}
}
errMsg = combinedErrs.join("\n");
}
if (!errMsg) {
continue;
}