[#215] added server-side handlers for serving private files

This commit is contained in:
Gani Georgiev 2023-04-04 20:33:35 +03:00
parent 9f76ad234c
commit 64c3e3b3c5
21 changed files with 519 additions and 42 deletions

View File

@ -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{}

View File

@ -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
}

View File

@ -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 {

View File

@ -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",
}

View File

@ -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 {

View File

@ -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
// ---------------------------------------------------------------

View File

@ -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)
}
}

View File

@ -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

View File

@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 7,
"OnModelBeforeDelete": 4,
},
},
{

View File

@ -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 {

View File

@ -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},

View File

@ -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,

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

Binary file not shown.

Binary file not shown.

View File

@ -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,
)
}

View File

@ -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)
}
}

View File

@ -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,
)
}

View File

@ -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)
}
}