[#215] added server-side handlers for serving private files
This commit is contained in:
parent
9f76ad234c
commit
64c3e3b3c5
|
@ -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{}
|
||||
|
|
132
apis/file.go
132
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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
19
core/app.go
19
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
|
||||
// ---------------------------------------------------------------
|
||||
|
|
55
core/base.go
55
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
|||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 7,
|
||||
"OnModelBeforeDelete": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue