From 64c3e3b3c573732ab33e22d5b52e80c0bea40d60 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Tue, 4 Apr 2023 20:33:35 +0300 Subject: [PATCH] [#215] added server-side handlers for serving private files --- apis/collection_test.go | 4 +- apis/file.go | 132 +++++++++++++++++++++- apis/file_test.go | 170 +++++++++++++++++++++++++++++ apis/realtime.go | 2 +- apis/realtime_test.go | 20 +++- core/app.go | 19 ++++ core/base.go | 55 ++++++++-- core/events.go | 45 +++++--- forms/collections_import_test.go | 2 +- models/schema/schema_field.go | 1 + models/schema/schema_field_test.go | 2 +- models/settings/settings.go | 24 +++- models/settings/settings_test.go | 4 + tests/api.go | 1 + tests/app.go | 8 ++ tests/data/data.db | Bin 241664 -> 249856 bytes tests/data/logs.db | Bin 40960 -> 69632 bytes tokens/admin.go | 9 ++ tokens/admin_test.go | 23 ++++ tokens/record.go | 17 +++ tokens/record_test.go | 23 ++++ 21 files changed, 519 insertions(+), 42 deletions(-) diff --git a/apis/collection_test.go b/apis/collection_test.go index 6d07189f..746fe7d4 100644 --- a/apis/collection_test.go +++ b/apis/collection_test.go @@ -1097,7 +1097,7 @@ func TestCollectionUpdate(t *testing.T) { } } -func TestCollectionImport(t *testing.T) { +func TestCollectionsImport(t *testing.T) { totalCollections := 10 scenarios := []tests.ApiScenario{ @@ -1157,7 +1157,7 @@ func TestCollectionImport(t *testing.T) { }, ExpectedEvents: map[string]int{ "OnCollectionsBeforeImportRequest": 1, - "OnModelBeforeDelete": 7, + "OnModelBeforeDelete": 4, }, AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { collections := []*models.Collection{} diff --git a/apis/file.go b/apis/file.go index 43d152b6..fc9e239d 100644 --- a/apis/file.go +++ b/apis/file.go @@ -1,13 +1,23 @@ package apis import ( + "errors" "fmt" + "log" + "net/http" + "strings" "github.com/labstack/echo/v5" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/resolvers" + "github.com/pocketbase/pocketbase/tokens" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/spf13/cast" ) var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"} @@ -18,6 +28,7 @@ func bindFileApi(app core.App, rg *echo.Group) { api := fileApi{app: app} subGroup := rg.Group("/files", ActivityLogger(app)) + subGroup.POST("/token", api.fileToken, RequireAdminOrRecordAuth()) subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app)) subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app)) } @@ -26,6 +37,37 @@ type fileApi struct { app core.App } +func (api *fileApi) fileToken(c echo.Context) error { + event := new(core.FileTokenEvent) + event.HttpContext = c + + if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil { + event.Model = admin + event.Token, _ = tokens.NewAdminFileToken(api.app, admin) + } else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil { + event.Model = record + event.Token, _ = tokens.NewRecordFileToken(api.app, record) + } + + handlerErr := api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error { + if e.Token == "" { + return NewBadRequestError("Failed to generate file token.", nil) + } + + return e.HttpContext.JSON(http.StatusOK, map[string]string{ + "token": e.Token, + }) + }) + + if handlerErr == nil { + if err := api.app.OnFileAfterTokenRequest().Trigger(event); err != nil && api.app.IsDebug() { + log.Println(err) + } + } + + return handlerErr +} + func (api *fileApi) download(c echo.Context) error { collection, _ := c.Get(ContextCollectionKey).(*models.Collection) if collection == nil { @@ -49,7 +91,21 @@ func (api *fileApi) download(c echo.Context) error { return NewNotFoundError("", nil) } - options, _ := fileField.Options.(*schema.FileOptions) + options, ok := fileField.Options.(*schema.FileOptions) + if !ok { + return NewBadRequestError("", errors.New("Failed to load file options.")) + } + + // check whether the request is authorized to view the private file + if options.Private { + token := c.QueryParam("token") + + adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token) + + if !api.canAccessRecord(adminOrAuthRecord, record, record.Collection().ViewRule) { + return NewForbiddenError("Invalid file token or unsufficient permissions to access the resource.", nil) + } + } baseFilesPath := record.BaseFilesPath() @@ -119,3 +175,77 @@ func (api *fileApi) download(c echo.Context) error { return nil }) } + +func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) { + fileToken = strings.TrimSpace(fileToken) + if fileToken == "" { + return nil, errors.New("missing file token") + } + + claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken)) + tokenType := cast.ToString(claims["type"]) + + switch tokenType { + case tokens.TypeAdmin: + admin, err := api.app.Dao().FindAdminByToken( + fileToken, + api.app.Settings().AdminFileToken.Secret, + ) + if err == nil && admin != nil { + return admin, nil + } + case tokens.TypeAuthRecord: + record, err := api.app.Dao().FindAuthRecordByToken( + fileToken, + api.app.Settings().RecordFileToken.Secret, + ) + if err == nil && record != nil { + return record, nil + } + } + + return nil, errors.New("missing or invalid file token") +} + +// @todo move to a helper and maybe combine with the realtime checks when refactoring the realtime service +func (api *fileApi) canAccessRecord(adminOrAuthRecord models.Model, record *models.Record, accessRule *string) bool { + admin, _ := adminOrAuthRecord.(*models.Admin) + if admin != nil { + // admins can access everything + return true + } + + if accessRule == nil { + // only admins can access this record + return false + } + + ruleFunc := func(q *dbx.SelectQuery) error { + if *accessRule == "" { + return nil // empty public rule + } + + // mock request data + requestData := &models.RequestData{ + Method: "GET", + } + requestData.AuthRecord, _ = adminOrAuthRecord.(*models.Record) + + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true) + expr, err := search.FilterData(*accessRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + + return nil + } + + foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc) + if err == nil && foundRecord != nil { + return true + } + + return false +} diff --git a/apis/file_test.go b/apis/file_test.go index 24ea9b45..9b7158aa 100644 --- a/apis/file_test.go +++ b/apis/file_test.go @@ -8,9 +8,60 @@ import ( "runtime" "testing" + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" ) +func TestFileToken(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPost, + Url: "/api/files/token", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "auth record", + Method: http.MethodPost, + Url: "/api/files/token", + RequestHeaders: map[string]string{ + "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":"`, + }, + ExpectedEvents: map[string]int{ + "OnFileBeforeTokenRequest": 1, + "OnFileAfterTokenRequest": 1, + }, + }, + { + Name: "admin", + Method: http.MethodPost, + Url: "/api/files/token", + RequestHeaders: map[string]string{ + "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":"`, + }, + ExpectedEvents: map[string]int{ + "OnFileBeforeTokenRequest": 1, + "OnFileAfterTokenRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + func TestFileDownload(t *testing.T) { _, currentFile, _, _ := runtime.Caller(0) dataDirRelPath := "../tests/data/" @@ -176,6 +227,125 @@ func TestFileDownload(t *testing.T) { "OnFileDownloadRequest": 1, }, }, + + // private file access checks + { + Name: "private file - expired token", + Method: http.MethodGet, + Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100", + ExpectedStatus: 200, + ExpectedContent: []string{string(testFile)}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "private file - admin with expired file token", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "private file - admin with valid file token", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU", + ExpectedStatus: 200, + ExpectedContent: []string{"PNG"}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "private file - guest without view access", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "private file - guest with view access", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png", + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + dao := daos.New(app.Dao().DB()) + + // mock public view access + c, err := dao.FindCollectionByNameOrId("demo1") + if err != nil { + t.Fatalf("Failed to fetch mock collection: %v", err) + } + c.ViewRule = types.Pointer("") + if err := dao.SaveCollection(c); err != nil { + t.Fatalf("Failed to update mock collection: %v", err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{"PNG"}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "private file - auth record without view access", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk", + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + dao := daos.New(app.Dao().DB()) + + // mock restricted user view access + c, err := dao.FindCollectionByNameOrId("demo1") + if err != nil { + t.Fatalf("Failed to fetch mock collection: %v", err) + } + c.ViewRule = types.Pointer("@request.auth.verified = true") + if err := dao.SaveCollection(c); err != nil { + t.Fatalf("Failed to update mock collection: %v", err) + } + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "private file - auth record with view access", + Method: http.MethodGet, + Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk", + BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + dao := daos.New(app.Dao().DB()) + + // mock user view access + c, err := dao.FindCollectionByNameOrId("demo1") + if err != nil { + t.Fatalf("Failed to fetch mock collection: %v", err) + } + c.ViewRule = types.Pointer("@request.auth.verified = false") + if err := dao.SaveCollection(c); err != nil { + t.Fatalf("Failed to update mock collection: %v", err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{"PNG"}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "private file in view (view's View API rule failure)", + Method: http.MethodGet, + Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "private file in view (view's View API rule success)", + Method: http.MethodGet, + Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk", + ExpectedStatus: 200, + ExpectedContent: []string{"test"}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, } for _, scenario := range scenarios { diff --git a/apis/realtime.go b/apis/realtime.go index a984bcf0..74c9ae8c 100644 --- a/apis/realtime.go +++ b/apis/realtime.go @@ -307,7 +307,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod return nil // empty public rule } - // emulate request data + // mock request data requestData := &models.RequestData{ Method: "GET", } diff --git a/apis/realtime_test.go b/apis/realtime_test.go index 037fc293..9c65bfb9 100644 --- a/apis/realtime_test.go +++ b/apis/realtime_test.go @@ -253,7 +253,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) { client.Set(apis.ContextAuthRecordKey, authRecord) testApp.SubscriptionsBroker().Register(client) - testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord}) + e := new(core.ModelEvent) + e.Dao = testApp.Dao() + e.Model = authRecord + testApp.OnModelAfterDelete().Trigger(e) if len(testApp.SubscriptionsBroker().Clients()) != 0 { t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) @@ -282,7 +285,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) { } authRecord2.SetEmail("new@example.com") - testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2}) + e := new(core.ModelEvent) + e.Dao = testApp.Dao() + e.Model = authRecord2 + testApp.OnModelAfterUpdate().Trigger(e) clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record) if clientAuthRecord.Email() != authRecord2.Email() { @@ -305,7 +311,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) { client.Set(apis.ContextAdminKey, admin) testApp.SubscriptionsBroker().Register(client) - testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin}) + e := new(core.ModelEvent) + e.Dao = testApp.Dao() + e.Model = admin + testApp.OnModelAfterDelete().Trigger(e) if len(testApp.SubscriptionsBroker().Clients()) != 0 { t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) @@ -334,7 +343,10 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) { } admin2.Email = "new@example.com" - testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2}) + e := new(core.ModelEvent) + e.Dao = testApp.Dao() + e.Model = admin2 + testApp.OnModelAfterUpdate().Trigger(e) clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) if clientAdmin.Email != admin2.Email { diff --git a/core/app.go b/core/app.go index 67cabf13..fb7daa56 100644 --- a/core/app.go +++ b/core/app.go @@ -307,6 +307,25 @@ type App interface { // returning it to the client. OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent] + // OnFileBeforeTokenRequest hook is triggered before each file + // token API request. + // + // If not token or model was submitted, e.Model and e.Token will be empty, + // allowing you to implement your own custom model file auth implementation. + // + // If the optional "tags" list (Collection ids or names) is specified, + // then all event handlers registered via the created hook will be + // triggered and called only if their event data origin matches the tags. + OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] + + // OnFileAfterTokenRequest hook is triggered after each + // successful file token API request. + // + // If the optional "tags" list (Collection ids or names) is specified, + // then all event handlers registered via the created hook will be + // triggered and called only if their event data origin matches the tags. + OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] + // --------------------------------------------------------------- // Admin API event hooks // --------------------------------------------------------------- diff --git a/core/base.go b/core/base.go index bc82df3c..fc841cae 100644 --- a/core/base.go +++ b/core/base.go @@ -88,7 +88,9 @@ type BaseApp struct { onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent] // file api event hooks - onFileDownloadRequest *hook.Hook[*FileDownloadEvent] + onFileDownloadRequest *hook.Hook[*FileDownloadEvent] + onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent] + onFileAfterTokenRequest *hook.Hook[*FileTokenEvent] // admin api event hooks onAdminsListRequest *hook.Hook[*AdminsListEvent] @@ -223,7 +225,9 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp { onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{}, // file API event hooks - onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{}, + onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{}, + onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{}, + onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{}, // admin API event hooks onAdminsListRequest: &hook.Hook[*AdminsListEvent]{}, @@ -653,6 +657,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File return hook.NewTaggedHook(app.onFileDownloadRequest, tags...) } +func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] { + return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...) +} + +func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] { + return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...) +} + // ------------------------------------------------------------------- // Admin API event hooks // ------------------------------------------------------------------- @@ -979,34 +991,55 @@ func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB) dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m}) + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + return app.OnModelBeforeCreate().Trigger(e) } dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug { log.Println(err) } } dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m}) + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + return app.OnModelBeforeUpdate().Trigger(e) } dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug { log.Println(err) } } dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m}) + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + return app.OnModelBeforeDelete().Trigger(e) } dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { + e := new(ModelEvent) + e.Dao = eventDao + e.Model = m + + if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug { log.Println(err) } } diff --git a/core/events.go b/core/events.go index 17d72a8b..e29b323f 100644 --- a/core/events.go +++ b/core/events.go @@ -14,6 +14,27 @@ import ( "github.com/pocketbase/pocketbase/tools/subscriptions" ) +var ( + _ hook.Tagger = (*BaseModelEvent)(nil) + _ hook.Tagger = (*BaseCollectionEvent)(nil) +) + +type BaseModelEvent struct { + Model models.Model +} + +func (e *BaseModelEvent) Tags() []string { + if e.Model == nil { + return nil + } + + if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil { + return []string{r.Collection().Id, r.Collection().Name} + } + + return []string{e.Model.TableName()} +} + type BaseCollectionEvent struct { Collection *models.Collection } @@ -58,23 +79,10 @@ type ApiErrorEvent struct { // Model DAO events data // ------------------------------------------------------------------- -var _ hook.Tagger = (*ModelEvent)(nil) - type ModelEvent struct { - Dao *daos.Dao - Model models.Model -} + BaseModelEvent -func (e *ModelEvent) Tags() []string { - if e.Model == nil { - return nil - } - - if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil { - return []string{r.Collection().Id, r.Collection().Name} - } - - return []string{e.Model.TableName()} + Dao *daos.Dao } // ------------------------------------------------------------------- @@ -379,6 +387,13 @@ type CollectionsImportEvent struct { // File API events data // ------------------------------------------------------------------- +type FileTokenEvent struct { + BaseModelEvent + + HttpContext echo.Context + Token string +} + type FileDownloadEvent struct { BaseCollectionEvent diff --git a/forms/collections_import_test.go b/forms/collections_import_test.go index 98093dcd..f426856d 100644 --- a/forms/collections_import_test.go +++ b/forms/collections_import_test.go @@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) { expectError: true, expectCollectionsCount: totalCollections, expectEvents: map[string]int{ - "OnModelBeforeDelete": 7, + "OnModelBeforeDelete": 4, }, }, { diff --git a/models/schema/schema_field.go b/models/schema/schema_field.go index 5f74d137..971e8cd7 100644 --- a/models/schema/schema_field.go +++ b/models/schema/schema_field.go @@ -597,6 +597,7 @@ type FileOptions struct { MaxSize int `form:"maxSize" json:"maxSize"` // in bytes MimeTypes []string `form:"mimeTypes" json:"mimeTypes"` Thumbs []string `form:"thumbs" json:"thumbs"` + Private bool `form:"private" json:"private"` } func (o FileOptions) Validate() error { diff --git a/models/schema/schema_field_test.go b/models/schema/schema_field_test.go index 5af0dfcc..6a97809e 100644 --- a/models/schema/schema_field_test.go +++ b/models/schema/schema_field_test.go @@ -504,7 +504,7 @@ func TestSchemaFieldInitOptions(t *testing.T) { { schema.SchemaField{Type: schema.FieldTypeFile}, false, - `{"system":false,"id":"","name":"","type":"file","required":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null}}`, + `{"system":false,"id":"","name":"","type":"file","required":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null,"private":false}}`, }, { schema.SchemaField{Type: schema.FieldTypeRelation}, diff --git a/models/settings/settings.go b/models/settings/settings.go index 0572c737..eac8c932 100644 --- a/models/settings/settings.go +++ b/models/settings/settings.go @@ -30,10 +30,12 @@ type Settings struct { AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"` AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"` + AdminFileToken TokenConfig `form:"adminFileToken" json:"adminFileToken"` RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"` RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"` RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"` RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"` + RecordFileToken TokenConfig `form:"recordFileToken" json:"recordFileToken"` // Deprecated: Will be removed in v0.9+ EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"` @@ -84,27 +86,35 @@ func New() *Settings { }, AdminAuthToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 1209600, // 14 days, + Duration: 1209600, // 14 days }, AdminPasswordResetToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 1800, // 30 minutes, + Duration: 1800, // 30 minutes + }, + AdminFileToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 180, // 3 minutes }, RecordAuthToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 1209600, // 14 days, + Duration: 1209600, // 14 days }, RecordPasswordResetToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 1800, // 30 minutes, + Duration: 1800, // 30 minutes }, RecordVerificationToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 604800, // 7 days, + Duration: 604800, // 7 days + }, + RecordFileToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 180, // 3 minutes }, RecordEmailChangeToken: TokenConfig{ Secret: security.RandomString(50), - Duration: 1800, // 30 minutes, + Duration: 1800, // 30 minutes }, GoogleAuth: AuthProviderConfig{ Enabled: false, @@ -177,6 +187,7 @@ func (s *Settings) Validate() error { validation.Field(&s.RecordPasswordResetToken), validation.Field(&s.RecordEmailChangeToken), validation.Field(&s.RecordVerificationToken), + validation.Field(&s.RecordFileToken), validation.Field(&s.Smtp), validation.Field(&s.S3), validation.Field(&s.GoogleAuth), @@ -239,6 +250,7 @@ func (s *Settings) RedactClone() (*Settings, error) { &clone.RecordPasswordResetToken.Secret, &clone.RecordEmailChangeToken.Secret, &clone.RecordVerificationToken.Secret, + &clone.RecordFileToken.Secret, &clone.GoogleAuth.ClientSecret, &clone.FacebookAuth.ClientSecret, &clone.GithubAuth.ClientSecret, diff --git a/models/settings/settings_test.go b/models/settings/settings_test.go index 324d189e..a8baa4d0 100644 --- a/models/settings/settings_test.go +++ b/models/settings/settings_test.go @@ -29,6 +29,7 @@ func TestSettingsValidate(t *testing.T) { s.RecordPasswordResetToken.Duration = -10 s.RecordEmailChangeToken.Duration = -10 s.RecordVerificationToken.Duration = -10 + s.RecordFileToken.Duration = -10 s.GoogleAuth.Enabled = true s.GoogleAuth.ClientId = "" s.FacebookAuth.Enabled = true @@ -83,6 +84,7 @@ func TestSettingsValidate(t *testing.T) { `"recordPasswordResetToken":{`, `"recordEmailChangeToken":{`, `"recordVerificationToken":{`, + `"recordFileToken":{`, `"googleAuth":{`, `"facebookAuth":{`, `"githubAuth":{`, @@ -129,6 +131,7 @@ func TestSettingsMerge(t *testing.T) { s2.RecordPasswordResetToken.Duration = 4 s2.RecordEmailChangeToken.Duration = 5 s2.RecordVerificationToken.Duration = 6 + s2.RecordFileToken.Duration = 7 s2.GoogleAuth.Enabled = true s2.GoogleAuth.ClientId = "google_test" s2.FacebookAuth.Enabled = true @@ -231,6 +234,7 @@ func TestSettingsRedactClone(t *testing.T) { s1.RecordPasswordResetToken.Secret = testSecret s1.RecordEmailChangeToken.Secret = testSecret s1.RecordVerificationToken.Secret = testSecret + s1.RecordFileToken.Secret = testSecret s1.GoogleAuth.ClientSecret = testSecret s1.FacebookAuth.ClientSecret = testSecret s1.GithubAuth.ClientSecret = testSecret diff --git a/tests/api.go b/tests/api.go index 554290a6..d44f9fda 100644 --- a/tests/api.go +++ b/tests/api.go @@ -159,6 +159,7 @@ func (scenario *ApiScenario) Test(t *testing.T) { } } + // @todo consider adding the response body to the AfterTestFunc args if scenario.AfterTestFunc != nil { scenario.AfterTestFunc(t, testApp, e) } diff --git a/tests/app.go b/tests/app.go index daaca1a9..e140857f 100644 --- a/tests/app.go +++ b/tests/app.go @@ -446,6 +446,14 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { return t.registerEventCall("OnFileDownloadRequest") }) + t.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error { + return t.registerEventCall("OnFileBeforeTokenRequest") + }) + + t.OnFileAfterTokenRequest().Add(func(e *core.FileTokenEvent) error { + return t.registerEventCall("OnFileAfterTokenRequest") + }) + return t, nil } diff --git a/tests/data/data.db b/tests/data/data.db index f59aa0bc942b081fe9ae1dc57e01989e85bfaf90..d9f3ed225cbe60082d067a24f85b742bec758659 100644 GIT binary patch delta 4309 zcmeHKZA=?w9KXA#@8#}5!HTX@k1bKDm1|2&%P_$(3RGSSh4La{XM2oer7hR%3maq% zNUdmk8>k$KvUW4#;dj_a`B zu+K38JA-v&FnTRxFuaSJ_k$+*-(2Emz-({Ix{_mAyVNbcA=71A9FgZCP#z+Z_zFSL z;>^vtd(>*^d=yre>DPRjALo7nvTTMXmrpIjFE1ks)fA+yg{IVxq{#d+&5Rx?C{q>b zX63@CZzHb&+(<-NCQflQ;pEsDow^b!<|p1kW|W%?Dfbv5EGNMn;4fc93SF9sNxEhjvwSRr92JO-`y`RX+@F zfH>Z(^~$cwj)HxP?-dD^pj=0ei)7>oU)=@@^Xn5GS5R|&|aWKP-PO*7BwGI=t^~w zdfwFqBH(16i(lykXJqP25X*agAOub}HuCL0u!=+)g!j5Yy-q8p6p;Z~@v#hu(Vm4> zaw40?(=j134!(r>!Q@40)Sd*%p6W?Qp2Uo$w6XN4gz#qeHPSudBk{?59Q&)(+C!UfVekv$77tNCaXm z#W7(KiLA^#eqjObV`+bw4g9kUYfaK|tajMkmf%98m1Mw0xjby6=ZR25vLeyh5^JU_ z9bLA#z4__pV{A>&U_7+TMe5tdF}IQM2N^oVH3Y<{#SI&y_AMUbOqO&98{AmtvP}OT z#iBG@6Ja*Tss5!0CB>UqDv=cL^`(g3NZf`(3QJ4N;IXtpQU)z)LQ=dDiQ}6|KOkv* z>2!&At0pC=&~YOXyfPRMc5+@N7GqUuau}kCQkbYDejJhn@d8gl;MV% zfM4=Zv!LcXf@+Z*m5M}yxhiOoEUl@vw7%FPUQjmgC}z9UVlmk&Dtofpl=ZDs93lYo?eINf{Nr ze6S`R7^D^iey|oRE_;(OgZv=$p$vMV55=N42!iTM^tcEjI_{1icfa5L@9y_IG(-=* zCViH43qq(^9e>)-Gph@<7G!UEcP%tTb7VP*=v&I^2^yqsI+uJVQ{*z~C0ocmIhjab zGbtyM+1WTpCN4zZ(+Bhd-B0Ui5jBx-WQK5Zn$#1QN}Nk#FY@)VFWHtvz&_fh#Yu#d z3B~vlYX-7}w8V#tS$gKW0cDJE5c%^PFeqrlt<90$tobg?XWuzAunRX~F_Xq%5$hO( zD8skF%35cqXScv69b9c|kM8Y=v~oS&~6N@ z=@B;as|Ud+#NPB|h>F+laeLVCI5uhA!3G;k2B4gm-^P>uia;Nem!w*LV)$X0lAcPN z^wU~Je?D!icwe8VL*drF&f>LI{<0dsb;IU${(#l?*EZW;4Q4nz$ICv} z!f80X(ZfE~!IU;n{}3%_2Wr70i4|fhRMB$QT? zG)GrRMt-RcnlS6?guyp#q37$3UjlXTEt5Xl{Eh{bMPdcIRKYo

9KGFuS1UAtF%h! ygLs`v>P?RWXGcH=Hgf`u(#aZKDj9vn{K_$KYuMM5FwTBVV2WK1{@@gpEcpdTxdVFu diff --git a/tests/data/logs.db b/tests/data/logs.db index b1d33059bf0199c6d6e30a5032b137605ef70469..8c38954505b5b76955727931e17b48c17cb5ae2a 100644 GIT binary patch literal 69632 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 H`yBp%y`Lz{ literal 40960 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 diff --git a/tokens/admin.go b/tokens/admin.go index 3a21b90a..d6870574 100644 --- a/tokens/admin.go +++ b/tokens/admin.go @@ -24,3 +24,12 @@ func NewAdminResetPasswordToken(app core.App, admin *models.Admin) (string, erro app.Settings().AdminPasswordResetToken.Duration, ) } + +// NewAdminFileToken generates and returns a new admin private file access token. +func NewAdminFileToken(app core.App, admin *models.Admin) (string, error) { + return security.NewToken( + jwt.MapClaims{"id": admin.Id, "type": TypeAdmin}, + (admin.TokenKey + app.Settings().AdminFileToken.Secret), + app.Settings().AdminFileToken.Duration, + ) +} diff --git a/tokens/admin_test.go b/tokens/admin_test.go index 9bba76ec..a6f571e5 100644 --- a/tokens/admin_test.go +++ b/tokens/admin_test.go @@ -52,3 +52,26 @@ func TestNewAdminResetPasswordToken(t *testing.T) { t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin) } } + +func TestNewAdminFileToken(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + admin, err := app.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + token, err := tokens.NewAdminFileToken(app, admin) + if err != nil { + t.Fatal(err) + } + + tokenAdmin, _ := app.Dao().FindAdminByToken( + token, + app.Settings().AdminFileToken.Secret, + ) + if tokenAdmin == nil || tokenAdmin.Id != admin.Id { + t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin) + } +} diff --git a/tokens/record.go b/tokens/record.go index 0cf7d0f0..8d8f405d 100644 --- a/tokens/record.go +++ b/tokens/record.go @@ -76,3 +76,20 @@ func NewRecordChangeEmailToken(app core.App, record *models.Record, newEmail str app.Settings().RecordEmailChangeToken.Duration, ) } + +// NewRecordFileToken generates and returns a new record private file access token. +func NewRecordFileToken(app core.App, record *models.Record) (string, error) { + if !record.Collection().IsAuth() { + return "", errors.New("The record is not from an auth collection.") + } + + return security.NewToken( + jwt.MapClaims{ + "id": record.Id, + "type": TypeAuthRecord, + "collectionId": record.Collection().Id, + }, + (record.TokenKey() + app.Settings().RecordFileToken.Secret), + app.Settings().RecordFileToken.Duration, + ) +} diff --git a/tokens/record_test.go b/tokens/record_test.go index e9470cbd..04358a6e 100644 --- a/tokens/record_test.go +++ b/tokens/record_test.go @@ -98,3 +98,26 @@ func TestNewRecordChangeEmailToken(t *testing.T) { t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) } } + +func TestNewRecordFileToken(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + token, err := tokens.NewRecordFileToken(app, user) + if err != nil { + t.Fatal(err) + } + + tokenRecord, _ := app.Dao().FindAuthRecordByToken( + token, + app.Settings().RecordFileToken.Secret, + ) + if tokenRecord == nil || tokenRecord.Id != user.Id { + t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) + } +}