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 f59aa0bc..d9f3ed22 100644 Binary files a/tests/data/data.db and b/tests/data/data.db differ diff --git a/tests/data/logs.db b/tests/data/logs.db index b1d33059..8c389545 100644 Binary files a/tests/data/logs.db and b/tests/data/logs.db differ 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) + } +}