[#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
|
totalCollections := 10
|
||||||
|
|
||||||
scenarios := []tests.ApiScenario{
|
scenarios := []tests.ApiScenario{
|
||||||
|
@ -1157,7 +1157,7 @@ func TestCollectionImport(t *testing.T) {
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{
|
ExpectedEvents: map[string]int{
|
||||||
"OnCollectionsBeforeImportRequest": 1,
|
"OnCollectionsBeforeImportRequest": 1,
|
||||||
"OnModelBeforeDelete": 7,
|
"OnModelBeforeDelete": 4,
|
||||||
},
|
},
|
||||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||||
collections := []*models.Collection{}
|
collections := []*models.Collection{}
|
||||||
|
|
132
apis/file.go
132
apis/file.go
|
@ -1,13 +1,23 @@
|
||||||
package apis
|
package apis
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"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/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"}
|
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}
|
api := fileApi{app: app}
|
||||||
|
|
||||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||||
|
subGroup.POST("/token", api.fileToken, RequireAdminOrRecordAuth())
|
||||||
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||||
subGroup.GET("/: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
|
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 {
|
func (api *fileApi) download(c echo.Context) error {
|
||||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||||
if collection == nil {
|
if collection == nil {
|
||||||
|
@ -49,7 +91,21 @@ func (api *fileApi) download(c echo.Context) error {
|
||||||
return NewNotFoundError("", nil)
|
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()
|
baseFilesPath := record.BaseFilesPath()
|
||||||
|
|
||||||
|
@ -119,3 +175,77 @@ func (api *fileApi) download(c echo.Context) error {
|
||||||
return nil
|
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"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"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) {
|
func TestFileDownload(t *testing.T) {
|
||||||
_, currentFile, _, _ := runtime.Caller(0)
|
_, currentFile, _, _ := runtime.Caller(0)
|
||||||
dataDirRelPath := "../tests/data/"
|
dataDirRelPath := "../tests/data/"
|
||||||
|
@ -176,6 +227,125 @@ func TestFileDownload(t *testing.T) {
|
||||||
"OnFileDownloadRequest": 1,
|
"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 {
|
for _, scenario := range scenarios {
|
||||||
|
|
|
@ -307,7 +307,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||||
return nil // empty public rule
|
return nil // empty public rule
|
||||||
}
|
}
|
||||||
|
|
||||||
// emulate request data
|
// mock request data
|
||||||
requestData := &models.RequestData{
|
requestData := &models.RequestData{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
}
|
}
|
||||||
|
|
|
@ -253,7 +253,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||||
testApp.SubscriptionsBroker().Register(client)
|
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 {
|
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
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")
|
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)
|
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||||
|
@ -305,7 +311,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
|
||||||
client.Set(apis.ContextAdminKey, admin)
|
client.Set(apis.ContextAdminKey, admin)
|
||||||
testApp.SubscriptionsBroker().Register(client)
|
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 {
|
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
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"
|
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)
|
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||||
if clientAdmin.Email != admin2.Email {
|
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.
|
// returning it to the client.
|
||||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
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
|
// Admin API event hooks
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
51
core/base.go
51
core/base.go
|
@ -89,6 +89,8 @@ type BaseApp struct {
|
||||||
|
|
||||||
// file api event hooks
|
// file api event hooks
|
||||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||||
|
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
|
||||||
|
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
|
||||||
|
|
||||||
// admin api event hooks
|
// admin api event hooks
|
||||||
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
||||||
|
@ -224,6 +226,8 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||||
|
|
||||||
// file API event hooks
|
// file API event hooks
|
||||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||||
|
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||||
|
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||||
|
|
||||||
// admin API event hooks
|
// admin API event hooks
|
||||||
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
||||||
|
@ -653,6 +657,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
|
||||||
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
|
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
|
// Admin API event hooks
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
@ -979,34 +991,55 @@ func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder
|
||||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||||
|
|
||||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
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) {
|
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||||
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
e := new(ModelEvent)
|
||||||
if err != nil && app.isDebug {
|
e.Dao = eventDao
|
||||||
|
e.Model = m
|
||||||
|
|
||||||
|
if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
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) {
|
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||||
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
e := new(ModelEvent)
|
||||||
if err != nil && app.isDebug {
|
e.Dao = eventDao
|
||||||
|
e.Model = m
|
||||||
|
|
||||||
|
if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
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) {
|
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||||
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
|
e := new(ModelEvent)
|
||||||
if err != nil && app.isDebug {
|
e.Dao = eventDao
|
||||||
|
e.Model = m
|
||||||
|
|
||||||
|
if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,27 @@ import (
|
||||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
"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 {
|
type BaseCollectionEvent struct {
|
||||||
Collection *models.Collection
|
Collection *models.Collection
|
||||||
}
|
}
|
||||||
|
@ -58,23 +79,10 @@ type ApiErrorEvent struct {
|
||||||
// Model DAO events data
|
// Model DAO events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
|
||||||
|
|
||||||
type ModelEvent struct {
|
type ModelEvent struct {
|
||||||
|
BaseModelEvent
|
||||||
|
|
||||||
Dao *daos.Dao
|
Dao *daos.Dao
|
||||||
Model models.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
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()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
@ -379,6 +387,13 @@ type CollectionsImportEvent struct {
|
||||||
// File API events data
|
// File API events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FileTokenEvent struct {
|
||||||
|
BaseModelEvent
|
||||||
|
|
||||||
|
HttpContext echo.Context
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
type FileDownloadEvent struct {
|
type FileDownloadEvent struct {
|
||||||
BaseCollectionEvent
|
BaseCollectionEvent
|
||||||
|
|
||||||
|
|
|
@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectCollectionsCount: totalCollections,
|
expectCollectionsCount: totalCollections,
|
||||||
expectEvents: map[string]int{
|
expectEvents: map[string]int{
|
||||||
"OnModelBeforeDelete": 7,
|
"OnModelBeforeDelete": 4,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -597,6 +597,7 @@ type FileOptions struct {
|
||||||
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
|
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
|
||||||
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
|
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
|
||||||
Thumbs []string `form:"thumbs" json:"thumbs"`
|
Thumbs []string `form:"thumbs" json:"thumbs"`
|
||||||
|
Private bool `form:"private" json:"private"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o FileOptions) Validate() error {
|
func (o FileOptions) Validate() error {
|
||||||
|
|
|
@ -504,7 +504,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
|
||||||
{
|
{
|
||||||
schema.SchemaField{Type: schema.FieldTypeFile},
|
schema.SchemaField{Type: schema.FieldTypeFile},
|
||||||
false,
|
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},
|
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||||
|
|
|
@ -30,10 +30,12 @@ type Settings struct {
|
||||||
|
|
||||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||||
|
AdminFileToken TokenConfig `form:"adminFileToken" json:"adminFileToken"`
|
||||||
RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
|
RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
|
||||||
RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
|
RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
|
||||||
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
|
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
|
||||||
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
|
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
|
||||||
|
RecordFileToken TokenConfig `form:"recordFileToken" json:"recordFileToken"`
|
||||||
|
|
||||||
// Deprecated: Will be removed in v0.9+
|
// Deprecated: Will be removed in v0.9+
|
||||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||||
|
@ -84,27 +86,35 @@ func New() *Settings {
|
||||||
},
|
},
|
||||||
AdminAuthToken: TokenConfig{
|
AdminAuthToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 1209600, // 14 days,
|
Duration: 1209600, // 14 days
|
||||||
},
|
},
|
||||||
AdminPasswordResetToken: TokenConfig{
|
AdminPasswordResetToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 1800, // 30 minutes,
|
Duration: 1800, // 30 minutes
|
||||||
|
},
|
||||||
|
AdminFileToken: TokenConfig{
|
||||||
|
Secret: security.RandomString(50),
|
||||||
|
Duration: 180, // 3 minutes
|
||||||
},
|
},
|
||||||
RecordAuthToken: TokenConfig{
|
RecordAuthToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 1209600, // 14 days,
|
Duration: 1209600, // 14 days
|
||||||
},
|
},
|
||||||
RecordPasswordResetToken: TokenConfig{
|
RecordPasswordResetToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 1800, // 30 minutes,
|
Duration: 1800, // 30 minutes
|
||||||
},
|
},
|
||||||
RecordVerificationToken: TokenConfig{
|
RecordVerificationToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 604800, // 7 days,
|
Duration: 604800, // 7 days
|
||||||
|
},
|
||||||
|
RecordFileToken: TokenConfig{
|
||||||
|
Secret: security.RandomString(50),
|
||||||
|
Duration: 180, // 3 minutes
|
||||||
},
|
},
|
||||||
RecordEmailChangeToken: TokenConfig{
|
RecordEmailChangeToken: TokenConfig{
|
||||||
Secret: security.RandomString(50),
|
Secret: security.RandomString(50),
|
||||||
Duration: 1800, // 30 minutes,
|
Duration: 1800, // 30 minutes
|
||||||
},
|
},
|
||||||
GoogleAuth: AuthProviderConfig{
|
GoogleAuth: AuthProviderConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
@ -177,6 +187,7 @@ func (s *Settings) Validate() error {
|
||||||
validation.Field(&s.RecordPasswordResetToken),
|
validation.Field(&s.RecordPasswordResetToken),
|
||||||
validation.Field(&s.RecordEmailChangeToken),
|
validation.Field(&s.RecordEmailChangeToken),
|
||||||
validation.Field(&s.RecordVerificationToken),
|
validation.Field(&s.RecordVerificationToken),
|
||||||
|
validation.Field(&s.RecordFileToken),
|
||||||
validation.Field(&s.Smtp),
|
validation.Field(&s.Smtp),
|
||||||
validation.Field(&s.S3),
|
validation.Field(&s.S3),
|
||||||
validation.Field(&s.GoogleAuth),
|
validation.Field(&s.GoogleAuth),
|
||||||
|
@ -239,6 +250,7 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||||
&clone.RecordPasswordResetToken.Secret,
|
&clone.RecordPasswordResetToken.Secret,
|
||||||
&clone.RecordEmailChangeToken.Secret,
|
&clone.RecordEmailChangeToken.Secret,
|
||||||
&clone.RecordVerificationToken.Secret,
|
&clone.RecordVerificationToken.Secret,
|
||||||
|
&clone.RecordFileToken.Secret,
|
||||||
&clone.GoogleAuth.ClientSecret,
|
&clone.GoogleAuth.ClientSecret,
|
||||||
&clone.FacebookAuth.ClientSecret,
|
&clone.FacebookAuth.ClientSecret,
|
||||||
&clone.GithubAuth.ClientSecret,
|
&clone.GithubAuth.ClientSecret,
|
||||||
|
|
|
@ -29,6 +29,7 @@ func TestSettingsValidate(t *testing.T) {
|
||||||
s.RecordPasswordResetToken.Duration = -10
|
s.RecordPasswordResetToken.Duration = -10
|
||||||
s.RecordEmailChangeToken.Duration = -10
|
s.RecordEmailChangeToken.Duration = -10
|
||||||
s.RecordVerificationToken.Duration = -10
|
s.RecordVerificationToken.Duration = -10
|
||||||
|
s.RecordFileToken.Duration = -10
|
||||||
s.GoogleAuth.Enabled = true
|
s.GoogleAuth.Enabled = true
|
||||||
s.GoogleAuth.ClientId = ""
|
s.GoogleAuth.ClientId = ""
|
||||||
s.FacebookAuth.Enabled = true
|
s.FacebookAuth.Enabled = true
|
||||||
|
@ -83,6 +84,7 @@ func TestSettingsValidate(t *testing.T) {
|
||||||
`"recordPasswordResetToken":{`,
|
`"recordPasswordResetToken":{`,
|
||||||
`"recordEmailChangeToken":{`,
|
`"recordEmailChangeToken":{`,
|
||||||
`"recordVerificationToken":{`,
|
`"recordVerificationToken":{`,
|
||||||
|
`"recordFileToken":{`,
|
||||||
`"googleAuth":{`,
|
`"googleAuth":{`,
|
||||||
`"facebookAuth":{`,
|
`"facebookAuth":{`,
|
||||||
`"githubAuth":{`,
|
`"githubAuth":{`,
|
||||||
|
@ -129,6 +131,7 @@ func TestSettingsMerge(t *testing.T) {
|
||||||
s2.RecordPasswordResetToken.Duration = 4
|
s2.RecordPasswordResetToken.Duration = 4
|
||||||
s2.RecordEmailChangeToken.Duration = 5
|
s2.RecordEmailChangeToken.Duration = 5
|
||||||
s2.RecordVerificationToken.Duration = 6
|
s2.RecordVerificationToken.Duration = 6
|
||||||
|
s2.RecordFileToken.Duration = 7
|
||||||
s2.GoogleAuth.Enabled = true
|
s2.GoogleAuth.Enabled = true
|
||||||
s2.GoogleAuth.ClientId = "google_test"
|
s2.GoogleAuth.ClientId = "google_test"
|
||||||
s2.FacebookAuth.Enabled = true
|
s2.FacebookAuth.Enabled = true
|
||||||
|
@ -231,6 +234,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||||
s1.RecordPasswordResetToken.Secret = testSecret
|
s1.RecordPasswordResetToken.Secret = testSecret
|
||||||
s1.RecordEmailChangeToken.Secret = testSecret
|
s1.RecordEmailChangeToken.Secret = testSecret
|
||||||
s1.RecordVerificationToken.Secret = testSecret
|
s1.RecordVerificationToken.Secret = testSecret
|
||||||
|
s1.RecordFileToken.Secret = testSecret
|
||||||
s1.GoogleAuth.ClientSecret = testSecret
|
s1.GoogleAuth.ClientSecret = testSecret
|
||||||
s1.FacebookAuth.ClientSecret = testSecret
|
s1.FacebookAuth.ClientSecret = testSecret
|
||||||
s1.GithubAuth.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 {
|
if scenario.AfterTestFunc != nil {
|
||||||
scenario.AfterTestFunc(t, testApp, e)
|
scenario.AfterTestFunc(t, testApp, e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -446,6 +446,14 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
|
||||||
return t.registerEventCall("OnFileDownloadRequest")
|
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
|
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,
|
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)
|
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,
|
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)
|
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