snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
1299
server/compose/service/access_control.gen.go
generated
Normal file
1299
server/compose/service/access_control.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
107
server/compose/service/access_control.gen_test.go
generated
Normal file
107
server/compose/service/access_control.gen_test.go
generated
Normal file
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccessControl_ResourceLoader(t *testing.T) {
|
||||
svc := accessControl{}
|
||||
|
||||
// Has wildcard resources
|
||||
testCases := []struct {
|
||||
resource string
|
||||
expected rbac.Resource
|
||||
err error
|
||||
}{
|
||||
{
|
||||
resource: "corteza::compose:chart/1/*",
|
||||
expected: rbac.NewResource(types.ChartRbacResource(1, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:chart/*/*",
|
||||
expected: rbac.NewResource(types.ChartRbacResource(0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:module/3/*",
|
||||
expected: rbac.NewResource(types.ModuleRbacResource(3, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:module/*/*",
|
||||
expected: rbac.NewResource(types.ModuleRbacResource(0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:module-field/5/*/*",
|
||||
expected: rbac.NewResource(types.ModuleFieldRbacResource(5, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:module-field/*/*/*",
|
||||
expected: rbac.NewResource(types.ModuleFieldRbacResource(0, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:namespace/*",
|
||||
expected: rbac.NewResource(types.NamespaceRbacResource(0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:page/9/*",
|
||||
expected: rbac.NewResource(types.PageRbacResource(9, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:page/*/*",
|
||||
expected: rbac.NewResource(types.PageRbacResource(0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:page-layout/11/*/*",
|
||||
expected: rbac.NewResource(types.PageLayoutRbacResource(11, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:page-layout/*/*/*",
|
||||
expected: rbac.NewResource(types.PageLayoutRbacResource(0, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:record/14/*/*",
|
||||
expected: rbac.NewResource(types.RecordRbacResource(14, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose:record/*/*/*",
|
||||
expected: rbac.NewResource(types.RecordRbacResource(0, 0, 0)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "corteza::compose",
|
||||
expected: &types.Component{},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
resource: "unknown_resource_type:17",
|
||||
expected: nil,
|
||||
err: fmt.Errorf("unknown resource type %q", "unknown_resource_type:17"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.resource, func(t *testing.T) {
|
||||
res, err := svc.resourceLoader(context.Background(), tc.resource)
|
||||
|
||||
require.Equal(t, tc.expected, res)
|
||||
require.Equal(t, tc.err, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
284
server/compose/service/access_control_actions.gen.go
generated
Normal file
284
server/compose/service/access_control_actions.gen.go
generated
Normal file
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
//
|
||||
// Definitions file that controls how this file is generated:
|
||||
// compose/service/access_control_actions.yaml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
accessControlActionProps struct {
|
||||
rule *rbac.Rule
|
||||
}
|
||||
|
||||
accessControlAction struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *accessControlActionProps
|
||||
}
|
||||
|
||||
accessControlLogMetaKey struct{}
|
||||
accessControlPropsMetaKey struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
// just a placeholder to cover template cases w/o fmt package use
|
||||
_ = fmt.Println
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
// setRule updates accessControlActionProps's rule
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *accessControlActionProps) setRule(rule *rbac.Rule) *accessControlActionProps {
|
||||
p.rule = rule
|
||||
return p
|
||||
}
|
||||
|
||||
// Serialize converts accessControlActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p accessControlActionProps) Serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
)
|
||||
|
||||
if p.rule != nil {
|
||||
m.Set("rule.operation", p.rule.Operation, true)
|
||||
m.Set("rule.roleID", p.rule.RoleID, true)
|
||||
m.Set("rule.access", p.rule.Access, true)
|
||||
m.Set("rule.resource", p.rule.Resource, true)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p accessControlActionProps) Format(in string, err error) string {
|
||||
var (
|
||||
pairs = []string{"{{err}}"}
|
||||
// first non-empty string
|
||||
fns = func(ii ...interface{}) string {
|
||||
for _, i := range ii {
|
||||
if s := fmt.Sprintf("%v", i); len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
|
||||
if p.rule != nil {
|
||||
// replacement for "{{rule}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{rule}}",
|
||||
fns(
|
||||
p.rule.Operation,
|
||||
p.rule.RoleID,
|
||||
p.rule.Access,
|
||||
p.rule.Resource,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{rule.operation}}", fns(p.rule.Operation))
|
||||
pairs = append(pairs, "{{rule.roleID}}", fns(p.rule.RoleID))
|
||||
pairs = append(pairs, "{{rule.access}}", fns(p.rule.Access))
|
||||
pairs = append(pairs, "{{rule.resource}}", fns(p.rule.Resource))
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (a *accessControlAction) String() string {
|
||||
var props = &accessControlActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.Format(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *accessControlAction) ToAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.Serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
// AccessControlActionGrant returns "compose:access_control.grant" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func AccessControlActionGrant(props ...*accessControlActionProps) *accessControlAction {
|
||||
a := &accessControlAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:access_control",
|
||||
action: "grant",
|
||||
log: "grant",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
// AccessControlErrGeneric returns "compose:access_control.generic" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func AccessControlErrGeneric(mm ...*accessControlActionProps) *errors.Error {
|
||||
var p = &accessControlActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("failed to complete request due to internal error", nil),
|
||||
|
||||
errors.Meta("type", "generic"),
|
||||
errors.Meta("resource", "compose:access_control"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(accessControlLogMetaKey{}, "{err}"),
|
||||
errors.Meta(accessControlPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "access-control.errors.generic"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// AccessControlErrNotAllowedToSetPermissions returns "compose:access_control.notAllowedToSetPermissions" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func AccessControlErrNotAllowedToSetPermissions(mm ...*accessControlActionProps) *errors.Error {
|
||||
var p = &accessControlActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to set permissions", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToSetPermissions"),
|
||||
errors.Meta("resource", "compose:access_control"),
|
||||
|
||||
errors.Meta(accessControlPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "access-control.errors.notAllowedToSetPermissions"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// It will wrap unrecognized/internal errors with generic errors.
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (svc accessControl) recordAction(ctx context.Context, props *accessControlActionProps, actionFn func(...*accessControlActionProps) *accessControlAction, err error) error {
|
||||
if svc.actionlog == nil || actionFn == nil {
|
||||
// action log disabled or no action fn passed, return error as-is
|
||||
return err
|
||||
} else if err == nil {
|
||||
// action completed w/o error, record it
|
||||
svc.actionlog.Record(ctx, actionFn(props).ToAction())
|
||||
return nil
|
||||
}
|
||||
|
||||
a := actionFn(props).ToAction()
|
||||
|
||||
// Extracting error information and recording it as action
|
||||
a.Error = err.Error()
|
||||
|
||||
switch c := err.(type) {
|
||||
case *errors.Error:
|
||||
m := c.Meta()
|
||||
|
||||
a.Error = err.Error()
|
||||
a.Severity = actionlog.Severity(m.AsInt("severity"))
|
||||
a.Description = props.Format(m.AsString(accessControlLogMetaKey{}), err)
|
||||
|
||||
if p, has := m[accessControlPropsMetaKey{}]; has {
|
||||
a.Meta = p.(*accessControlActionProps).Serialize()
|
||||
}
|
||||
|
||||
svc.actionlog.Record(ctx, a)
|
||||
default:
|
||||
svc.actionlog.Record(ctx, a)
|
||||
}
|
||||
|
||||
// Original error is passed on
|
||||
return err
|
||||
}
|
||||
25
server/compose/service/access_control_actions.yaml
Normal file
25
server/compose/service/access_control_actions.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# List of security/audit events and errors that we need to log
|
||||
|
||||
resource: compose:access_control
|
||||
service: accessControl
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: alert
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/pkg/rbac
|
||||
|
||||
props:
|
||||
- name: rule
|
||||
type: "*rbac.Rule"
|
||||
fields: [ operation, roleID, access, resource ]
|
||||
|
||||
actions:
|
||||
- action: grant
|
||||
|
||||
errors:
|
||||
- error: notAllowedToSetPermissions
|
||||
message: "not allowed to set permissions"
|
||||
717
server/compose/service/attachment.go
Normal file
717
server/compose/service/attachment.go
Normal file
@@ -0,0 +1,717 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/gif"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/dalutils"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/objstore"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemService "github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/edwvee/exiffix"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/mat/besticon/ico"
|
||||
)
|
||||
|
||||
const (
|
||||
attachmentPreviewMaxWidth = 320
|
||||
attachmentPreviewMaxHeight = 180
|
||||
|
||||
// using base 10, it will be less confusing for the non-techie users
|
||||
megabyte = 1_000_000
|
||||
)
|
||||
|
||||
var (
|
||||
reMimeType = regexp.MustCompile(`\w+/[-.\w]+(?:\+[-.\w]+)?`)
|
||||
)
|
||||
|
||||
type (
|
||||
attachment struct {
|
||||
actionlog actionlog.Recorder
|
||||
objects objstore.Store
|
||||
ac attachmentAccessController
|
||||
store store.Storer
|
||||
dal dalDater
|
||||
}
|
||||
|
||||
attachmentAccessController interface {
|
||||
CanReadNamespace(context.Context, *types.Namespace) bool
|
||||
CanCreateNamespace(context.Context) bool
|
||||
CanReadModule(context.Context, *types.Module) bool
|
||||
CanReadPage(context.Context, *types.Page) bool
|
||||
CanUpdatePage(context.Context, *types.Page) bool
|
||||
CanReadRecord(context.Context, *types.Record) bool
|
||||
CanUpdateRecord(context.Context, *types.Record) bool
|
||||
CanCreateRecordOnModule(context.Context, *types.Module) bool
|
||||
}
|
||||
|
||||
AttachmentService interface {
|
||||
FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error)
|
||||
Find(ctx context.Context, filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error)
|
||||
CreatePageAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error)
|
||||
CreateIconAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error)
|
||||
CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error)
|
||||
CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error)
|
||||
OpenOriginal(att *types.Attachment) (io.ReadSeekCloser, error)
|
||||
OpenPreview(att *types.Attachment) (io.ReadSeekCloser, error)
|
||||
DeleteByID(ctx context.Context, namespaceID, attachmentID uint64) error
|
||||
}
|
||||
)
|
||||
|
||||
func Attachment(store objstore.Store, dal dalDater) *attachment {
|
||||
return &attachment{
|
||||
objects: store,
|
||||
ac: DefaultAccessControl,
|
||||
store: DefaultStore,
|
||||
dal: dal,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc attachment) Find(ctx context.Context, filter types.AttachmentFilter) (set types.AttachmentSet, f types.AttachmentFilter, err error) {
|
||||
var (
|
||||
aProps = &attachmentActionProps{filter: &filter}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
if filter.NamespaceID == 0 && filter.Kind != types.IconAttachment {
|
||||
return AttachmentErrInvalidNamespaceID()
|
||||
}
|
||||
|
||||
if filter.PageID > 0 {
|
||||
aProps.namespace, aProps.page, err = loadPageCombo(ctx, svc.store, filter.NamespaceID, filter.PageID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if svc.ac.CanReadPage(ctx, aProps.page) {
|
||||
return AttachmentErrNotAllowedToReadPage()
|
||||
}
|
||||
}
|
||||
|
||||
if filter.RecordID > 0 {
|
||||
aProps.namespace, aProps.module, aProps.record, err = loadRecordCombo(ctx, svc.store, svc.dal, filter.NamespaceID, filter.ModuleID, filter.RecordID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if svc.ac.CanReadRecord(ctx, aProps.record) {
|
||||
return AttachmentErrNotAllowedToReadRecord()
|
||||
}
|
||||
} else if filter.ModuleID > 0 {
|
||||
aProps.namespace, aProps.module, err = loadModuleCombo(ctx, svc.store, filter.NamespaceID, filter.ModuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if svc.ac.CanReadRecord(ctx, aProps.record) {
|
||||
return AttachmentErrNotAllowedToReadRecord()
|
||||
}
|
||||
}
|
||||
|
||||
set, f, err = store.SearchComposeAttachments(ctx, svc.store, filter)
|
||||
return err
|
||||
}()
|
||||
|
||||
return set, f, svc.recordAction(ctx, aProps, AttachmentActionSearch, err)
|
||||
}
|
||||
|
||||
func (svc attachment) FindByID(ctx context.Context, namespaceID, attachmentID uint64) (att *types.Attachment, err error) {
|
||||
var (
|
||||
aProps = &attachmentActionProps{}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
if attachmentID == 0 {
|
||||
return AttachmentErrInvalidID()
|
||||
}
|
||||
|
||||
if att, err = store.LookupComposeAttachmentByID(ctx, svc.store, attachmentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setAttachment(att)
|
||||
return nil
|
||||
}()
|
||||
|
||||
return att, svc.recordAction(ctx, aProps, AttachmentActionLookup, err)
|
||||
}
|
||||
|
||||
func (svc attachment) DeleteByID(ctx context.Context, namespaceID, attachmentID uint64) (err error) {
|
||||
var (
|
||||
att *types.Attachment
|
||||
aProps = &attachmentActionProps{attachment: &types.Attachment{ID: attachmentID}}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if attachmentID == 0 {
|
||||
return AttachmentErrInvalidID()
|
||||
}
|
||||
|
||||
if att, err = store.LookupComposeAttachmentByID(ctx, s, attachmentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setAttachment(att)
|
||||
|
||||
att.DeletedAt = now()
|
||||
return store.UpdateComposeAttachment(ctx, s, att)
|
||||
})
|
||||
|
||||
return svc.recordAction(ctx, aProps, AttachmentActionDelete, err)
|
||||
}
|
||||
|
||||
// func (svc attachment) findNamespaceByID(namespaceID uint64) (ns *types.Namespace, err error) {
|
||||
// if namespaceID == 0 {
|
||||
// return nil, AttachmentErrInvalidNamespaceID()
|
||||
// }
|
||||
//
|
||||
// ns, err = svc.namespaceRepo.FindByID(namespaceID)
|
||||
// if repository.ErrNamespaceNotFound.Eq(err) {
|
||||
// return nil, AttachmentErrNamespaceNotFound()
|
||||
// }
|
||||
//
|
||||
// if !svc.ac.CanReadNamespace(ctx, ns) {
|
||||
// return nil, AttachmentErrNotAllowedToReadNamespace()
|
||||
// }
|
||||
//
|
||||
// return ns, nil
|
||||
// }
|
||||
//
|
||||
// func (svc attachment) findPageByID(namespaceID, pageID uint64) (p *types.Page, err error) {
|
||||
// if pageID == 0 {
|
||||
// return nil, AttachmentErrInvalidPageID()
|
||||
// }
|
||||
//
|
||||
// p, err = svc.pageRepo.FindByID(namespaceID, pageID)
|
||||
// if repository.ErrPageNotFound.Eq(err) {
|
||||
// return nil, AttachmentErrPageNotFound()
|
||||
// }
|
||||
//
|
||||
// if !svc.ac.CanReadPage(ctx, p) {
|
||||
// return nil, AttachmentErrNotAllowedToReadPage()
|
||||
// }
|
||||
//
|
||||
// return p, nil
|
||||
// }
|
||||
//
|
||||
// func (svc attachment) findModuleByID(namespaceID, moduleID uint64) (m *types.Module, err error) {
|
||||
// if moduleID == 0 {
|
||||
// return nil, AttachmentErrInvalidModuleID()
|
||||
// }
|
||||
//
|
||||
// m, err = svc.moduleRepo.FindByID(namespaceID, moduleID)
|
||||
// if repository.ErrModuleNotFound.Eq(err) {
|
||||
// return nil, AttachmentErrModuleNotFound()
|
||||
// }
|
||||
//
|
||||
// if !svc.ac.CanReadModule(ctx, m) {
|
||||
// return nil, AttachmentErrNotAllowedToReadModule()
|
||||
// }
|
||||
//
|
||||
// return m, nil
|
||||
// }
|
||||
//
|
||||
// func (svc attachment) findRecordByID(m *types.Module, recordID uint64) (r *types.Record, err error) {
|
||||
// if recordID == 0 {
|
||||
// return nil, AttachmentErrInvalidRecordID()
|
||||
// }
|
||||
//
|
||||
// r, err = svc.recordRepo.FindByID(m.NamespaceID, recordID)
|
||||
// if repository.ErrRecordNotFound.Eq(err) {
|
||||
// return nil, AttachmentErrRecordNotFound()
|
||||
// }
|
||||
//
|
||||
// if !svc.ac.CanReadRecord(ctx, m) {
|
||||
// return nil, AttachmentErrNotAllowedToReadRecord()
|
||||
// }
|
||||
//
|
||||
// return r, nil
|
||||
// }
|
||||
|
||||
func (svc attachment) OpenOriginal(att *types.Attachment) (io.ReadSeekCloser, error) {
|
||||
if len(att.Url) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return svc.objects.Open(att.Url)
|
||||
}
|
||||
|
||||
func (svc attachment) OpenPreview(att *types.Attachment) (io.ReadSeekCloser, error) {
|
||||
if len(att.PreviewUrl) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return svc.objects.Open(att.PreviewUrl)
|
||||
}
|
||||
|
||||
func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (att *types.Attachment, err error) {
|
||||
var (
|
||||
ns *types.Namespace
|
||||
p *types.Page
|
||||
|
||||
aProps = &attachmentActionProps{
|
||||
namespace: &types.Namespace{ID: namespaceID},
|
||||
page: &types.Page{ID: pageID},
|
||||
}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if size == 0 {
|
||||
return AttachmentErrNotAllowedToCreateEmptyAttachment()
|
||||
}
|
||||
|
||||
ns, p, err = loadPageCombo(ctx, s, namespaceID, pageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setPage(p)
|
||||
|
||||
if !svc.ac.CanUpdatePage(ctx, p) {
|
||||
return AttachmentErrNotAllowedToUpdatePage()
|
||||
}
|
||||
|
||||
{
|
||||
// Verify size and type of the uploaded page attachment
|
||||
// Max size & allowed mime-types are pulled from the current settings
|
||||
var (
|
||||
maxSize = int64(systemService.CurrentSettings.Compose.Page.Attachments.MaxSize) * megabyte
|
||||
allowedTypes = systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes
|
||||
)
|
||||
|
||||
if err = svc.verifySizeAndMimetype(fh, size, maxSize, allowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
att = &types.Attachment{
|
||||
NamespaceID: namespaceID,
|
||||
Name: strings.TrimSpace(name),
|
||||
Kind: types.PageAttachment,
|
||||
}
|
||||
|
||||
return svc.create(ctx, s, name, size, fh, att)
|
||||
})
|
||||
|
||||
return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err)
|
||||
|
||||
}
|
||||
|
||||
func (svc attachment) CreateIconAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) {
|
||||
var (
|
||||
aProps = &attachmentActionProps{}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if size == 0 {
|
||||
return AttachmentErrNotAllowedToCreateEmptyAttachment()
|
||||
}
|
||||
|
||||
{
|
||||
// Verify size and type of the uploaded page attachment
|
||||
// Max size & allowed mime-types are pulled from the current settings
|
||||
var (
|
||||
maxSize = int64(systemService.CurrentSettings.Compose.Icon.Attachments.MaxSize) * megabyte
|
||||
allowedTypes = systemService.CurrentSettings.Compose.Icon.Attachments.Mimetypes
|
||||
)
|
||||
|
||||
if err = svc.verifySizeAndMimetype(fh, size, maxSize, allowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
att = &types.Attachment{
|
||||
Name: strings.TrimSpace(name),
|
||||
Kind: types.IconAttachment,
|
||||
}
|
||||
|
||||
return svc.create(ctx, s, name, size, fh, att)
|
||||
})
|
||||
|
||||
return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc attachment) verifySizeAndMimetype(fh io.ReadSeeker, size, maxSize int64, allowedTypes []string) (err error) {
|
||||
// Verify size and type of the uploaded page attachment
|
||||
// Max size & allowed mime-types are pulled from the current settings
|
||||
var (
|
||||
mimeType *mimetype.MIME
|
||||
)
|
||||
|
||||
if maxSize > 0 && maxSize < size {
|
||||
return AttachmentErrTooLarge().Apply(
|
||||
errors.Meta("size", size),
|
||||
errors.Meta("maxSize", maxSize),
|
||||
)
|
||||
}
|
||||
|
||||
if mimeType, err = svc.extractMimetype(fh); err != nil {
|
||||
return err
|
||||
} else if !svc.checkMimeType(mimeType, allowedTypes...) {
|
||||
return AttachmentErrNotAllowedToUploadThisType()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (att *types.Attachment, err error) {
|
||||
var (
|
||||
ns *types.Namespace
|
||||
m *types.Module
|
||||
r *types.Record
|
||||
|
||||
aProps = &attachmentActionProps{
|
||||
namespace: &types.Namespace{ID: namespaceID},
|
||||
module: &types.Module{ID: moduleID},
|
||||
record: &types.Record{ID: recordID},
|
||||
}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if size == 0 {
|
||||
return AttachmentErrNotAllowedToCreateEmptyAttachment()
|
||||
}
|
||||
|
||||
ns, m, err = loadModuleCombo(ctx, s, namespaceID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setModule(m)
|
||||
|
||||
if recordID > 0 {
|
||||
// Uploading to existing record
|
||||
//
|
||||
// To allow upload (attachment creation) user must have permissions to
|
||||
// alter that record
|
||||
|
||||
r, err = dalutils.ComposeRecordsFind(ctx, svc.dal, m, recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setRecord(r)
|
||||
|
||||
if !svc.ac.CanUpdateRecord(ctx, r) {
|
||||
return AttachmentErrNotAllowedToUpdateRecord()
|
||||
}
|
||||
} else {
|
||||
// Uploading to non-existing record
|
||||
//
|
||||
// To allow upload (attachment creation) user must have permissions to
|
||||
// create records
|
||||
if !svc.ac.CanCreateRecordOnModule(ctx, m) {
|
||||
return AttachmentErrNotAllowedToCreateRecords()
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Verify size and type of the uploaded record attachment
|
||||
// Max size & allowed mime-types are pulled from the current settings
|
||||
var (
|
||||
maxSize = int64(systemService.CurrentSettings.Compose.Record.Attachments.MaxSize) * megabyte
|
||||
allowedTypes = systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes
|
||||
mimeType *mimetype.MIME
|
||||
)
|
||||
|
||||
f := m.Fields.FindByName(fieldName)
|
||||
if f == nil || f.Kind != "File" {
|
||||
return AttachmentErrInvalidModuleField().Apply(
|
||||
errors.Meta("fieldName", fieldName),
|
||||
)
|
||||
}
|
||||
|
||||
if aux := f.Options.Int64("maxSize"); aux > 0 {
|
||||
maxSize = aux * megabyte
|
||||
}
|
||||
if aux := f.Options.String("mimetypes"); len(aux) > 0 {
|
||||
allowedTypes = strings.Split(aux, ",")
|
||||
}
|
||||
|
||||
if maxSize > 0 && maxSize < size {
|
||||
return AttachmentErrTooLarge().Apply(
|
||||
errors.Meta("size", size),
|
||||
errors.Meta("maxSize", maxSize),
|
||||
)
|
||||
}
|
||||
|
||||
if mimeType, err = svc.extractMimetype(fh); err != nil {
|
||||
return err
|
||||
} else if !svc.checkMimeType(mimeType, allowedTypes...) {
|
||||
return AttachmentErrNotAllowedToUploadThisType().Apply(errors.Meta("mimetype", mimeType))
|
||||
}
|
||||
}
|
||||
|
||||
att = &types.Attachment{
|
||||
NamespaceID: namespaceID,
|
||||
Name: strings.TrimSpace(name),
|
||||
Kind: types.RecordAttachment,
|
||||
}
|
||||
|
||||
return svc.create(ctx, s, name, size, fh, att)
|
||||
})
|
||||
|
||||
return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc attachment) CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) {
|
||||
var (
|
||||
aProps = &attachmentActionProps{}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if size == 0 {
|
||||
return AttachmentErrNotAllowedToCreateEmptyAttachment()
|
||||
}
|
||||
|
||||
if !svc.ac.CanCreateNamespace(ctx) {
|
||||
return AttachmentErrNotAllowedToUpdateNamespace()
|
||||
}
|
||||
|
||||
{
|
||||
// Verify file size and mimetype
|
||||
var (
|
||||
maxSize = int64(systemService.CurrentSettings.Compose.Namespace.Attachments.MaxSize) * megabyte
|
||||
allowedTypes = systemService.CurrentSettings.Compose.Namespace.Attachments.Mimetypes
|
||||
)
|
||||
|
||||
if err = svc.verifySizeAndMimetype(fh, size, maxSize, allowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
att = &types.Attachment{
|
||||
Name: strings.TrimSpace(name),
|
||||
Kind: types.NamespaceAttachment,
|
||||
}
|
||||
|
||||
// @todo limit upload on image/* only!
|
||||
|
||||
return svc.create(ctx, s, name, size, fh, att)
|
||||
})
|
||||
|
||||
return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc attachment) create(ctx context.Context, s store.ComposeAttachments, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) {
|
||||
var (
|
||||
aProps = &attachmentActionProps{}
|
||||
)
|
||||
|
||||
// preset attachment ID because we need ref for storage
|
||||
att.ID = nextID()
|
||||
att.CreatedAt = *now()
|
||||
|
||||
if att.OwnerID == 0 {
|
||||
att.OwnerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
}
|
||||
|
||||
if svc.objects == nil {
|
||||
return errors.Internal("cannot create attachment: store handler not set")
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
return AttachmentErrNotAllowedToCreateEmptyAttachment()
|
||||
}
|
||||
|
||||
aProps.setName(name)
|
||||
aProps.setSize(size)
|
||||
|
||||
// Extract extension but make sure path.Ext is not confused by any leading/trailing dots
|
||||
att.Meta.Original.Extension = strings.Trim(path.Ext(strings.Trim(name, ".")), ".")
|
||||
|
||||
att.Meta.Original.Size = size
|
||||
if att.Meta.Original.Mimetype, err = svc.extractMimetypeS(fh); err != nil {
|
||||
return AttachmentErrFailedToExtractMimeType(aProps).Wrap(err)
|
||||
}
|
||||
|
||||
att.Url = svc.objects.Original(att.ID, att.Meta.Original.Extension)
|
||||
aProps.setUrl(att.Url)
|
||||
|
||||
if err = svc.objects.Save(att.Url, fh); err != nil {
|
||||
return AttachmentErrFailedToStoreFile(aProps).Wrap(err)
|
||||
}
|
||||
|
||||
// Process image: extract width, height, make preview
|
||||
err = svc.processImage(fh, att)
|
||||
if err != nil {
|
||||
return AttachmentErrFailedToProcessImage(aProps).Wrap(err)
|
||||
}
|
||||
|
||||
if err = store.CreateComposeAttachment(ctx, s, att); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc attachment) extractMimetype(file io.ReadSeeker) (mType *mimetype.MIME, err error) {
|
||||
if _, err = file.Seek(0, 0); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure we rewind when we're done
|
||||
defer func(file io.ReadSeeker, offset int64, whence int) {
|
||||
_, _ = file.Seek(offset, whence)
|
||||
}(file, 0, 0)
|
||||
|
||||
var mime *mimetype.MIME
|
||||
if mime, err = mimetype.DetectReader(file); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return mime, nil
|
||||
}
|
||||
|
||||
func (svc attachment) extractMimetypeS(file io.ReadSeeker) (mType string, err error) {
|
||||
aux, err := svc.extractMimetype(file)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return aux.String(), nil
|
||||
}
|
||||
|
||||
func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) {
|
||||
if !strings.HasPrefix(att.Meta.Original.Mimetype, "image/") {
|
||||
// Only supporting previews from images (for now)
|
||||
return
|
||||
}
|
||||
|
||||
if att.Meta.Original.Mimetype == "image/svg+xml" {
|
||||
// SVG is a vector format, skip raster image processing/preview generation
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
iconMimetype = "image/x-icon"
|
||||
iconExtension = "ico"
|
||||
)
|
||||
|
||||
var (
|
||||
preview image.Image
|
||||
opts []imaging.EncodeOption
|
||||
format imaging.Format
|
||||
previewFormat imaging.Format
|
||||
animated bool
|
||||
f2m = map[imaging.Format]string{
|
||||
imaging.JPEG: "image/jpeg",
|
||||
imaging.GIF: "image/gif",
|
||||
}
|
||||
f2e = map[imaging.Format]string{
|
||||
imaging.JPEG: "jpg",
|
||||
imaging.GIF: "gif",
|
||||
}
|
||||
isIcon = att.Meta.Original.Mimetype == iconMimetype
|
||||
)
|
||||
|
||||
if _, err = original.Seek(0, 0); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if isIcon {
|
||||
preview, err = ico.Decode(original)
|
||||
if err != nil {
|
||||
return errors.Internal("Could not decode ico config").Wrap(err)
|
||||
}
|
||||
} else {
|
||||
if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil {
|
||||
return errors.Internal("could not get format from extension '%s'", att.Meta.Original.Extension).Wrap(err)
|
||||
}
|
||||
|
||||
previewFormat = format
|
||||
|
||||
if imaging.JPEG == format {
|
||||
// Rotate image if needed
|
||||
// if preview, _, err = exiffix.Decode(original); err != nil {
|
||||
// return fmt.Errorf("Could not decode EXIF from JPEG", err)
|
||||
// }
|
||||
preview, _, _ = exiffix.Decode(original)
|
||||
}
|
||||
|
||||
if imaging.GIF == format {
|
||||
// Decode all and check loops & delay to determine if GIF is animated or not
|
||||
if cfg, err := gif.DecodeAll(original); err == nil {
|
||||
animated = cfg.LoopCount > 0 || len(cfg.Delay) > 1
|
||||
|
||||
// Use first image for the preview
|
||||
preview = cfg.Image[0]
|
||||
} else {
|
||||
return errors.Internal("Could not decode gif config").Wrap(err)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Use GIF preview for GIFs and JPEG for everything else!
|
||||
previewFormat = imaging.JPEG
|
||||
|
||||
// Store with a bit lower quality
|
||||
opts = append(opts, imaging.JPEGQuality(85))
|
||||
}
|
||||
}
|
||||
|
||||
// In case of JPEG we decode the image and rotate it beforehand
|
||||
// other cases are handled here
|
||||
if preview == nil {
|
||||
if preview, err = imaging.Decode(original); err != nil {
|
||||
return errors.Internal("Could not decode original image").Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
var width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y
|
||||
att.SetOriginalImageMeta(width, height, animated)
|
||||
|
||||
if width > attachmentPreviewMaxWidth && width > height {
|
||||
// Landscape does not fit
|
||||
preview = imaging.Resize(preview, attachmentPreviewMaxWidth, 0, imaging.Lanczos)
|
||||
} else if height > attachmentPreviewMaxHeight {
|
||||
// Height does not fit
|
||||
preview = imaging.Resize(preview, 0, attachmentPreviewMaxHeight, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Get dimensions from the preview
|
||||
width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y
|
||||
|
||||
var buf = &bytes.Buffer{}
|
||||
if err = imaging.Encode(buf, preview, previewFormat, opts...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
meta := att.SetPreviewImageMeta(width, height, false)
|
||||
meta.Size = int64(buf.Len())
|
||||
|
||||
if isIcon {
|
||||
meta.Mimetype = iconMimetype
|
||||
meta.Extension = iconExtension
|
||||
} else {
|
||||
meta.Mimetype = f2m[previewFormat]
|
||||
meta.Extension = f2e[previewFormat]
|
||||
}
|
||||
|
||||
// Can and how we make a preview of this attachment?
|
||||
att.PreviewUrl = svc.objects.Preview(att.ID, meta.Extension)
|
||||
|
||||
return svc.objects.Save(att.PreviewUrl, buf)
|
||||
}
|
||||
|
||||
func (attachment) checkMimeType(test *mimetype.MIME, vv ...string) bool {
|
||||
if len(vv) == 0 {
|
||||
// return true if there are no type constraints to check against
|
||||
return true
|
||||
}
|
||||
|
||||
for _, v := range vv {
|
||||
if test.Is(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var _ AttachmentService = &attachment{}
|
||||
1439
server/compose/service/attachment_actions.gen.go
generated
Normal file
1439
server/compose/service/attachment_actions.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
163
server/compose/service/attachment_actions.yaml
Normal file
163
server/compose/service/attachment_actions.yaml
Normal file
@@ -0,0 +1,163 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:attachment
|
||||
service: attachment
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: alert
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: size
|
||||
type: int64
|
||||
- name: name
|
||||
- name: mimetype
|
||||
- name: url
|
||||
- name: attachment
|
||||
type: "*types.Attachment"
|
||||
fields: [ name, kind, url, previewUrl, meta, ownerID, ID, namespaceID ]
|
||||
- name: filter
|
||||
type: "*types.AttachmentFilter"
|
||||
fields: [ filter, kind, sort ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID ]
|
||||
- name: record
|
||||
type: "*types.Record"
|
||||
fields: [ ID, moduleID, namespaceID ]
|
||||
- name: page
|
||||
type: "*types.Page"
|
||||
fields: [ handle, title, ID ]
|
||||
- name: module
|
||||
type: "*types.Module"
|
||||
fields: [ handle, name, ID ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for attachments"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{attachment}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{attachment}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{attachment}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "attachment not found"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace not found"
|
||||
severity: warning
|
||||
|
||||
- error: moduleNotFound
|
||||
message: "module not found"
|
||||
severity: warning
|
||||
|
||||
- error: pageNotFound
|
||||
message: "page not found"
|
||||
severity: warning
|
||||
|
||||
- error: recordNotFound
|
||||
message: "record not found"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidModuleID
|
||||
message: "invalid module ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidModuleField
|
||||
message: "invalid module field"
|
||||
severity: warning
|
||||
|
||||
- error: invalidPageID
|
||||
message: "invalid page ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidRecordID
|
||||
message: "invalid record ID"
|
||||
severity: warning
|
||||
|
||||
- error: tooLarge
|
||||
message: "file too large"
|
||||
log: "could not upload this file, file size too large"
|
||||
|
||||
- error: notAllowedToUploadThisType
|
||||
message: "file type not allowed"
|
||||
log: "could not upload this file, file type not allowed"
|
||||
|
||||
- error: notAllowedToListAttachments
|
||||
message: "not allowed to list attachments"
|
||||
log: "could not list attachments; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create attachments"
|
||||
log: "could not create attachments; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreateEmptyAttachment
|
||||
message: "not allowed to create empty attachments"
|
||||
log: "failed to create attachment; empty file"
|
||||
|
||||
- error: failedToExtractMimeType
|
||||
message: "could not extract mime type"
|
||||
|
||||
- error: failedToStoreFile
|
||||
message: "could not extract store file"
|
||||
|
||||
- error: failedToProcessImage
|
||||
message: "could not process image"
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this module"
|
||||
log: "could not delete {{module}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list modules"
|
||||
log: "could not search or list modules; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadNamespace
|
||||
message: "not allowed to read this namespace"
|
||||
log: "could not delete {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadPage
|
||||
message: "not allowed to read this page"
|
||||
log: "could not read {{page}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadRecord
|
||||
message: "not allowed to read this record"
|
||||
log: "could not read {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdatePage
|
||||
message: "not allowed to update this page"
|
||||
log: "could not update {{page}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreateRecords
|
||||
message: "not allowed to create records"
|
||||
log: "could not create records; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdateRecord
|
||||
message: "not allowed to update this record"
|
||||
log: "could not update {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdateNamespace
|
||||
message: "not allowed to update this namespace"
|
||||
log: "could not update {{namespace}}; insufficient permissions"
|
||||
447
server/compose/service/chart.go
Normal file
447
server/compose/service/chart.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/handle"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/label"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
)
|
||||
|
||||
type (
|
||||
chart struct {
|
||||
actionlog actionlog.Recorder
|
||||
ac chartAccessController
|
||||
store store.Storer
|
||||
locale ResourceTranslationsManagerService
|
||||
}
|
||||
|
||||
chartAccessController interface {
|
||||
CanManageResourceTranslations(ctx context.Context) bool
|
||||
CanSearchChartsOnNamespace(context.Context, *types.Namespace) bool
|
||||
CanReadNamespace(context.Context, *types.Namespace) bool
|
||||
CanCreateChartOnNamespace(context.Context, *types.Namespace) bool
|
||||
CanReadChart(context.Context, *types.Chart) bool
|
||||
CanUpdateChart(context.Context, *types.Chart) bool
|
||||
CanDeleteChart(context.Context, *types.Chart) bool
|
||||
}
|
||||
|
||||
chartUpdateHandler func(ctx context.Context, ns *types.Namespace, c *types.Chart) (chartChanges, error)
|
||||
|
||||
chartChanges uint8
|
||||
)
|
||||
|
||||
const (
|
||||
chartUnchanged chartChanges = 0
|
||||
chartChanged chartChanges = 1
|
||||
chartLabelsChanged chartChanges = 2
|
||||
)
|
||||
|
||||
func Chart() *chart {
|
||||
return &chart{
|
||||
ac: DefaultAccessControl,
|
||||
actionlog: DefaultActionlog,
|
||||
store: DefaultStore,
|
||||
locale: DefaultResourceTranslation,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc chart) Find(ctx context.Context, filter types.ChartFilter) (set types.ChartSet, f types.ChartFilter, err error) {
|
||||
var (
|
||||
aProps = &chartActionProps{filter: &filter}
|
||||
ns *types.Namespace
|
||||
)
|
||||
|
||||
// For each fetched item, store backend will check if it is valid or not
|
||||
filter.Check = func(res *types.Chart) (bool, error) {
|
||||
if !svc.ac.CanReadChart(ctx, res) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = func() error {
|
||||
ns, err = loadNamespace(ctx, svc.store, filter.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
if !svc.ac.CanSearchChartsOnNamespace(ctx, ns) {
|
||||
return ChartErrNotAllowedToSearch()
|
||||
}
|
||||
|
||||
if len(filter.Labels) > 0 {
|
||||
filter.LabeledIDs, err = label.Search(
|
||||
ctx,
|
||||
svc.store,
|
||||
types.Chart{}.LabelResourceKind(),
|
||||
filter.Labels,
|
||||
id.Uints(filter.ChartID...)...,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// labels specified but no labeled resources found
|
||||
if len(filter.LabeledIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if set, f, err = store.SearchComposeCharts(ctx, svc.store, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, toLabeledCharts(set)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set.Walk(func(c *types.Chart) error {
|
||||
svc.proc(ctx, c)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return set, f, svc.recordAction(ctx, aProps, ChartActionSearch, err)
|
||||
}
|
||||
|
||||
func (svc chart) FindByID(ctx context.Context, namespaceID, chartID uint64) (c *types.Chart, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *chartActionProps) (*types.Chart, error) {
|
||||
return loadChart(ctx, svc.store, namespaceID, chartID)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc chart) FindByHandle(ctx context.Context, namespaceID uint64, h string) (c *types.Chart, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *chartActionProps) (*types.Chart, error) {
|
||||
if !handle.IsValid(h) {
|
||||
return nil, ChartErrInvalidHandle()
|
||||
}
|
||||
|
||||
aProps.chart.Handle = h
|
||||
return store.LookupComposeChartByNamespaceIDHandle(ctx, svc.store, namespaceID, h)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc chart) proc(ctx context.Context, c *types.Chart) {
|
||||
if svc.locale == nil || svc.locale.Locale() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
c.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, c.ResourceTranslation()))
|
||||
}
|
||||
|
||||
func (svc chart) Create(ctx context.Context, new *types.Chart) (*types.Chart, error) {
|
||||
var (
|
||||
err error
|
||||
ns *types.Namespace
|
||||
aProps = &chartActionProps{chart: new}
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if !handle.IsValid(new.Handle) {
|
||||
return ChartErrInvalidHandle()
|
||||
}
|
||||
|
||||
if ns, err = loadNamespace(ctx, s, new.NamespaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
|
||||
if !svc.ac.CanCreateChartOnNamespace(ctx, ns) {
|
||||
return ChartErrNotAllowedToCreate()
|
||||
}
|
||||
|
||||
if err = svc.uniqueCheck(ctx, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
new.ID = nextID()
|
||||
new.CreatedAt = *now()
|
||||
new.UpdatedAt = nil
|
||||
new.DeletedAt = nil
|
||||
|
||||
// generate config element IDs
|
||||
new.Config.GenerateIDs(nextID)
|
||||
|
||||
aProps.setChanged(new)
|
||||
|
||||
if err = store.CreateComposeChart(ctx, s, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, new.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Create(ctx, s, new); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return new, svc.recordAction(ctx, aProps, ChartActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc chart) Update(ctx context.Context, upd *types.Chart) (c *types.Chart, err error) {
|
||||
return svc.updater(ctx, upd.NamespaceID, upd.ID, ChartActionUpdate, svc.handleUpdate(ctx, upd))
|
||||
}
|
||||
|
||||
func (svc chart) DeleteByID(ctx context.Context, namespaceID, chartID uint64) error {
|
||||
return trim1st(svc.updater(ctx, namespaceID, chartID, ChartActionDelete, svc.handleDelete))
|
||||
}
|
||||
|
||||
func (svc chart) UndeleteByID(ctx context.Context, namespaceID, chartID uint64) error {
|
||||
return trim1st(svc.updater(ctx, namespaceID, chartID, ChartActionUndelete, svc.handleUndelete))
|
||||
}
|
||||
|
||||
// lookup fn() orchestrates chart lookup, namespace preload and check
|
||||
func (svc chart) lookup(ctx context.Context, namespaceID uint64, lookup func(*chartActionProps) (*types.Chart, error)) (c *types.Chart, err error) {
|
||||
var aProps = &chartActionProps{chart: &types.Chart{NamespaceID: namespaceID}}
|
||||
|
||||
err = func() error {
|
||||
if ns, err := loadNamespace(ctx, svc.store, namespaceID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
aProps.setNamespace(ns)
|
||||
}
|
||||
|
||||
if c, err = lookup(aProps); errors.IsNotFound(err) {
|
||||
return ChartErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setChart(c)
|
||||
|
||||
if !svc.ac.CanReadChart(ctx, c) {
|
||||
return ChartErrNotAllowedToRead()
|
||||
}
|
||||
|
||||
svc.proc(ctx, c)
|
||||
|
||||
if err = label.Load(ctx, svc.store, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return c, svc.recordAction(ctx, aProps, ChartActionLookup, err)
|
||||
}
|
||||
|
||||
func (svc chart) updater(ctx context.Context, namespaceID, chartID uint64, action func(...*chartActionProps) *chartAction, fn chartUpdateHandler) (*types.Chart, error) {
|
||||
var (
|
||||
changes chartChanges
|
||||
ns *types.Namespace
|
||||
c *types.Chart
|
||||
aProps = &chartActionProps{chart: &types.Chart{ID: chartID, NamespaceID: namespaceID}}
|
||||
err error
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, c, err = loadChartCombo(ctx, s, namespaceID, chartID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setChanged(c)
|
||||
|
||||
if changes, err = fn(ctx, ns, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate config element IDs if missing
|
||||
c.Config.GenerateIDs(nextID)
|
||||
|
||||
if changes&chartChanged > 0 {
|
||||
if err = store.UpdateComposeChart(ctx, s, c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, c.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes&chartLabelsChanged > 0 {
|
||||
if err = label.Update(ctx, s, c); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return c, svc.recordAction(ctx, aProps, action, err)
|
||||
}
|
||||
|
||||
func (svc chart) uniqueCheck(ctx context.Context, c *types.Chart) (err error) {
|
||||
if c.Handle != "" {
|
||||
if e, _ := store.LookupComposeChartByNamespaceIDHandle(ctx, svc.store, c.NamespaceID, c.Handle); e != nil && e.ID != c.ID {
|
||||
return ChartErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc chart) handleUpdate(ctx context.Context, upd *types.Chart) chartUpdateHandler {
|
||||
return func(ctx context.Context, ns *types.Namespace, res *types.Chart) (changes chartChanges, err error) {
|
||||
if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) {
|
||||
return chartUnchanged, ChartErrStaleData()
|
||||
}
|
||||
|
||||
if upd.Handle != res.Handle && !handle.IsValid(upd.Handle) {
|
||||
return chartUnchanged, ChartErrInvalidHandle()
|
||||
}
|
||||
|
||||
if err := svc.uniqueCheck(ctx, upd); err != nil {
|
||||
return chartUnchanged, err
|
||||
}
|
||||
|
||||
if !svc.ac.CanUpdateChart(ctx, res) {
|
||||
return chartUnchanged, ChartErrNotAllowedToUpdate()
|
||||
}
|
||||
|
||||
if res.Name != upd.Name {
|
||||
changes |= chartChanged
|
||||
res.Name = upd.Name
|
||||
}
|
||||
|
||||
if res.Handle != upd.Handle {
|
||||
changes |= chartChanged
|
||||
res.Handle = upd.Handle
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(upd.Config, res.Config) {
|
||||
changes |= chartChanged
|
||||
res.Config = upd.Config
|
||||
}
|
||||
|
||||
// Assure ReportIDs
|
||||
for i, r := range res.Config.Reports {
|
||||
if r.ReportID == 0 {
|
||||
r.ReportID = nextID()
|
||||
res.Config.Reports[i] = r
|
||||
|
||||
changes |= chartChanged
|
||||
}
|
||||
|
||||
// Ensure chart report metric IDs
|
||||
for j, m := range r.Metrics {
|
||||
if val, ok := m["metricID"]; !ok || val == 0 {
|
||||
m["metricID"] = strconv.FormatUint(nextID(), 10)
|
||||
res.Config.Reports[i].Metrics[j] = m
|
||||
|
||||
changes |= chartChanged
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes&chartChanged > 0 {
|
||||
res.UpdatedAt = now()
|
||||
}
|
||||
|
||||
if upd.Labels != nil {
|
||||
if label.Changed(res.Labels, upd.Labels) {
|
||||
changes |= chartLabelsChanged
|
||||
res.Labels = upd.Labels
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (svc chart) handleDelete(ctx context.Context, ns *types.Namespace, c *types.Chart) (chartChanges, error) {
|
||||
if !svc.ac.CanDeleteChart(ctx, c) {
|
||||
return chartUnchanged, ChartErrNotAllowedToDelete()
|
||||
}
|
||||
|
||||
if c.DeletedAt != nil {
|
||||
// chart already deleted
|
||||
return chartUnchanged, nil
|
||||
}
|
||||
|
||||
c.DeletedAt = now()
|
||||
return chartChanged, nil
|
||||
}
|
||||
|
||||
func (svc chart) handleUndelete(ctx context.Context, ns *types.Namespace, c *types.Chart) (chartChanges, error) {
|
||||
if !svc.ac.CanDeleteChart(ctx, c) {
|
||||
return chartUnchanged, ChartErrNotAllowedToUndelete()
|
||||
}
|
||||
|
||||
if c.DeletedAt == nil {
|
||||
// chart not deleted
|
||||
return chartUnchanged, nil
|
||||
}
|
||||
|
||||
c.DeletedAt = nil
|
||||
return chartChanged, nil
|
||||
}
|
||||
|
||||
func loadChartCombo(ctx context.Context, s interface {
|
||||
store.ComposeCharts
|
||||
store.ComposeNamespaces
|
||||
}, namespaceID, chartID uint64) (ns *types.Namespace, c *types.Chart, err error) {
|
||||
ns, err = loadNamespace(ctx, s, namespaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c, err = loadChart(ctx, s, namespaceID, chartID)
|
||||
return
|
||||
}
|
||||
|
||||
func loadChart(ctx context.Context, s store.ComposeCharts, namespaceID, chartID uint64) (res *types.Chart, err error) {
|
||||
if chartID == 0 || namespaceID == 0 {
|
||||
return nil, ChartErrInvalidID()
|
||||
}
|
||||
|
||||
if res, err = store.LookupComposeChartByID(ctx, s, chartID); errors.IsNotFound(err) {
|
||||
err = ChartErrNotFound()
|
||||
}
|
||||
|
||||
if err == nil && namespaceID != res.NamespaceID {
|
||||
// Make sure chart belongs to the right namespace
|
||||
return nil, ChartErrNotFound()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// toLabeledCharts converts to []label.LabeledResource
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func toLabeledCharts(set []*types.Chart) []label.LabeledResource {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ll := make([]label.LabeledResource, len(set))
|
||||
for i := range set {
|
||||
ll[i] = set[i]
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
964
server/compose/service/chart_actions.gen.go
generated
Normal file
964
server/compose/service/chart_actions.gen.go
generated
Normal file
@@ -0,0 +1,964 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
//
|
||||
// Definitions file that controls how this file is generated:
|
||||
// compose/service/chart_actions.yaml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
chartActionProps struct {
|
||||
chart *types.Chart
|
||||
changed *types.Chart
|
||||
filter *types.ChartFilter
|
||||
namespace *types.Namespace
|
||||
}
|
||||
|
||||
chartAction struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *chartActionProps
|
||||
}
|
||||
|
||||
chartLogMetaKey struct{}
|
||||
chartPropsMetaKey struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
// just a placeholder to cover template cases w/o fmt package use
|
||||
_ = fmt.Println
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
// setChart updates chartActionProps's chart
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *chartActionProps) setChart(chart *types.Chart) *chartActionProps {
|
||||
p.chart = chart
|
||||
return p
|
||||
}
|
||||
|
||||
// setChanged updates chartActionProps's changed
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *chartActionProps) setChanged(changed *types.Chart) *chartActionProps {
|
||||
p.changed = changed
|
||||
return p
|
||||
}
|
||||
|
||||
// setFilter updates chartActionProps's filter
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *chartActionProps) setFilter(filter *types.ChartFilter) *chartActionProps {
|
||||
p.filter = filter
|
||||
return p
|
||||
}
|
||||
|
||||
// setNamespace updates chartActionProps's namespace
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *chartActionProps) setNamespace(namespace *types.Namespace) *chartActionProps {
|
||||
p.namespace = namespace
|
||||
return p
|
||||
}
|
||||
|
||||
// Serialize converts chartActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p chartActionProps) Serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
)
|
||||
|
||||
if p.chart != nil {
|
||||
m.Set("chart.name", p.chart.Name, true)
|
||||
m.Set("chart.handle", p.chart.Handle, true)
|
||||
m.Set("chart.ID", p.chart.ID, true)
|
||||
m.Set("chart.namespaceID", p.chart.NamespaceID, true)
|
||||
}
|
||||
if p.changed != nil {
|
||||
m.Set("changed.name", p.changed.Name, true)
|
||||
m.Set("changed.handle", p.changed.Handle, true)
|
||||
m.Set("changed.ID", p.changed.ID, true)
|
||||
m.Set("changed.namespaceID", p.changed.NamespaceID, true)
|
||||
m.Set("changed.config", p.changed.Config, true)
|
||||
}
|
||||
if p.filter != nil {
|
||||
m.Set("filter.query", p.filter.Query, true)
|
||||
m.Set("filter.handle", p.filter.Handle, true)
|
||||
m.Set("filter.namespaceID", p.filter.NamespaceID, true)
|
||||
m.Set("filter.sort", p.filter.Sort, true)
|
||||
m.Set("filter.limit", p.filter.Limit, true)
|
||||
}
|
||||
if p.namespace != nil {
|
||||
m.Set("namespace.name", p.namespace.Name, true)
|
||||
m.Set("namespace.slug", p.namespace.Slug, true)
|
||||
m.Set("namespace.ID", p.namespace.ID, true)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p chartActionProps) Format(in string, err error) string {
|
||||
var (
|
||||
pairs = []string{"{{err}}"}
|
||||
// first non-empty string
|
||||
fns = func(ii ...interface{}) string {
|
||||
for _, i := range ii {
|
||||
if s := fmt.Sprintf("%v", i); len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
|
||||
if p.chart != nil {
|
||||
// replacement for "{{chart}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{chart}}",
|
||||
fns(
|
||||
p.chart.Name,
|
||||
p.chart.Handle,
|
||||
p.chart.ID,
|
||||
p.chart.NamespaceID,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{chart.name}}", fns(p.chart.Name))
|
||||
pairs = append(pairs, "{{chart.handle}}", fns(p.chart.Handle))
|
||||
pairs = append(pairs, "{{chart.ID}}", fns(p.chart.ID))
|
||||
pairs = append(pairs, "{{chart.namespaceID}}", fns(p.chart.NamespaceID))
|
||||
}
|
||||
|
||||
if p.changed != nil {
|
||||
// replacement for "{{changed}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{changed}}",
|
||||
fns(
|
||||
p.changed.Name,
|
||||
p.changed.Handle,
|
||||
p.changed.ID,
|
||||
p.changed.NamespaceID,
|
||||
p.changed.Config,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{changed.name}}", fns(p.changed.Name))
|
||||
pairs = append(pairs, "{{changed.handle}}", fns(p.changed.Handle))
|
||||
pairs = append(pairs, "{{changed.ID}}", fns(p.changed.ID))
|
||||
pairs = append(pairs, "{{changed.namespaceID}}", fns(p.changed.NamespaceID))
|
||||
pairs = append(pairs, "{{changed.config}}", fns(p.changed.Config))
|
||||
}
|
||||
|
||||
if p.filter != nil {
|
||||
// replacement for "{{filter}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{filter}}",
|
||||
fns(
|
||||
p.filter.Query,
|
||||
p.filter.Handle,
|
||||
p.filter.NamespaceID,
|
||||
p.filter.Sort,
|
||||
p.filter.Limit,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{filter.query}}", fns(p.filter.Query))
|
||||
pairs = append(pairs, "{{filter.handle}}", fns(p.filter.Handle))
|
||||
pairs = append(pairs, "{{filter.namespaceID}}", fns(p.filter.NamespaceID))
|
||||
pairs = append(pairs, "{{filter.sort}}", fns(p.filter.Sort))
|
||||
pairs = append(pairs, "{{filter.limit}}", fns(p.filter.Limit))
|
||||
}
|
||||
|
||||
if p.namespace != nil {
|
||||
// replacement for "{{namespace}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{namespace}}",
|
||||
fns(
|
||||
p.namespace.Name,
|
||||
p.namespace.Slug,
|
||||
p.namespace.ID,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{namespace.name}}", fns(p.namespace.Name))
|
||||
pairs = append(pairs, "{{namespace.slug}}", fns(p.namespace.Slug))
|
||||
pairs = append(pairs, "{{namespace.ID}}", fns(p.namespace.ID))
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (a *chartAction) String() string {
|
||||
var props = &chartActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.Format(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *chartAction) ToAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.Serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
// ChartActionSearch returns "compose:chart.search" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionSearch(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "search",
|
||||
log: "searched for charts",
|
||||
severity: actionlog.Info,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionLookup returns "compose:chart.lookup" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionLookup(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "lookup",
|
||||
log: "looked-up for a {{chart}}",
|
||||
severity: actionlog.Info,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionCreate returns "compose:chart.create" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionCreate(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "create",
|
||||
log: "created {{chart}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionUpdate returns "compose:chart.update" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionUpdate(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "update",
|
||||
log: "updated {{chart}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionDelete returns "compose:chart.delete" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionDelete(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "delete",
|
||||
log: "deleted {{chart}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionUndelete returns "compose:chart.undelete" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionUndelete(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "undelete",
|
||||
log: "undeleted {{chart}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// ChartActionReorder returns "compose:chart.reorder" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartActionReorder(props ...*chartActionProps) *chartAction {
|
||||
a := &chartAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:chart",
|
||||
action: "reorder",
|
||||
log: "reordered {{chart}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
// ChartErrGeneric returns "compose:chart.generic" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrGeneric(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("failed to complete request due to internal error", nil),
|
||||
|
||||
errors.Meta("type", "generic"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "{err}"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.generic"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotFound returns "compose:chart.notFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotFound(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("chart does not exist", nil),
|
||||
|
||||
errors.Meta("type", "notFound"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNamespaceNotFound returns "compose:chart.namespaceNotFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNamespaceNotFound(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("namespace does not exist", nil),
|
||||
|
||||
errors.Meta("type", "namespaceNotFound"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.namespaceNotFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrModuleNotFound returns "compose:chart.moduleNotFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrModuleNotFound(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("module does not exist", nil),
|
||||
|
||||
errors.Meta("type", "moduleNotFound"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.moduleNotFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrInvalidID returns "compose:chart.invalidID" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrInvalidID(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid ID", nil),
|
||||
|
||||
errors.Meta("type", "invalidID"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.invalidID"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrInvalidHandle returns "compose:chart.invalidHandle" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrInvalidHandle(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid handle", nil),
|
||||
|
||||
errors.Meta("type", "invalidHandle"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.invalidHandle"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrHandleNotUnique returns "compose:chart.handleNotUnique" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrHandleNotUnique(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("handle not unique", nil),
|
||||
|
||||
errors.Meta("type", "handleNotUnique"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "used duplicate handle ({{chart.handle}}) for chart"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.handleNotUnique"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrStaleData returns "compose:chart.staleData" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrStaleData(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("chart was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version", nil),
|
||||
|
||||
errors.Meta("type", "staleData"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.staleData"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrInvalidNamespaceID returns "compose:chart.invalidNamespaceID" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrInvalidNamespaceID(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid or missing namespace ID", nil),
|
||||
|
||||
errors.Meta("type", "invalidNamespaceID"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.invalidNamespaceID"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToRead returns "compose:chart.notAllowedToRead" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToRead(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to read this chart", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToRead"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not read {{chart}}; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToRead"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToSearch returns "compose:chart.notAllowedToSearch" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToSearch(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to search or list charts", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToSearch"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not search or list charts; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToSearch"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToReadNamespace returns "compose:chart.notAllowedToReadNamespace" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToReadNamespace(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to read this namespace", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToReadNamespace"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not read namespace {{namespace}}; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToReadNamespace"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToCreate returns "compose:chart.notAllowedToCreate" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToCreate(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to create charts", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToCreate"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not create charts; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToCreate"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToUpdate returns "compose:chart.notAllowedToUpdate" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToUpdate(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to update this chart", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToUpdate"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not update {{chart}}; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToUpdate"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToDelete returns "compose:chart.notAllowedToDelete" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToDelete(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to delete this chart", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToDelete"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not delete {{chart}}; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToDelete"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ChartErrNotAllowedToUndelete returns "compose:chart.notAllowedToUndelete" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ChartErrNotAllowedToUndelete(mm ...*chartActionProps) *errors.Error {
|
||||
var p = &chartActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to undelete this chart", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToUndelete"),
|
||||
errors.Meta("resource", "compose:chart"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(chartLogMetaKey{}, "could not undelete {{chart}}; insufficient permissions"),
|
||||
errors.Meta(chartPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "chart.errors.notAllowedToUndelete"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// It will wrap unrecognized/internal errors with generic errors.
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (svc chart) recordAction(ctx context.Context, props *chartActionProps, actionFn func(...*chartActionProps) *chartAction, err error) error {
|
||||
if svc.actionlog == nil || actionFn == nil {
|
||||
// action log disabled or no action fn passed, return error as-is
|
||||
return err
|
||||
} else if err == nil {
|
||||
// action completed w/o error, record it
|
||||
svc.actionlog.Record(ctx, actionFn(props).ToAction())
|
||||
return nil
|
||||
}
|
||||
|
||||
a := actionFn(props).ToAction()
|
||||
|
||||
// Extracting error information and recording it as action
|
||||
a.Error = err.Error()
|
||||
|
||||
switch c := err.(type) {
|
||||
case *errors.Error:
|
||||
m := c.Meta()
|
||||
|
||||
a.Error = err.Error()
|
||||
a.Severity = actionlog.Severity(m.AsInt("severity"))
|
||||
a.Description = props.Format(m.AsString(chartLogMetaKey{}), err)
|
||||
|
||||
if p, has := m[chartPropsMetaKey{}]; has {
|
||||
a.Meta = p.(*chartActionProps).Serialize()
|
||||
}
|
||||
|
||||
svc.actionlog.Record(ctx, a)
|
||||
default:
|
||||
svc.actionlog.Record(ctx, a)
|
||||
}
|
||||
|
||||
// Original error is passed on
|
||||
return err
|
||||
}
|
||||
113
server/compose/service/chart_actions.yaml
Normal file
113
server/compose/service/chart_actions.yaml
Normal file
@@ -0,0 +1,113 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:chart
|
||||
service: chart
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: chart
|
||||
type: "*types.Chart"
|
||||
fields: [ name, handle, ID, namespaceID ]
|
||||
- name: changed
|
||||
type: "*types.Chart"
|
||||
fields: [ name, handle, ID, namespaceID, config ]
|
||||
- name: filter
|
||||
type: "*types.ChartFilter"
|
||||
fields: [ query, handle, namespaceID, sort, limit ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for charts"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{chart}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{chart}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{chart}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{chart}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{chart}}"
|
||||
|
||||
- action: reorder
|
||||
log: "reordered {{chart}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "chart does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: moduleNotFound
|
||||
message: "module does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidHandle
|
||||
message: "invalid handle"
|
||||
severity: warning
|
||||
|
||||
- error: handleNotUnique
|
||||
message: "handle not unique"
|
||||
log: "used duplicate handle ({{chart.handle}}) for chart"
|
||||
severity: warning
|
||||
|
||||
- error: staleData
|
||||
message: "chart was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid or missing namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this chart"
|
||||
log: "could not read {{chart}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list charts"
|
||||
log: "could not search or list charts; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadNamespace
|
||||
message: "not allowed to read this namespace"
|
||||
log: "could not read namespace {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create charts"
|
||||
log: "could not create charts; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this chart"
|
||||
log: "could not update {{chart}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this chart"
|
||||
log: "could not delete {{chart}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this chart"
|
||||
log: "could not undelete {{chart}}; insufficient permissions"
|
||||
100
server/compose/service/chart_test.go
Normal file
100
server/compose/service/chart_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCharts(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
s, err = sqlite.ConnectInMemory(ctx)
|
||||
|
||||
namespaceID = nextID()
|
||||
ns *types.Namespace
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init sqlite in-memory db: %v", err)
|
||||
}
|
||||
|
||||
if err = store.Upgrade(ctx, zap.NewNop(), s); err != nil {
|
||||
t.Fatalf("failed to upgrade store: %v", err)
|
||||
}
|
||||
|
||||
if err = s.TruncateComposeNamespaces(ctx); err != nil {
|
||||
t.Fatalf("failed to truncate compose namespaces: %v", err)
|
||||
}
|
||||
|
||||
if err = s.TruncateComposeCharts(ctx); err != nil {
|
||||
t.Fatalf("failed to truncate compose charts: %v", err)
|
||||
}
|
||||
|
||||
ns = &types.Namespace{Name: "testing", ID: namespaceID, CreatedAt: *now()}
|
||||
if err = store.CreateComposeNamespace(ctx, s, ns); err != nil {
|
||||
t.Fatalf("failed to seed namespaces: %v", err)
|
||||
}
|
||||
|
||||
t.Run("crud", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
svc := &chart{
|
||||
store: s,
|
||||
ac: &accessControl{rbac: &rbac.ServiceAllowAll{}},
|
||||
}
|
||||
res, err := svc.Create(ctx, &types.Chart{Name: "My first chart", NamespaceID: namespaceID})
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
|
||||
res, err = svc.FindByID(ctx, namespaceID, res.ID)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
|
||||
res, err = svc.FindByHandle(ctx, namespaceID, res.Handle)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
|
||||
res.Name = "Changed"
|
||||
res, err = svc.Update(ctx, res)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
req.NotNil(res.UpdatedAt)
|
||||
req.Equal(res.Name, "Changed")
|
||||
|
||||
res, err = svc.FindByID(ctx, namespaceID, res.ID)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
req.Equal(res.Name, "Changed")
|
||||
|
||||
err = svc.DeleteByID(ctx, namespaceID, res.ID)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
|
||||
// this works because we're allowed to do everything
|
||||
res, err = svc.FindByID(ctx, namespaceID, res.ID)
|
||||
req.NoError(unwrapChartInternal(err))
|
||||
req.NotNil(res)
|
||||
req.NotNil(res.DeletedAt)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func unwrapChartInternal(err error) error {
|
||||
g := ChartErrGeneric()
|
||||
for {
|
||||
if errors.Is(err, g) {
|
||||
err = errors.Unwrap(err)
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
21
server/compose/service/dal_interfaces.go
Normal file
21
server/compose/service/dal_interfaces.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
)
|
||||
|
||||
type (
|
||||
dalDater interface {
|
||||
Create(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, vv ...dal.ValueGetter) error
|
||||
Update(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, rr ...dal.ValueGetter) (err error)
|
||||
Search(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, f filter.Filter) (dal.Iterator, error)
|
||||
Count(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, f filter.Filter) (uint, error)
|
||||
Run(ctx context.Context, pp dal.Pipeline) (dal.Iterator, error)
|
||||
Lookup(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, lookup dal.ValueGetter, dst dal.ValueSetter) (err error)
|
||||
Delete(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, pkv ...dal.ValueGetter) (err error)
|
||||
Truncate(ctx context.Context, m dal.ModelRef, operations dal.OperationSet) (err error)
|
||||
}
|
||||
)
|
||||
84
server/compose/service/data_privacy.go
Normal file
84
server/compose/service/data_privacy.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
sysService "github.com/cortezaproject/corteza/server/system/service"
|
||||
sysTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
dataPrivacy struct {
|
||||
ns NamespaceService
|
||||
m ModuleService
|
||||
dalConn dalConnectionService
|
||||
locale ResourceTranslationsManagerService
|
||||
}
|
||||
|
||||
dalConnectionService interface {
|
||||
FindByID(ctx context.Context, ID uint64) (q *sysTypes.DalConnection, err error)
|
||||
}
|
||||
|
||||
DataPrivacyService interface {
|
||||
FindModules(ctx context.Context, filter types.PrivacyModuleFilter) (types.PrivacyModuleSet, types.PrivacyModuleFilter, error)
|
||||
}
|
||||
)
|
||||
|
||||
func DataPrivacy() *dataPrivacy {
|
||||
return &dataPrivacy{
|
||||
ns: DefaultNamespace,
|
||||
m: DefaultModule,
|
||||
dalConn: sysService.DefaultDalConnection,
|
||||
locale: DefaultResourceTranslation,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc dataPrivacy) FindModules(ctx context.Context, filter types.PrivacyModuleFilter) (out types.PrivacyModuleSet, f types.PrivacyModuleFilter, err error) {
|
||||
var (
|
||||
modules []types.PrivacyModule
|
||||
cc = make(map[uint64]*sysTypes.DalConnection, 0)
|
||||
)
|
||||
|
||||
namespaces, _, err := svc.ns.Find(ctx, types.NamespaceFilter{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, n := range namespaces {
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
n.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, n.ResourceTranslation()))
|
||||
|
||||
modules, f, err = svc.m.SearchSensitive(ctx, types.PrivacyModuleFilter{
|
||||
NamespaceID: n.ID,
|
||||
ConnectionID: filter.ConnectionID,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(modules) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, m := range modules {
|
||||
connID := m.ConnectionID
|
||||
if val, ok := cc[connID]; ok {
|
||||
m.Connection = val
|
||||
} else {
|
||||
m.Connection, err = svc.dalConn.FindByID(ctx, connID)
|
||||
if err != nil {
|
||||
cc[connID] = m.Connection
|
||||
}
|
||||
}
|
||||
|
||||
m.Namespace = types.PrivacyNamespaceMeta{
|
||||
ID: n.ID,
|
||||
Slug: n.Slug,
|
||||
Name: n.Name,
|
||||
}
|
||||
out = append(out, &m)
|
||||
}
|
||||
}
|
||||
|
||||
return out, f, nil
|
||||
}
|
||||
22
server/compose/service/event/compose.go
Normal file
22
server/compose/service/event/compose.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/scheduler"
|
||||
)
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res composeOnInterval) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return scheduler.OnInterval(c.Values()...)
|
||||
}
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res composeOnTimestamp) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return scheduler.OnTimestamp(c.Values()...)
|
||||
}
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res composeBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
// We do not support any matchers, so if there is a constraint, fail.
|
||||
return false
|
||||
}
|
||||
39
server/compose/service/event/compose_test.go
Normal file
39
server/compose/service/event/compose_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComposeOnIntervalMatching(t *testing.T) {
|
||||
var (
|
||||
res = &composeOnInterval{}
|
||||
cInt = eventbus.MustMakeConstraint("", "", "* * * * *")
|
||||
)
|
||||
|
||||
// Just make sure it runs
|
||||
// not bothering with precise test setup (scheduler's tests cover all that
|
||||
res.Match(cInt)
|
||||
}
|
||||
|
||||
func TestComposeOnTimestampMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &composeOnInterval{}
|
||||
|
||||
cTStamp = eventbus.MustMakeConstraint("", "", "2000-01-01T00:00:00Z") // Y2k!
|
||||
)
|
||||
|
||||
a.False(res.Match(cTStamp), "Year 2000?! How did we get here? Anyhow, happy New year!")
|
||||
}
|
||||
|
||||
func TestComposeMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &composeBase{}
|
||||
)
|
||||
|
||||
a.False(res.Match(eventbus.MustMakeConstraint("foo", "", "bar")))
|
||||
}
|
||||
3396
server/compose/service/event/events.gen.go
generated
Normal file
3396
server/compose/service/event/events.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
server/compose/service/event/events.yaml
Normal file
106
server/compose/service/event/events.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
compose:
|
||||
on: ['manual', 'interval', 'timestamp']
|
||||
|
||||
compose:namespace:
|
||||
on: ['manual']
|
||||
ba: ['create', 'update', 'delete']
|
||||
props:
|
||||
- name: 'namespace'
|
||||
type: '*types.Namespace'
|
||||
- name: 'oldNamespace'
|
||||
type: '*types.Namespace'
|
||||
immutable: true
|
||||
constraints:
|
||||
- name: namespace.handle
|
||||
- name: namespace.name
|
||||
|
||||
compose:page:
|
||||
on: ['manual']
|
||||
ba: ['create', 'update', 'delete']
|
||||
props:
|
||||
- name: 'page'
|
||||
type: '*types.Page'
|
||||
- name: 'oldPage'
|
||||
type: '*types.Page'
|
||||
immutable: true
|
||||
- name: 'namespace'
|
||||
type: '*types.Namespace'
|
||||
immutable: true
|
||||
- name: 'selected'
|
||||
type: '[]interface{}'
|
||||
immutable: true
|
||||
constraints:
|
||||
- name: namespace.handle
|
||||
- name: namespace.name
|
||||
- name: page.handle
|
||||
- name: page.name
|
||||
|
||||
compose:page-layout:
|
||||
on: ['manual']
|
||||
ba: ['create', 'update', 'delete']
|
||||
props:
|
||||
- name: 'pageLayout'
|
||||
type: '*types.PageLayout'
|
||||
- name: 'oldPageLayout'
|
||||
type: '*types.PageLayout'
|
||||
immutable: true
|
||||
- name: 'namespace'
|
||||
type: '*types.Namespace'
|
||||
immutable: true
|
||||
- name: 'selected'
|
||||
type: '[]interface{}'
|
||||
immutable: true
|
||||
constraints:
|
||||
- name: namespace.handle
|
||||
- name: namespace.name
|
||||
- name: pageLayout.handle
|
||||
- name: pageLayout.name
|
||||
|
||||
compose:module:
|
||||
on: ['manual']
|
||||
ba: ['create', 'update', 'delete']
|
||||
props:
|
||||
- name: 'module'
|
||||
type: '*types.Module'
|
||||
- name: 'oldModule'
|
||||
type: '*types.Module'
|
||||
immutable: true
|
||||
- name: 'namespace'
|
||||
type: '*types.Namespace'
|
||||
immutable: true
|
||||
constraints:
|
||||
- name: namespace.handle
|
||||
- name: namespace.name
|
||||
- name: module.handle
|
||||
- name: module.name
|
||||
|
||||
compose:record:
|
||||
on: ['manual', 'iteration']
|
||||
ba: ['create', 'update', 'delete', 'undelete', 'organize']
|
||||
props:
|
||||
- name: 'record'
|
||||
type: '*types.Record'
|
||||
- name: 'oldRecord'
|
||||
type: '*types.Record'
|
||||
immutable: true
|
||||
- name: 'module'
|
||||
type: '*types.Module'
|
||||
immutable: true
|
||||
- name: 'namespace'
|
||||
type: '*types.Namespace'
|
||||
immutable: true
|
||||
- name: 'recordValueErrors'
|
||||
type: '*types.RecordValueErrorSet'
|
||||
- name: 'selected'
|
||||
type: '[]interface{}'
|
||||
immutable: true
|
||||
|
||||
constraints:
|
||||
- name: namespace.handle
|
||||
- name: namespace.name
|
||||
- name: module.handle
|
||||
- name: module.name
|
||||
- name: record.created-at
|
||||
- name: record.updated-at
|
||||
- name: record.deleted-at
|
||||
- name: record.values.*
|
||||
26
server/compose/service/event/module.go
Normal file
26
server/compose/service/event/module.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
)
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res moduleBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return eventbus.MatchFirst(
|
||||
func() bool { return moduleMatch(res.module, c) },
|
||||
func() bool { return namespaceMatch(res.namespace, c) },
|
||||
)
|
||||
}
|
||||
|
||||
// Handles module matchers
|
||||
func moduleMatch(r *types.Module, c eventbus.ConstraintMatcher) bool {
|
||||
switch c.Name() {
|
||||
case "module", "module.handle":
|
||||
return c.Match(r.Handle)
|
||||
case "module.name":
|
||||
return c.Match(r.Name)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
25
server/compose/service/event/module_test.go
Normal file
25
server/compose/service/event/module_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestModuleMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &moduleBase{
|
||||
module: &types.Module{Handle: "mh1"},
|
||||
namespace: &types.Namespace{Slug: "slg1"},
|
||||
}
|
||||
|
||||
cMod = eventbus.MustMakeConstraint("module", "eq", "mh1")
|
||||
cNms = eventbus.MustMakeConstraint("namespace", "eq", "slg1")
|
||||
)
|
||||
|
||||
a.True(res.Match(cMod))
|
||||
a.True(res.Match(cNms))
|
||||
}
|
||||
23
server/compose/service/event/namespace.go
Normal file
23
server/compose/service/event/namespace.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
)
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res namespaceBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return namespaceMatch(res.namespace, c)
|
||||
}
|
||||
|
||||
// Handles namespace matchers
|
||||
func namespaceMatch(r *types.Namespace, c eventbus.ConstraintMatcher) bool {
|
||||
switch c.Name() {
|
||||
case "namespace", "namespace.slug", "namespace.handle":
|
||||
return c.Match(r.Slug)
|
||||
case "namespace.name":
|
||||
return c.Match(r.Name)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
22
server/compose/service/event/namespace_test.go
Normal file
22
server/compose/service/event/namespace_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNamespaceMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &namespaceBase{
|
||||
namespace: &types.Namespace{Slug: "slg1"},
|
||||
}
|
||||
|
||||
cNms = eventbus.MustMakeConstraint("namespace", "eq", "slg1")
|
||||
)
|
||||
|
||||
a.True(res.Match(cNms))
|
||||
}
|
||||
26
server/compose/service/event/page.go
Normal file
26
server/compose/service/event/page.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
)
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res pageBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return eventbus.MatchFirst(
|
||||
func() bool { return pageMatch(res.page, c) },
|
||||
func() bool { return namespaceMatch(res.namespace, c) },
|
||||
)
|
||||
}
|
||||
|
||||
// Handles namespace matchers
|
||||
func pageMatch(r *types.Page, c eventbus.ConstraintMatcher) bool {
|
||||
switch c.Name() {
|
||||
case "page", "page.handle":
|
||||
return c.Match(r.Handle)
|
||||
case "page.title":
|
||||
return c.Match(r.Title)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
19
server/compose/service/event/page_layout.go
Normal file
19
server/compose/service/event/page_layout.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
)
|
||||
|
||||
var _ = eventbus.ConstraintMaker
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res pageLayoutBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
// By default we match no mather what kind of constraints we receive
|
||||
//
|
||||
// Function will be called multiple times - once for every trigger constraint
|
||||
// All should match (return true):
|
||||
// constraint#1 AND constraint#2 AND constraint#3 ...
|
||||
//
|
||||
// When there are multiple values, Match() can decide how to treat them (OR, AND...)
|
||||
return true
|
||||
}
|
||||
25
server/compose/service/event/page_test.go
Normal file
25
server/compose/service/event/page_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPageMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &pageBase{
|
||||
page: &types.Page{Handle: "ph1"},
|
||||
namespace: &types.Namespace{Slug: "slg1"},
|
||||
}
|
||||
|
||||
cPge = eventbus.MustMakeConstraint("page", "eq", "ph1")
|
||||
cNms = eventbus.MustMakeConstraint("namespace", "eq", "slg1")
|
||||
)
|
||||
|
||||
a.True(res.Match(cPge))
|
||||
a.True(res.Match(cNms))
|
||||
}
|
||||
43
server/compose/service/event/record.go
Normal file
43
server/compose/service/event/record.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
recordMatchValues = "record.values."
|
||||
)
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res recordBase) Match(c eventbus.ConstraintMatcher) bool {
|
||||
return eventbus.MatchFirst(
|
||||
func() bool { return recordMatch(res.record, c) },
|
||||
func() bool { return moduleMatch(res.module, c) },
|
||||
func() bool { return namespaceMatch(res.namespace, c) },
|
||||
)
|
||||
}
|
||||
|
||||
func recordMatch(r *types.Record, c eventbus.ConstraintMatcher) bool {
|
||||
switch c.Name() {
|
||||
case "record.updatedAt", "record.updated-at":
|
||||
return c.Match(r.UpdatedAt.Format(time.RFC3339))
|
||||
case "record.createdAt", "record.created-at":
|
||||
return c.Match(r.CreatedAt.Format(time.RFC3339))
|
||||
case "record.deletedAt", "record.deleted-at":
|
||||
return c.Match(r.DeletedAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.Name(), recordMatchValues) {
|
||||
fieldName := c.Name()[len(recordMatchValues):]
|
||||
for _, v := range r.Values.FilterByName(fieldName) {
|
||||
if c.Match(v.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
58
server/compose/service/event/record_test.go
Normal file
58
server/compose/service/event/record_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecordMatching(t *testing.T) {
|
||||
var (
|
||||
a = assert.New(t)
|
||||
res = &recordBase{
|
||||
module: &types.Module{Handle: "mh1"},
|
||||
namespace: &types.Namespace{Slug: "slg1"},
|
||||
}
|
||||
|
||||
cMod = eventbus.MustMakeConstraint("module", "eq", "mh1")
|
||||
cNms = eventbus.MustMakeConstraint("namespace", "eq", "slg1")
|
||||
)
|
||||
|
||||
a.True(res.Match(cMod))
|
||||
a.True(res.Match(cNms))
|
||||
}
|
||||
|
||||
func TestRecordMatchValues(t *testing.T) {
|
||||
var (
|
||||
rec = &types.Record{
|
||||
Values: types.RecordValueSet{
|
||||
&types.RecordValue{Name: "fld1", Value: "val1"},
|
||||
&types.RecordValue{Name: "fld2", Value: "val2"},
|
||||
},
|
||||
}
|
||||
|
||||
res = &recordBase{record: rec}
|
||||
|
||||
cases = []struct {
|
||||
match bool
|
||||
name string
|
||||
op string
|
||||
v string
|
||||
}{
|
||||
{true, "fld1", "eq", "val1"},
|
||||
{false, "fld2", "eq", "val1"},
|
||||
{false, "fld1", "!=", "val1"},
|
||||
}
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(
|
||||
fmt.Sprintf("(%s %s %s) == %v", c.name, c.op, c.v, c.match),
|
||||
func(t *testing.T) {
|
||||
assert.Equal(t, c.match, res.Match(eventbus.MustMakeConstraint("record.values."+c.name, c.op, c.v)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
143
server/compose/service/export_session.go
Normal file
143
server/compose/service/export_session.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
exportSessionTTL = time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
RecordExportSession struct {
|
||||
SessionID uint64 `json:"sessionID,string"`
|
||||
UserID uint64 `json:"userID,string"`
|
||||
|
||||
NamespaceID uint64 `json:"namespaceID,string"`
|
||||
ModuleID uint64 `json:"moduleID,string"`
|
||||
|
||||
Filename string `json:"filename"`
|
||||
Ext string `json:"ext"`
|
||||
|
||||
Filter string `json:"filter"`
|
||||
Fields []string `json:"fields"`
|
||||
Timezone string `json:"timezone"`
|
||||
MultiValueDelimiter string `json:"multiValueDelimiter"`
|
||||
WrapMultiValue string `json:"wrapMultiValue"`
|
||||
ResolveRefs bool `json:"resolveRefs"`
|
||||
IncludeRefID bool `json:"includeRefID"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
exportSessionSet []*RecordExportSession
|
||||
|
||||
exportSession struct {
|
||||
l sync.Mutex
|
||||
sessions exportSessionSet
|
||||
}
|
||||
|
||||
ExportSessionService interface {
|
||||
Create(ctx context.Context, s *RecordExportSession) (*RecordExportSession, error)
|
||||
FindByID(ctx context.Context, sessionID uint64) (*RecordExportSession, error)
|
||||
DeleteByID(ctx context.Context, sessionID uint64) error
|
||||
}
|
||||
)
|
||||
|
||||
func ExportSession() *exportSession {
|
||||
svc := &exportSession{
|
||||
sessions: exportSessionSet{},
|
||||
}
|
||||
|
||||
go svc.cleanupLoop(context.Background())
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (svc *exportSession) indexOf(userID, sessionID uint64) int {
|
||||
for i, r := range svc.sessions {
|
||||
if r.SessionID == sessionID && r.UserID == userID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (svc *exportSession) Create(ctx context.Context, s *RecordExportSession) (*RecordExportSession, error) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
s.SessionID = nextID()
|
||||
s.UserID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
s.CreatedAt = time.Now()
|
||||
s.UpdatedAt = s.CreatedAt
|
||||
|
||||
svc.sessions = append(svc.sessions, s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (svc *exportSession) FindByID(ctx context.Context, sessionID uint64) (*RecordExportSession, error) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
userID := auth.GetIdentityFromContext(ctx).Identity()
|
||||
i := svc.indexOf(userID, sessionID)
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("compose.service.RecordExportSessionNotFound")
|
||||
}
|
||||
|
||||
s := svc.sessions[i]
|
||||
if time.Since(s.UpdatedAt) > exportSessionTTL {
|
||||
svc.sessions = removeExportSession(svc.sessions, i)
|
||||
return nil, fmt.Errorf("compose.service.RecordExportSessionNotFound")
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (svc *exportSession) DeleteByID(ctx context.Context, sessionID uint64) error {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
userID := auth.GetIdentityFromContext(ctx).Identity()
|
||||
i := svc.indexOf(userID, sessionID)
|
||||
if i >= 0 {
|
||||
svc.sessions = removeExportSession(svc.sessions, i)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeExportSession(s exportSessionSet, i int) exportSessionSet {
|
||||
s[len(s)-1], s[i] = s[i], s[len(s)-1]
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
func (svc *exportSession) cleanupLoop(ctx context.Context) {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
svc.clean()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *exportSession) clean() {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
for i := len(svc.sessions) - 1; i >= 0; i-- {
|
||||
if time.Since(svc.sessions[i].UpdatedAt) > exportSessionTTL {
|
||||
svc.sessions = removeExportSession(svc.sessions, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
server/compose/service/helpers_test.go
Normal file
38
server/compose/service/helpers_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
sysTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resourceMaker(ctx context.Context, t *testing.T, s store.Storer, mods ...any) {
|
||||
req := require.New(t)
|
||||
|
||||
for _, m := range mods {
|
||||
switch c := m.(type) {
|
||||
case *sysTypes.User:
|
||||
t.Log("creating User")
|
||||
req.NoError(store.CreateUser(ctx, s, c))
|
||||
|
||||
case *sysTypes.Role:
|
||||
t.Log("creating Role")
|
||||
req.NoError(store.CreateRole(ctx, s, c))
|
||||
|
||||
case *types.Namespace:
|
||||
t.Log("creating Namespace")
|
||||
req.NoError(store.CreateComposeNamespace(ctx, s, c))
|
||||
|
||||
case *types.Module:
|
||||
t.Log("creating Module")
|
||||
req.NoError(store.CreateComposeModule(ctx, s, c))
|
||||
|
||||
case *types.ModuleField:
|
||||
t.Log("creating ModuleField")
|
||||
req.NoError(store.CreateComposeModuleField(ctx, s, c))
|
||||
}
|
||||
}
|
||||
}
|
||||
156
server/compose/service/import_session.go
Normal file
156
server/compose/service/import_session.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/envoyx"
|
||||
|
||||
envoyCsv "github.com/cortezaproject/corteza/server/pkg/envoyx/csv"
|
||||
envoyJson "github.com/cortezaproject/corteza/server/pkg/envoyx/json"
|
||||
)
|
||||
|
||||
type (
|
||||
recordSet []*RecordImportSession
|
||||
|
||||
importSession struct {
|
||||
l sync.Mutex
|
||||
records recordSet
|
||||
}
|
||||
|
||||
countableProvider interface {
|
||||
envoyx.Provider
|
||||
Count() uint64
|
||||
Fields() []string
|
||||
}
|
||||
|
||||
ImportSessionService interface {
|
||||
Create(ctx context.Context, f io.ReadSeeker, name, contentType string, namespaceID, moduleID uint64) (*RecordImportSession, error)
|
||||
FindByID(ctx context.Context, sessionID uint64) (*RecordImportSession, error)
|
||||
DeleteByID(ctx context.Context, sessionID uint64) error
|
||||
}
|
||||
)
|
||||
|
||||
func ImportSession() *importSession {
|
||||
return &importSession{
|
||||
records: recordSet{},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *importSession) indexOf(userID, sessionID uint64) int {
|
||||
for i, r := range svc.records {
|
||||
if r.SessionID == sessionID && r.UserID == userID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (svc *importSession) Create(ctx context.Context, f io.ReadSeeker, name, contentType string, namespaceID, moduleID uint64) (_ *RecordImportSession, err error) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
// Prepare the session
|
||||
sh := &RecordImportSession{
|
||||
Name: name,
|
||||
SessionID: nextID(),
|
||||
UserID: auth.GetIdentityFromContext(ctx).Identity(),
|
||||
NamespaceID: namespaceID,
|
||||
ModuleID: moduleID,
|
||||
|
||||
OnError: IMPORT_ON_ERROR_FAIL,
|
||||
Fields: make(map[string]string),
|
||||
Progress: &RecordImportProgress{},
|
||||
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
rd, err := func() (pp countableProvider, err error) {
|
||||
if envoyCsv.CanDecodeFile(f) || envoyCsv.CanDecodeMime(contentType) {
|
||||
f.Seek(0, 0)
|
||||
return envoyCsv.Decoder(f, name, nil)
|
||||
}
|
||||
|
||||
if envoyJson.CanDecodeFile(f) || envoyJson.CanDecodeMime(contentType) {
|
||||
f.Seek(0, 0)
|
||||
return envoyJson.Decoder(f, name)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("compose.service.RecordImportFormatNotSupported")
|
||||
}()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sh.Providers = append(sh.Providers, rd)
|
||||
|
||||
prepKey := func(k string) string {
|
||||
return strings.TrimSpace(strings.ToLower(k))
|
||||
}
|
||||
|
||||
sh.Progress.EntryCount = rd.Count()
|
||||
for _, f := range rd.Fields() {
|
||||
sh.Fields[f] = ""
|
||||
|
||||
// @todo improve this bit
|
||||
k := prepKey(f)
|
||||
if k == "id" || k == "recordid" || k == "record_id" {
|
||||
sh.Key = f
|
||||
}
|
||||
}
|
||||
|
||||
// Create it
|
||||
svc.records = append(svc.records, sh)
|
||||
return sh, nil
|
||||
}
|
||||
|
||||
func (svc *importSession) FindByID(ctx context.Context, sessionID uint64) (*RecordImportSession, error) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
userID := auth.GetIdentityFromContext(ctx).Identity()
|
||||
i := svc.indexOf(userID, sessionID)
|
||||
if i >= 0 {
|
||||
return svc.records[i], nil
|
||||
}
|
||||
return nil, fmt.Errorf("compose.service.RecordImportSessionNotFound")
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/37335777
|
||||
func remove(s recordSet, i int) recordSet {
|
||||
s[len(s)-1], s[i] = s[i], s[len(s)-1]
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
func (svc *importSession) DeleteByID(ctx context.Context, sessionID uint64) error {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
userID := auth.GetIdentityFromContext(ctx).Identity()
|
||||
i := svc.indexOf(userID, sessionID)
|
||||
|
||||
if i >= 0 {
|
||||
svc.records = remove(svc.records, i)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @todo run this in some interval
|
||||
func (svc *importSession) clean(ctx context.Context) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
for i := len(svc.records) - 1; i >= 0; i-- {
|
||||
r := svc.records[i]
|
||||
if time.Now().After(r.UpdatedAt.Add(time.Hour * 24 * 3)) {
|
||||
svc.records = remove(svc.records, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
349
server/compose/service/locale.gen.go
generated
Normal file
349
server/compose/service/locale.gen.go
generated
Normal file
@@ -0,0 +1,349 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
//
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
intAuth "github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type (
|
||||
localeAccessControl interface {
|
||||
CanManageResourceTranslations(ctx context.Context) bool
|
||||
}
|
||||
|
||||
resourceTranslationsManager struct {
|
||||
actionlog actionlog.Recorder
|
||||
locale locale.Resource
|
||||
store store.Storer
|
||||
ac localeAccessController
|
||||
}
|
||||
|
||||
localeAccessController interface {
|
||||
CanManageResourceTranslations(context.Context) bool
|
||||
}
|
||||
|
||||
ResourceTranslationsManagerService interface {
|
||||
Chart(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error)
|
||||
Module(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error)
|
||||
Namespace(ctx context.Context, id uint64) (locale.ResourceTranslationSet, error)
|
||||
Page(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error)
|
||||
PageLayout(ctx context.Context, namespaceID uint64, pageID uint64, id uint64) (locale.ResourceTranslationSet, error)
|
||||
|
||||
Upsert(context.Context, locale.ResourceTranslationSet) error
|
||||
Locale() locale.Resource
|
||||
}
|
||||
)
|
||||
|
||||
var ErrNotAllowedToManageResourceTranslations = errors.Unauthorized("not allowed to manage resource translations")
|
||||
|
||||
func ResourceTranslationsManager(ls locale.Resource) *resourceTranslationsManager {
|
||||
return &resourceTranslationsManager{
|
||||
actionlog: DefaultActionlog,
|
||||
store: DefaultStore,
|
||||
ac: DefaultAccessControl,
|
||||
locale: ls,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Upsert(ctx context.Context, rr locale.ResourceTranslationSet) (err error) {
|
||||
// User is allowed to manage resource translations when:
|
||||
// - managed resource translation strings are all for default language
|
||||
// or
|
||||
// - user is allowed to manage resource translations
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if rr.ContainsForeign(svc.Locale().Default().Tag) {
|
||||
if !svc.ac.CanManageResourceTranslations(ctx) {
|
||||
return ErrNotAllowedToManageResourceTranslations
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range rr {
|
||||
r.Msg = locale.SanitizeMessage(r.Msg)
|
||||
}
|
||||
|
||||
// @todo validation
|
||||
|
||||
me := intAuth.GetIdentityFromContext(ctx)
|
||||
|
||||
// - group by resource
|
||||
localeByRes := make(map[string]locale.ResourceTranslationSet)
|
||||
for _, r := range rr {
|
||||
localeByRes[r.Resource] = append(localeByRes[r.Resource], r)
|
||||
}
|
||||
|
||||
// - for each resource, fetch the current state
|
||||
sysLocale := make(systemTypes.ResourceTranslationSet, 0, len(rr))
|
||||
for res, rr := range localeByRes {
|
||||
current, _, err := store.SearchResourceTranslations(ctx, s, systemTypes.ResourceTranslationFilter{
|
||||
Resource: res,
|
||||
Deleted: filter.StateInclusive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get deltas and prepare upsert accordingly
|
||||
aux := current.New(rr)
|
||||
aux.Walk(func(cc *systemTypes.ResourceTranslation) error {
|
||||
cc.ID = nextID()
|
||||
cc.CreatedAt = *now()
|
||||
cc.CreatedBy = me.Identity()
|
||||
|
||||
return nil
|
||||
})
|
||||
sysLocale = append(sysLocale, aux...)
|
||||
|
||||
for _, diff := range current.Old(rr) {
|
||||
old := diff[0]
|
||||
new := diff[1]
|
||||
|
||||
// soft delete; restore old message
|
||||
if new.Message == "" {
|
||||
new.Message = old.Message
|
||||
if new.DeletedAt == nil {
|
||||
new.DeletedAt = now()
|
||||
new.DeletedBy = me.Identity()
|
||||
}
|
||||
} else {
|
||||
new.UpdatedAt = now()
|
||||
new.UpdatedBy = me.Identity()
|
||||
|
||||
new.DeletedAt = nil
|
||||
new.DeletedBy = 0
|
||||
}
|
||||
|
||||
sysLocale = append(sysLocale, new)
|
||||
}
|
||||
}
|
||||
|
||||
err = store.UpsertResourceTranslation(ctx, s, sysLocale...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload ALL resource translations
|
||||
// @todo we could probably do this more selectively and refresh only updated resources?
|
||||
return locale.Global().ReloadResourceTranslations(ctx)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Locale() locale.Resource {
|
||||
return svc.locale
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Chart(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
err error
|
||||
out locale.ResourceTranslationSet
|
||||
res *types.Chart
|
||||
)
|
||||
|
||||
res, err = svc.loadChart(ctx, svc.store, namespaceID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmp, err := svc.chartExtended(ctx, res)
|
||||
return append(out, tmp...), err
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Module(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
err error
|
||||
out locale.ResourceTranslationSet
|
||||
res *types.Module
|
||||
)
|
||||
|
||||
res, err = svc.loadModule(ctx, svc.store, namespaceID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var k types.LocaleKey
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
k = types.LocaleKeyModuleName
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
tmp, err := svc.moduleExtended(ctx, res)
|
||||
return append(out, tmp...), err
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Namespace(ctx context.Context, id uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
err error
|
||||
out locale.ResourceTranslationSet
|
||||
res *types.Namespace
|
||||
)
|
||||
|
||||
res, err = svc.loadNamespace(ctx, svc.store, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var k types.LocaleKey
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
k = types.LocaleKeyNamespaceName
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyNamespaceMetaSubtitle
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyNamespaceMetaDescription
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) Page(ctx context.Context, namespaceID uint64, id uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
err error
|
||||
out locale.ResourceTranslationSet
|
||||
res *types.Page
|
||||
)
|
||||
|
||||
res, err = svc.loadPage(ctx, svc.store, namespaceID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var k types.LocaleKey
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
k = types.LocaleKeyPageTitle
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyPageDescription
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
tmp, err := svc.pageExtended(ctx, res)
|
||||
return append(out, tmp...), err
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) PageLayout(ctx context.Context, namespaceID uint64, pageID uint64, id uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
err error
|
||||
out locale.ResourceTranslationSet
|
||||
res *types.PageLayout
|
||||
)
|
||||
|
||||
res, err = svc.loadPageLayout(ctx, svc.store, namespaceID, pageID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var k types.LocaleKey
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
k = types.LocaleKeyPageLayoutMetaTitle
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyPageLayoutMetaDescription
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
tmp, err := svc.pageLayoutExtended(ctx, res)
|
||||
return append(out, tmp...), err
|
||||
}
|
||||
|
||||
func updateTranslations(ctx context.Context, ac localeAccessControl, lsvc ResourceTranslationsManagerService, tt ...*locale.ResourceTranslation) error {
|
||||
if lsvc == nil || lsvc.Locale() == nil || lsvc.Locale().Default() == nil {
|
||||
// gracefully handle partial initializations
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
// assuming options will not change after start
|
||||
contentLang = lsvc.Locale().Default().Tag
|
||||
)
|
||||
|
||||
if options.Locale().ResourceTranslationsEnabled {
|
||||
contentLang = locale.GetContentLanguageFromContext(ctx)
|
||||
// Resource translations enabled
|
||||
if contentLang == language.Und {
|
||||
// If no content-language meta (HTTP header) info was
|
||||
// used, do not run update translations - we do not know
|
||||
// what is the language that we're sending in
|
||||
return nil
|
||||
}
|
||||
|
||||
if !lsvc.Locale().SupportedLang(contentLang) {
|
||||
// unsupported language
|
||||
return errors.InvalidData("unsupported language")
|
||||
}
|
||||
|
||||
if !ac.CanManageResourceTranslations(ctx) {
|
||||
return errors.Unauthorized("not allowed to manage resource translations")
|
||||
}
|
||||
}
|
||||
|
||||
locale.ResourceTranslationSet(tt).SetLanguage(contentLang)
|
||||
if err := lsvc.Upsert(ctx, tt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
444
server/compose/service/locale.go
Normal file
444
server/compose/service/locale.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func (svc resourceTranslationsManager) moduleExtended(ctx context.Context, res *types.Module) (out locale.ResourceTranslationSet, err error) {
|
||||
var (
|
||||
k types.LocaleKey
|
||||
set locale.ResourceTranslationSet
|
||||
)
|
||||
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
for _, f := range res.Fields {
|
||||
k = types.LocaleKeyModuleFieldLabel
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyModuleFieldMetaDescriptionView
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), k.Path),
|
||||
})
|
||||
k = types.LocaleKeyModuleFieldMetaDescriptionEdit
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyModuleFieldMetaHintView
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), k.Path),
|
||||
})
|
||||
k = types.LocaleKeyModuleFieldMetaHintEdit
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: k.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), k.Path),
|
||||
})
|
||||
|
||||
// Expressions
|
||||
set, err = svc.moduleFieldExpressionsHandler(ctx, tag, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, set...)
|
||||
|
||||
// Extra field bits
|
||||
set, err = svc.moduleFieldOptionsHandler(ctx, tag, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, set...)
|
||||
|
||||
set, err = svc.moduleFieldBoolHandler(ctx, tag, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, set...)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) moduleFieldExpressionsHandler(ctx context.Context, tag language.Tag, f *types.ModuleField) (locale.ResourceTranslationSet, error) {
|
||||
out := make(locale.ResourceTranslationSet, 0, 10)
|
||||
|
||||
for i, v := range f.Expressions.Validators {
|
||||
vContentID := locale.ContentID(v.ValidatorID, i)
|
||||
rpl := strings.NewReplacer(
|
||||
"{{validatorID}}", strconv.FormatUint(vContentID, 10),
|
||||
)
|
||||
|
||||
tKey := rpl.Replace(types.LocaleKeyModuleFieldExpressionValidatorValidatorIDError.Path)
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: tKey,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), tKey),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) moduleFieldOptionsHandler(ctx context.Context, tag language.Tag, f *types.ModuleField) (locale.ResourceTranslationSet, error) {
|
||||
out := make(locale.ResourceTranslationSet, 0, 10)
|
||||
|
||||
optsUnknown, has := f.Options["options"]
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
optsSlice, is := optsUnknown.([]interface{})
|
||||
if !is {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, optUnknown := range optsSlice {
|
||||
var value string
|
||||
|
||||
// what is this we're dealing with?
|
||||
// slice of strings (values) or map (value+text)
|
||||
switch opt := optUnknown.(type) {
|
||||
case string:
|
||||
value = opt
|
||||
|
||||
case map[string]interface{}:
|
||||
value, is = opt["value"].(string)
|
||||
if !is {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
trKey := strings.NewReplacer("{{value}}", value).Replace(types.LocaleKeyModuleFieldMetaOptionsValueText.Path)
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: trKey,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), trKey),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) moduleFieldBoolHandler(ctx context.Context, tag language.Tag, f *types.ModuleField) (locale.ResourceTranslationSet, error) {
|
||||
if f.Kind != "Bool" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make(locale.ResourceTranslationSet, 0, 2)
|
||||
|
||||
trKey := strings.NewReplacer("{{value}}", "true").Replace(types.LocaleKeyModuleFieldMetaBoolValueLabel.Path)
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: trKey,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), trKey),
|
||||
})
|
||||
|
||||
trKey = strings.NewReplacer("{{value}}", "false").Replace(types.LocaleKeyModuleFieldMetaBoolValueLabel.Path)
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: f.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: trKey,
|
||||
Msg: svc.locale.TResourceFor(tag, f.ResourceTranslation(), trKey),
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) pageExtended(ctx context.Context, res *types.Page) (out locale.ResourceTranslationSet, err error) {
|
||||
var (
|
||||
k types.LocaleKey
|
||||
aux []*locale.ResourceTranslation
|
||||
)
|
||||
|
||||
// We need to get page layouts to include them also
|
||||
// @todo refactor the base logic to simplify this; it's suboptimal
|
||||
layouts, _, err := store.SearchComposePageLayouts(ctx, svc.store, types.PageLayoutFilter{
|
||||
NamespaceID: res.NamespaceID,
|
||||
PageID: res.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
for _, l := range layouts {
|
||||
aux, err = svc.PageLayout(ctx, res.NamespaceID, res.ID, l.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out = append(out, aux...)
|
||||
}
|
||||
|
||||
for i, block := range res.Blocks {
|
||||
pbContentID := locale.ContentID(block.BlockID, i)
|
||||
rpl := strings.NewReplacer(
|
||||
"{{blockID}}", strconv.FormatUint(pbContentID, 10),
|
||||
)
|
||||
|
||||
// base stuff
|
||||
k = types.LocaleKeyPagePageBlockBlockIDTitle
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: rpl.Replace(k.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), rpl.Replace(k.Path)),
|
||||
})
|
||||
|
||||
k = types.LocaleKeyPagePageBlockBlockIDDescription
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: rpl.Replace(k.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), rpl.Replace(k.Path)),
|
||||
})
|
||||
|
||||
switch block.Kind {
|
||||
case "Automation":
|
||||
aux, err = svc.pageExtendedAutomationBlock(tag, res, block, pbContentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, aux...)
|
||||
case "RecordList":
|
||||
aux, err = svc.pageExtendedRecordListBlock(tag, res, block, pbContentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, aux...)
|
||||
case "Content":
|
||||
k = types.LocaleKeyPagePageBlockBlockIDContentBody
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: rpl.Replace(k.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), rpl.Replace(k.Path)),
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) pageLayoutExtended(ctx context.Context, res *types.PageLayout) (out locale.ResourceTranslationSet, err error) {
|
||||
var (
|
||||
k types.LocaleKey
|
||||
)
|
||||
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
// Standard buttons
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsNewLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsNewLabel.Path),
|
||||
})
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsEditLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsEditLabel.Path),
|
||||
})
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsSubmitLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsSubmitLabel.Path),
|
||||
})
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsDeleteLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsDeleteLabel.Path),
|
||||
})
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsCloneLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsCloneLabel.Path),
|
||||
})
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: types.LocaleKeyPageLayoutConfigButtonsBackLabel.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), types.LocaleKeyPageLayoutConfigButtonsBackLabel.Path),
|
||||
})
|
||||
|
||||
// Actions
|
||||
for i, action := range res.Config.Actions {
|
||||
acContentID := locale.ContentID(action.ActionID, i)
|
||||
rpl := strings.NewReplacer(
|
||||
"{{actionID}}", strconv.FormatUint(acContentID, 10),
|
||||
)
|
||||
|
||||
k = types.LocaleKeyPageLayoutConfigActionsActionIDMetaLabel
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: rpl.Replace(k.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), rpl.Replace(k.Path)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) chartExtended(_ context.Context, res *types.Chart) (out locale.ResourceTranslationSet, err error) {
|
||||
var (
|
||||
yAxisLabelK = types.LocaleKeyChartYAxisLabel
|
||||
metricLabelK = types.LocaleKeyChartMetricsMetricIDLabel
|
||||
dimStepLabelK = types.LocaleKeyChartDimensionsDimensionIDMetaStepsStepIDLabel
|
||||
)
|
||||
|
||||
for _, report := range res.Config.Reports {
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: yAxisLabelK.Path,
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), yAxisLabelK.Path),
|
||||
})
|
||||
}
|
||||
|
||||
report.WalkMetrics(func(metricID string, _ map[string]interface{}) {
|
||||
mpl := strings.NewReplacer(
|
||||
"{{metricID}}", metricID,
|
||||
)
|
||||
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: mpl.Replace(metricLabelK.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), mpl.Replace(metricLabelK.Path)),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
report.WalkDimensionSteps(func(dimensionID string, stepID string, _ map[string]interface{}) {
|
||||
mpl := strings.NewReplacer(
|
||||
"{{dimensionID}}", dimensionID,
|
||||
"{{stepID}}", stepID,
|
||||
)
|
||||
|
||||
for _, tag := range svc.locale.Tags() {
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: mpl.Replace(dimStepLabelK.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), mpl.Replace(dimStepLabelK.Path)),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) pageExtendedAutomationBlock(tag language.Tag, res *types.Page, block types.PageBlock, blockID uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
bb, _ = block.Options["buttons"].([]interface{})
|
||||
)
|
||||
|
||||
return svc.pageBlockButtons(tag, res, blockID, bb)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) pageExtendedRecordListBlock(tag language.Tag, res *types.Page, block types.PageBlock, blockID uint64) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
bb, _ = block.Options["selectionButtons"].([]interface{})
|
||||
)
|
||||
|
||||
return svc.pageBlockButtons(tag, res, blockID, bb)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) pageBlockButtons(tag language.Tag, res *types.Page, blockID uint64, bb []interface{}) (locale.ResourceTranslationSet, error) {
|
||||
var (
|
||||
k = types.LocaleKeyPagePageBlockBlockIDButtonButtonIDLabel
|
||||
out = make(locale.ResourceTranslationSet, 0, 10)
|
||||
)
|
||||
|
||||
for j, auxBtn := range bb {
|
||||
btn := auxBtn.(map[string]interface{})
|
||||
|
||||
bContentID := uint64(0)
|
||||
if aux, ok := btn["buttonID"]; ok {
|
||||
bContentID = cast.ToUint64(aux)
|
||||
}
|
||||
|
||||
bContentID = locale.ContentID(bContentID, j)
|
||||
|
||||
rpl := strings.NewReplacer(
|
||||
"{{blockID}}", strconv.FormatUint(blockID, 10),
|
||||
"{{buttonID}}", strconv.FormatUint(bContentID, 10),
|
||||
)
|
||||
|
||||
out = append(out, &locale.ResourceTranslation{
|
||||
Resource: res.ResourceTranslation(),
|
||||
Lang: tag.String(),
|
||||
Key: rpl.Replace(k.Path),
|
||||
Msg: svc.locale.TResourceFor(tag, res.ResourceTranslation(), rpl.Replace(k.Path)),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Helper loaders
|
||||
|
||||
func (svc resourceTranslationsManager) loadModule(ctx context.Context, s store.Storer, namespaceID, moduleID uint64) (*types.Module, error) {
|
||||
return loadModule(ctx, s, namespaceID, moduleID)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) loadNamespace(ctx context.Context, s store.Storer, namespaceID uint64) (*types.Namespace, error) {
|
||||
return loadNamespace(ctx, s, namespaceID)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) loadPage(ctx context.Context, s store.Storer, namespaceID, pageID uint64) (*types.Page, error) {
|
||||
return loadPage(ctx, s, namespaceID, pageID)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) loadPageLayout(ctx context.Context, s store.Storer, namespaceID, pageID, pageLayoutID uint64) (res *types.PageLayout, err error) {
|
||||
return loadPageLayout(ctx, s, namespaceID, pageID, pageLayoutID)
|
||||
}
|
||||
|
||||
func (svc resourceTranslationsManager) loadChart(ctx context.Context, s store.Storer, namespaceID, chartID uint64) (*types.Chart, error) {
|
||||
return loadChart(ctx, s, namespaceID, chartID)
|
||||
}
|
||||
10
server/compose/service/main_test.go
Normal file
10
server/compose/service/main_test.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/cli"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
)
|
||||
|
||||
func init() {
|
||||
id.Init(cli.Context())
|
||||
}
|
||||
1624
server/compose/service/module.go
Normal file
1624
server/compose/service/module.go
Normal file
File diff suppressed because it is too large
Load Diff
1056
server/compose/service/module_actions.gen.go
generated
Normal file
1056
server/compose/service/module_actions.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
122
server/compose/service/module_actions.yaml
Normal file
122
server/compose/service/module_actions.yaml
Normal file
@@ -0,0 +1,122 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:module
|
||||
service: module
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: module
|
||||
type: "*types.Module"
|
||||
fields: [ name, handle, ID, namespaceID ]
|
||||
- name: changed
|
||||
type: "*types.Module"
|
||||
fields: [ name, handle, ID, namespaceID, meta, fields ]
|
||||
- name: filter
|
||||
type: "*types.ModuleFilter"
|
||||
fields: [ query, name, handle, name, namespaceID, sort, limit ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for modules"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{module}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{module}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{module}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{module}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{module}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "module does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidHandle
|
||||
message: "invalid handle"
|
||||
severity: warning
|
||||
|
||||
- error: handleNotUnique
|
||||
message: "handle not unique"
|
||||
log: "used duplicate handle ({{module.handle}}) for module"
|
||||
severity: warning
|
||||
|
||||
- error: nameNotUnique
|
||||
message: "name not unique"
|
||||
log: "used duplicate name ({{module.name}}) for module"
|
||||
severity: warning
|
||||
|
||||
- error: fieldNameReserved
|
||||
message: "field name is reserved for system fields"
|
||||
|
||||
- error: staleData
|
||||
message: "module was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid or missing namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this module"
|
||||
log: "could not read {{module}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list modules"
|
||||
log: "could not search or list modules; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadNamespace
|
||||
message: "not allowed to read this namespace"
|
||||
log: "could not read namespace {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToListModules
|
||||
message: "not allowed to list modules"
|
||||
log: "could not list modules; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create modules"
|
||||
log: "could not create modules; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this module"
|
||||
log: "could not update {{module}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this module"
|
||||
log: "could not delete {{module}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this module"
|
||||
log: "could not undelete {{module}}; insufficient permissions"
|
||||
|
||||
- error: dedupConfigurationInvalidMissingConstraint
|
||||
message: "invalid deduplication configuration - constraint not defined"
|
||||
log: "missing constraint on deduplication configuration for module"
|
||||
321
server/compose/service/module_test.go
Normal file
321
server/compose/service/module_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
labelTypes "github.com/cortezaproject/corteza/server/pkg/label/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
sysService "github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func makeTestModuleService(t *testing.T, mods ...any) *module {
|
||||
|
||||
var (
|
||||
err error
|
||||
req = require.New(t)
|
||||
log = zap.NewNop()
|
||||
)
|
||||
|
||||
for _, m := range mods {
|
||||
switch c := m.(type) {
|
||||
|
||||
case *zap.Logger:
|
||||
t.Log("using custom Logger to initialize Module service")
|
||||
log = c
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = logger.ContextWithValue(context.Background(), log)
|
||||
svc = &module{
|
||||
eventbus: eventbus.New(),
|
||||
}
|
||||
)
|
||||
|
||||
for _, m := range mods {
|
||||
switch c := m.(type) {
|
||||
case rbacService:
|
||||
t.Log("using custom RBAC to initialize Module service")
|
||||
svc.ac = &accessControl{rbac: c}
|
||||
case store.Storer:
|
||||
t.Log("using custom Store to initialize Module service")
|
||||
svc.store = c
|
||||
case dal.FullService:
|
||||
t.Log("using custom DAL to initialize Module service")
|
||||
t.Log("make sure you manually reload models!")
|
||||
svc.dal = c
|
||||
}
|
||||
}
|
||||
|
||||
if svc.ac == nil {
|
||||
svc.ac = &accessControl{rbac: rbac.NewService(log, nil)}
|
||||
}
|
||||
|
||||
if svc.store == nil {
|
||||
t.Log("using SQLite in-memory Store")
|
||||
svc.store, err = sqlite.ConnectInMemoryWithDebug(ctx)
|
||||
req.NoError(err)
|
||||
|
||||
t.Log("upgrading store")
|
||||
req.NoError(store.Upgrade(ctx, log, svc.store))
|
||||
|
||||
t.Log("data cleanup")
|
||||
req.NoError(store.TruncateUsers(ctx, svc.store))
|
||||
req.NoError(store.TruncateRoles(ctx, svc.store))
|
||||
req.NoError(store.TruncateComposeNamespaces(ctx, svc.store))
|
||||
req.NoError(store.TruncateComposeModules(ctx, svc.store))
|
||||
req.NoError(store.TruncateComposeModuleFields(ctx, svc.store))
|
||||
req.NoError(store.TruncateRbacRules(ctx, svc.store))
|
||||
req.NoError(store.TruncateLabels(ctx, svc.store))
|
||||
|
||||
}
|
||||
|
||||
resourceMaker(ctx, t, svc.store, mods...)
|
||||
|
||||
if svc.dal == nil {
|
||||
dalAux, err := dal.New(zap.NewNop(), true)
|
||||
req.NoError(err)
|
||||
|
||||
const (
|
||||
recordsTable = "compose_record"
|
||||
)
|
||||
|
||||
req.NoError(
|
||||
dalAux.ReplaceConnection(
|
||||
ctx,
|
||||
dal.MakeConnection(1, svc.store.ToDalConn(),
|
||||
dal.ConnectionParams{},
|
||||
dal.ConnectionConfig{ModelIdent: recordsTable},
|
||||
),
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
svc.dal = dalAux
|
||||
|
||||
t.Log("reloading DAL models")
|
||||
req.NoError(DalModelReload(ctx, svc.store, sysService.DefaultDalSchemaAlteration, dalAux))
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestModules(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
ns = &types.Namespace{Name: "testing", ID: nextID(), CreatedAt: *now()}
|
||||
)
|
||||
|
||||
t.Run("crud", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
svc := makeTestModuleService(t,
|
||||
ns,
|
||||
&rbac.ServiceAllowAll{},
|
||||
)
|
||||
|
||||
res, err := svc.Create(ctx, &types.Module{Name: "My first module", NamespaceID: ns.ID})
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
|
||||
res, err = svc.FindByID(ctx, ns.ID, res.ID)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
|
||||
res, err = svc.FindByHandle(ctx, ns.ID, res.Handle)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
|
||||
res.Name = "Changed"
|
||||
res, err = svc.Update(ctx, res)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.NotNil(res.UpdatedAt)
|
||||
req.Equal(res.Name, "Changed")
|
||||
|
||||
res, err = svc.FindByID(ctx, ns.ID, res.ID)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.Equal(res.Name, "Changed")
|
||||
|
||||
err = svc.DeleteByID(ctx, ns.ID, res.ID)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
|
||||
// this works because we're allowed to do everything
|
||||
res, err = svc.FindByID(ctx, ns.ID, res.ID)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.NotNil(res.DeletedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModule_LabelSearch(t *testing.T) {
|
||||
var (
|
||||
ns = &types.Namespace{Name: "testing", ID: nextID(), CreatedAt: *now()}
|
||||
req = require.New(t)
|
||||
svc = makeTestModuleService(t,
|
||||
ns,
|
||||
&rbac.ServiceAllowAll{},
|
||||
)
|
||||
|
||||
ctx = context.Background()
|
||||
//ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
makeModule = func(n ...string) *types.Module {
|
||||
mod := &types.Module{
|
||||
NamespaceID: ns.ID,
|
||||
Name: n[0],
|
||||
Labels: map[string]labelTypes.LabelValue{},
|
||||
}
|
||||
|
||||
for i := 1; i < len(n); i += 2 {
|
||||
mod.Labels[n[i]] = labelTypes.LabelValue{Val: n[i+1]}
|
||||
}
|
||||
|
||||
out, err := svc.Create(ctx, mod)
|
||||
|
||||
req.NoError(err)
|
||||
return out
|
||||
}
|
||||
|
||||
findModules = func(labels map[string]labelTypes.LabelValue, IDs []string) types.ModuleSet {
|
||||
f := types.ModuleFilter{NamespaceID: ns.ID, Labels: labels, ModuleID: IDs}
|
||||
set, _, err := svc.Find(ctx, f)
|
||||
req.NoError(err)
|
||||
|
||||
return set
|
||||
}
|
||||
)
|
||||
|
||||
makeModule("labeled module 1", "label1", "value1", "label2", "value2")
|
||||
m2 := makeModule("labeled module 2", "label1", "value1")
|
||||
m3 := makeModule("labeled module 3")
|
||||
|
||||
//return all -- no label/ID filter, return all
|
||||
req.Len(findModules(nil, nil), 3)
|
||||
|
||||
// return 2 - both that have label1=valu1
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{"label1": {Val: "value1"}}, nil), 2)
|
||||
|
||||
// return 0 - none have foo=foo
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{"missing": {Val: "missing"}}, nil), 0)
|
||||
|
||||
// one has label2=value2
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{"label2": {Val: "value2"}}, nil), 1)
|
||||
|
||||
// explicit by ID and label
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{"label1": {Val: "value1"}}, id.Strings(m2.ID)), 1)
|
||||
|
||||
// none with this combo
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{"foo": {Val: "foo"}}, id.Strings(m3.ID)), 0)
|
||||
|
||||
// one with explicit ID (regression) and nil for label filter
|
||||
req.Len(findModules(nil, id.Strings(m3.ID)), 1)
|
||||
|
||||
// one with explicit ID (regression) and empty map for label filter
|
||||
req.Len(findModules(map[string]labelTypes.LabelValue{}, id.Strings(m3.ID)), 1)
|
||||
|
||||
}
|
||||
|
||||
func TestModule_LabelCRUD(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
//ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
|
||||
ns = &types.Namespace{Name: "testing", ID: nextID(), CreatedAt: *now()}
|
||||
|
||||
req = require.New(t)
|
||||
svc = makeTestModuleService(t,
|
||||
ns,
|
||||
&rbac.ServiceAllowAll{},
|
||||
)
|
||||
|
||||
findAndReturnLabel = func(id uint64) map[string]labelTypes.LabelValue {
|
||||
res, err := svc.FindByID(ctx, ns.ID, id)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
return res.Labels
|
||||
}
|
||||
)
|
||||
|
||||
// create unlabeled module
|
||||
res, err := svc.Create(ctx, &types.Module{Name: "unLabeledIDs", NamespaceID: ns.ID})
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.Nil(res.Labels)
|
||||
|
||||
// no labels should be present
|
||||
req.Nil(findAndReturnLabel(res.ID))
|
||||
|
||||
// update the module with labels
|
||||
res.Labels = map[string]labelTypes.LabelValue{"label1": {Val: "1st"}}
|
||||
res, err = svc.Update(ctx, res)
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.Contains(res.Labels, "label1")
|
||||
|
||||
// must contain the added label
|
||||
req.Contains(findAndReturnLabel(res.ID), "label1")
|
||||
|
||||
res, err = svc.Create(ctx, &types.Module{Name: "LabeledIDs", NamespaceID: ns.ID, Labels: map[string]labelTypes.LabelValue{"label2": {Val: "2nd"}}})
|
||||
req.NoError(err)
|
||||
req.NotNil(res)
|
||||
req.Contains(res.Labels, "label2")
|
||||
|
||||
// must contain the added label
|
||||
req.Contains(findAndReturnLabel(res.ID), "label2")
|
||||
|
||||
// update with Meta:nil (should keep labels intact)
|
||||
res.Labels = nil
|
||||
res, err = svc.Update(ctx, res)
|
||||
req.NoError(err)
|
||||
|
||||
req.Contains(findAndReturnLabel(res.ID), "label2")
|
||||
|
||||
// update with Meta:empty-map (should remove all labels)
|
||||
res.Labels = map[string]labelTypes.LabelValue{}
|
||||
res, err = svc.Update(ctx, res)
|
||||
req.NoError(err)
|
||||
|
||||
req.Empty(findAndReturnLabel(res.ID))
|
||||
}
|
||||
|
||||
func TestModuleToModel(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
model *dal.Model
|
||||
err error
|
||||
|
||||
m = &types.Module{
|
||||
ID: 1,
|
||||
Handle: "model-handle",
|
||||
Config: types.ModuleConfig{},
|
||||
CreatedAt: time.Time{},
|
||||
Fields: []*types.ModuleField{},
|
||||
}
|
||||
)
|
||||
|
||||
t.Log("ident on DAL config not set, use ident from connection config")
|
||||
model, err = ModuleToModel(nil, m, "ident-from-conn-config")
|
||||
req.NoError(err)
|
||||
req.Equal("ident-from-conn-config", model.Ident)
|
||||
|
||||
t.Log("explicit ident in module's DAL config should override the handle")
|
||||
m.Config.DAL.Ident = "explicit-ident"
|
||||
model, err = ModuleToModel(nil, m, "ident-from-conn-config")
|
||||
req.NoError(err)
|
||||
req.Equal("explicit-ident", model.Ident)
|
||||
}
|
||||
894
server/compose/service/namespace.go
Normal file
894
server/compose/service/namespace.go
Normal file
@@ -0,0 +1,894 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
automationService "github.com/cortezaproject/corteza/server/automation/service"
|
||||
"github.com/cortezaproject/corteza/server/compose/service/event"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/envoyx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/handle"
|
||||
"github.com/cortezaproject/corteza/server/pkg/label"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
type (
|
||||
namespace struct {
|
||||
actionlog actionlog.Recorder
|
||||
ac namespaceAccessController
|
||||
modAc moduleAccessController
|
||||
pageAc pageAccessController
|
||||
chartAc chartAccessController
|
||||
|
||||
eventbus eventDispatcher
|
||||
store store.Storer
|
||||
locale ResourceTranslationsManagerService
|
||||
envoy *envoyx.Service
|
||||
}
|
||||
|
||||
namespaceImportSession struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"handle"`
|
||||
NamespaceID uint64 `json:"namespaceID,string"`
|
||||
SessionID uint64 `json:"sessionID,string"`
|
||||
UserID uint64 `json:"userID,string"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
Nodes envoyx.NodeSet `json:"-"`
|
||||
}
|
||||
|
||||
namespaceAccessController interface {
|
||||
CanManageResourceTranslations(ctx context.Context) bool
|
||||
CanSearchNamespaces(context.Context) bool
|
||||
CanCreateNamespace(context.Context) bool
|
||||
CanReadNamespace(context.Context, *types.Namespace) bool
|
||||
CanUpdateNamespace(context.Context, *types.Namespace) bool
|
||||
CanDeleteNamespace(context.Context, *types.Namespace) bool
|
||||
|
||||
Grant(ctx context.Context, rr ...*rbac.Rule) error
|
||||
}
|
||||
|
||||
NamespaceService interface {
|
||||
FindByID(ctx context.Context, namespaceID uint64) (*types.Namespace, error)
|
||||
FindByHandle(ctx context.Context, handle string) (*types.Namespace, error)
|
||||
Find(context.Context, types.NamespaceFilter) (types.NamespaceSet, types.NamespaceFilter, error)
|
||||
FindByAny(context.Context, interface{}) (*types.Namespace, error)
|
||||
|
||||
Create(ctx context.Context, namespace *types.Namespace) (*types.Namespace, error)
|
||||
Update(ctx context.Context, namespace *types.Namespace) (*types.Namespace, error)
|
||||
Clone(ctx context.Context, namespaceID uint64, dup *types.Namespace, decoder func() (envoyx.NodeSet, error)) (ns *types.Namespace, err error)
|
||||
ImportInit(ctx context.Context, f multipart.File, size int64) (namespaceImportSession, error)
|
||||
ImportRun(ctx context.Context, sessionID uint64, dup *types.Namespace) (ns *types.Namespace, err error)
|
||||
DeleteByID(ctx context.Context, namespaceID uint64) error
|
||||
}
|
||||
|
||||
namespaceUpdateHandler func(ctx context.Context, ns *types.Namespace) (namespaceChanges, error)
|
||||
namespaceChanges uint8
|
||||
)
|
||||
|
||||
const (
|
||||
namespaceUnchanged namespaceChanges = 0
|
||||
namespaceChanged namespaceChanges = 1
|
||||
namespaceLabelsChanged namespaceChanges = 2
|
||||
)
|
||||
|
||||
var (
|
||||
// @todo this is a temporary implementation; we will rework resource import/export
|
||||
// in the following versions
|
||||
namespaceSessionStore = make(map[uint64]namespaceImportSession)
|
||||
)
|
||||
|
||||
func Namespace() *namespace {
|
||||
return &namespace{
|
||||
ac: DefaultAccessControl,
|
||||
modAc: DefaultAccessControl,
|
||||
pageAc: DefaultAccessControl,
|
||||
chartAc: DefaultAccessControl,
|
||||
|
||||
eventbus: eventbus.Service(),
|
||||
actionlog: DefaultActionlog,
|
||||
store: DefaultStore,
|
||||
locale: DefaultResourceTranslation,
|
||||
envoy: envoyx.Global(),
|
||||
}
|
||||
}
|
||||
|
||||
// search fn() orchestrates pages search, namespace preload and check
|
||||
func (svc namespace) Find(ctx context.Context, filter types.NamespaceFilter) (set types.NamespaceSet, f types.NamespaceFilter, err error) {
|
||||
var (
|
||||
aProps = &namespaceActionProps{filter: &filter}
|
||||
)
|
||||
|
||||
// For each fetched item, store backend will check if it is valid or not
|
||||
filter.Check = func(res *types.Namespace) (bool, error) {
|
||||
if !svc.ac.CanReadNamespace(ctx, res) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = func() error {
|
||||
if !svc.ac.CanSearchNamespaces(ctx) {
|
||||
return NamespaceErrNotAllowedToSearch()
|
||||
}
|
||||
|
||||
if len(filter.Labels) > 0 {
|
||||
filter.LabeledIDs, err = label.Search(
|
||||
ctx,
|
||||
svc.store,
|
||||
types.Namespace{}.LabelResourceKind(),
|
||||
filter.Labels,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// labels specified but no labeled resources found
|
||||
if len(filter.LabeledIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if set, f, err = store.SearchComposeNamespaces(ctx, svc.store, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// i18n
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
set.Walk(func(n *types.Namespace) error {
|
||||
n.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, n.ResourceTranslation()))
|
||||
return nil
|
||||
})
|
||||
|
||||
if err = label.Load(ctx, svc.store, toLabeledNamespaces(set)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return set, f, svc.recordAction(ctx, aProps, NamespaceActionSearch, err)
|
||||
}
|
||||
|
||||
func (svc namespace) FindByID(ctx context.Context, ID uint64) (ns *types.Namespace, err error) {
|
||||
return svc.lookup(ctx, func(aProps *namespaceActionProps) (*types.Namespace, error) {
|
||||
if ID == 0 {
|
||||
return nil, NamespaceErrInvalidID()
|
||||
}
|
||||
|
||||
aProps.namespace.ID = ID
|
||||
return store.LookupComposeNamespaceByID(ctx, svc.store, ID)
|
||||
})
|
||||
}
|
||||
|
||||
// FindByHandle is an alias for FindBySlug
|
||||
func (svc namespace) FindByHandle(ctx context.Context, handle string) (ns *types.Namespace, err error) {
|
||||
return svc.FindBySlug(ctx, handle)
|
||||
}
|
||||
|
||||
func (svc namespace) FindBySlug(ctx context.Context, slug string) (ns *types.Namespace, err error) {
|
||||
return svc.lookup(ctx, func(aProps *namespaceActionProps) (*types.Namespace, error) {
|
||||
if !handle.IsValid(slug) {
|
||||
return nil, NamespaceErrInvalidHandle()
|
||||
}
|
||||
|
||||
aProps.namespace.Slug = slug
|
||||
return store.LookupComposeNamespaceBySlug(ctx, svc.store, slug)
|
||||
})
|
||||
}
|
||||
|
||||
// FindByAny tries to find namespace by id, handle or slug
|
||||
func (svc namespace) FindByAny(ctx context.Context, identifier interface{}) (r *types.Namespace, err error) {
|
||||
if ID, ok := identifier.(uint64); ok {
|
||||
r, err = svc.FindByID(ctx, ID)
|
||||
} else if strIdentifier, ok := identifier.(string); ok {
|
||||
if ID, _ := strconv.ParseUint(strIdentifier, 10, 64); ID > 0 {
|
||||
r, err = svc.FindByID(ctx, ID)
|
||||
} else {
|
||||
r, err = svc.FindByHandle(ctx, strIdentifier)
|
||||
if err == nil && r.ID == 0 {
|
||||
r, err = svc.FindBySlug(ctx, strIdentifier)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = NamespaceErrInvalidID()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create adds namespace and presets access rules for role everyone
|
||||
func (svc namespace) Create(ctx context.Context, new *types.Namespace) (*types.Namespace, error) {
|
||||
var (
|
||||
aProps = &namespaceActionProps{namespace: new}
|
||||
)
|
||||
|
||||
err := store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if !handle.IsValid(new.Slug) {
|
||||
return NamespaceErrInvalidHandle()
|
||||
}
|
||||
|
||||
if !svc.ac.CanCreateNamespace(ctx) {
|
||||
return NamespaceErrNotAllowedToCreate()
|
||||
}
|
||||
|
||||
if err = svc.eventbus.WaitFor(ctx, event.NamespaceBeforeCreate(new, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = svc.uniqueCheck(ctx, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
new.ID = nextID()
|
||||
new.CreatedAt = *now()
|
||||
new.UpdatedAt = nil
|
||||
new.DeletedAt = nil
|
||||
|
||||
aProps.setChanged(new)
|
||||
|
||||
if err = store.CreateComposeNamespace(ctx, svc.store, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, new.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Create(ctx, s, new); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = svc.eventbus.WaitFor(ctx, event.NamespaceAfterCreate(new, nil))
|
||||
return nil
|
||||
})
|
||||
|
||||
return new, svc.recordAction(ctx, aProps, NamespaceActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc namespace) Update(ctx context.Context, upd *types.Namespace) (c *types.Namespace, err error) {
|
||||
return svc.updater(ctx, upd.ID, NamespaceActionUpdate, svc.handleUpdate(ctx, upd))
|
||||
}
|
||||
|
||||
func (svc namespace) Clone(ctx context.Context, namespaceID uint64, dup *types.Namespace, decoder func() (envoyx.NodeSet, error)) (ns *types.Namespace, err error) {
|
||||
var (
|
||||
aProps = &namespaceActionProps{namespace: dup}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
||||
// Preparation
|
||||
// - target namespace
|
||||
targetNs, err := loadNamespace(ctx, s, namespaceID)
|
||||
if errors.IsNotFound(err) {
|
||||
return NamespaceErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
aProps.setNamespace(targetNs)
|
||||
|
||||
// - destination namespace
|
||||
if dup.Slug != "" {
|
||||
dstNs, err := store.LookupComposeNamespaceBySlug(ctx, s, dup.Slug)
|
||||
if err != nil && err != store.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
if dstNs != nil {
|
||||
return NamespaceErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
// Access control
|
||||
if err = svc.canExport(ctx, targetNs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get namespace resources
|
||||
nn, err := decoder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(dup)
|
||||
dup, err = svc.envoyRun(ctx, s, nn, targetNs, dup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dup, err = store.LookupComposeNamespaceBySlug(ctx, s, dup.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
dup.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, dup.ResourceTranslation()))
|
||||
|
||||
aProps.setNamespace(dup)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = svc.reloadServices(ctx, dup)
|
||||
return err
|
||||
}()
|
||||
|
||||
return dup, svc.recordAction(ctx, aProps, NamespaceActionClone, err)
|
||||
}
|
||||
|
||||
func (svc namespace) ImportInit(ctx context.Context, f multipart.File, size int64) (namespaceImportSession, error) {
|
||||
var (
|
||||
aProps = &namespaceActionProps{}
|
||||
err error
|
||||
ns *types.Namespace
|
||||
session namespaceImportSession
|
||||
nodes envoyx.NodeSet
|
||||
|
||||
esvc = envoyx.Global()
|
||||
nn envoyx.NodeSet
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
// access control
|
||||
if err := svc.canImport(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// archive type check
|
||||
mt, err := mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aProps.setArchiveFormat(mt.Extension())
|
||||
if !mt.Is("application/zip") {
|
||||
return NamespaceErrUnsupportedImportFormat()
|
||||
}
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// un-archive
|
||||
archive, err := zip.NewReader(f, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, zf := range archive.File {
|
||||
if zf.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
nn, _, err = esvc.Decode(ctx, envoyx.DecodeParams{
|
||||
Type: envoyx.DecodeTypeIO,
|
||||
Params: map[string]any{
|
||||
"reader": f,
|
||||
"mime": "text/yaml",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes = append(nodes, nn...)
|
||||
}
|
||||
|
||||
// store a session for later
|
||||
session = namespaceImportSession{
|
||||
SessionID: nextID(),
|
||||
UserID: auth.GetIdentityFromContext(ctx).Identity(),
|
||||
|
||||
CreatedAt: *now(),
|
||||
Nodes: nodes,
|
||||
}
|
||||
|
||||
// find the ns node
|
||||
for _, n := range nodes {
|
||||
if n.ResourceType == types.NamespaceResourceType {
|
||||
ns = n.Resource.(*types.Namespace)
|
||||
}
|
||||
}
|
||||
if ns == nil {
|
||||
return NamespaceErrImportMissingNamespace()
|
||||
}
|
||||
|
||||
// session needs to have namespaceID if ns Handle is not provided
|
||||
session.NamespaceID = ns.ID
|
||||
session.Name = ns.Name
|
||||
session.Slug = ns.Slug
|
||||
namespaceSessionStore[session.SessionID] = session
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
return nil
|
||||
}()
|
||||
|
||||
return session, svc.recordAction(ctx, aProps, NamespaceActionImportInit, err)
|
||||
}
|
||||
|
||||
func (svc namespace) ImportRun(ctx context.Context, sessionID uint64, dup *types.Namespace) (ns *types.Namespace, err error) {
|
||||
var (
|
||||
aProps = &namespaceActionProps{namespace: dup}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
var (
|
||||
newNS *types.Namespace
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
||||
// access control
|
||||
if err = svc.canImport(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !handle.IsValid(dup.Slug) {
|
||||
return NamespaceErrInvalidHandle()
|
||||
}
|
||||
|
||||
if dup.Slug != "" {
|
||||
// check for duplicate
|
||||
dstNs, err := store.LookupComposeNamespaceBySlug(ctx, svc.store, dup.Slug)
|
||||
if err != nil && err != store.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
if dstNs != nil {
|
||||
return NamespaceErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
// session
|
||||
var (
|
||||
session namespaceImportSession
|
||||
ok bool
|
||||
)
|
||||
if session, ok = namespaceSessionStore[sessionID]; !ok {
|
||||
return NamespaceErrImportSessionNotFound()
|
||||
}
|
||||
defer func() {
|
||||
delete(namespaceSessionStore, sessionID)
|
||||
}()
|
||||
|
||||
aProps.setNamespace(dup)
|
||||
|
||||
newNS, err = svc.envoyRun(ctx, s, session.Nodes, &types.Namespace{ID: session.NamespaceID, Slug: session.Slug, Name: session.Name}, dup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newNS, err = store.LookupComposeNamespaceBySlug(ctx, s, newNS.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(newNS)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = svc.reloadServices(ctx, newNS)
|
||||
dup = newNS
|
||||
return err
|
||||
}()
|
||||
|
||||
return dup, svc.recordAction(ctx, aProps, NamespaceActionImportRun, err)
|
||||
}
|
||||
|
||||
func (svc namespace) DeleteByID(ctx context.Context, namespaceID uint64) error {
|
||||
return trim1st(svc.updater(ctx, namespaceID, NamespaceActionDelete, svc.handleDelete))
|
||||
}
|
||||
|
||||
func (svc namespace) UndeleteByID(ctx context.Context, namespaceID uint64) error {
|
||||
return trim1st(svc.updater(ctx, namespaceID, NamespaceActionUndelete, svc.handleUndelete))
|
||||
}
|
||||
|
||||
func (svc namespace) updater(ctx context.Context, namespaceID uint64, action func(...*namespaceActionProps) *namespaceAction, fn namespaceUpdateHandler) (*types.Namespace, error) {
|
||||
var (
|
||||
changes namespaceChanges
|
||||
ns, old *types.Namespace
|
||||
aProps = &namespaceActionProps{namespace: &types.Namespace{ID: namespaceID}}
|
||||
err error
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, err = loadNamespace(ctx, s, namespaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, ns); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
old = ns.Clone()
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setChanged(ns)
|
||||
|
||||
if ns.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.NamespaceBeforeUpdate(ns, old))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.NamespaceBeforeDelete(ns, old))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes, err = fn(ctx, ns); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if changes&namespaceChanged > 0 {
|
||||
if err = store.UpdateComposeNamespace(ctx, svc.store, ns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, ns.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes&namespaceLabelsChanged > 0 {
|
||||
if err = label.Update(ctx, s, ns); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ns.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.NamespaceAfterUpdate(ns, old))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.NamespaceAfterDelete(nil, old))
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return ns, svc.recordAction(ctx, aProps, action, err)
|
||||
}
|
||||
|
||||
// lookup fn() orchestrates namespace lookup, and check
|
||||
func (svc namespace) lookup(ctx context.Context, lookup func(*namespaceActionProps) (*types.Namespace, error)) (ns *types.Namespace, err error) {
|
||||
var aProps = &namespaceActionProps{namespace: &types.Namespace{}}
|
||||
|
||||
err = func() error {
|
||||
if ns, err = lookup(aProps); errors.IsNotFound(err) {
|
||||
return NamespaceErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
|
||||
if !svc.ac.CanReadNamespace(ctx, ns) {
|
||||
return NamespaceErrNotAllowedToRead()
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, ns); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return ns, svc.recordAction(ctx, aProps, NamespaceActionLookup, err)
|
||||
}
|
||||
|
||||
func (svc namespace) uniqueCheck(ctx context.Context, ns *types.Namespace) (err error) {
|
||||
if ns.Slug != "" {
|
||||
if e, _ := store.LookupComposeNamespaceBySlug(ctx, svc.store, ns.Slug); e != nil && e.ID != ns.ID {
|
||||
return NamespaceErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc namespace) handleUpdate(ctx context.Context, upd *types.Namespace) namespaceUpdateHandler {
|
||||
return func(ctx context.Context, res *types.Namespace) (changes namespaceChanges, err error) {
|
||||
if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) {
|
||||
return namespaceUnchanged, NamespaceErrStaleData()
|
||||
}
|
||||
|
||||
if upd.Slug != res.Slug && !handle.IsValid(upd.Slug) {
|
||||
return namespaceUnchanged, NamespaceErrInvalidHandle()
|
||||
}
|
||||
|
||||
if err := svc.uniqueCheck(ctx, upd); err != nil {
|
||||
return namespaceUnchanged, err
|
||||
}
|
||||
|
||||
if !svc.ac.CanUpdateNamespace(ctx, res) {
|
||||
return namespaceUnchanged, NamespaceErrNotAllowedToUpdate()
|
||||
}
|
||||
|
||||
if res.Name != upd.Name {
|
||||
changes |= namespaceChanged
|
||||
res.Name = upd.Name
|
||||
}
|
||||
|
||||
if res.Slug != upd.Slug {
|
||||
changes |= namespaceChanged
|
||||
res.Slug = upd.Slug
|
||||
}
|
||||
|
||||
if res.Enabled != upd.Enabled {
|
||||
changes |= namespaceChanged
|
||||
res.Enabled = upd.Enabled
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(upd.Meta, res.Meta) {
|
||||
changes |= namespaceChanged
|
||||
res.Meta = upd.Meta
|
||||
}
|
||||
|
||||
if upd.Labels != nil {
|
||||
if label.Changed(res.Labels, upd.Labels) {
|
||||
changes |= namespaceLabelsChanged
|
||||
res.Labels = upd.Labels
|
||||
}
|
||||
}
|
||||
|
||||
if changes&namespaceChanged > 0 {
|
||||
res.UpdatedAt = now()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (svc namespace) handleDelete(ctx context.Context, ns *types.Namespace) (namespaceChanges, error) {
|
||||
if !svc.ac.CanDeleteNamespace(ctx, ns) {
|
||||
return namespaceUnchanged, NamespaceErrNotAllowedToDelete()
|
||||
}
|
||||
|
||||
if ns.DeletedAt != nil {
|
||||
// namespace already deleted
|
||||
return namespaceUnchanged, nil
|
||||
}
|
||||
|
||||
ns.DeletedAt = now()
|
||||
return namespaceChanged, nil
|
||||
}
|
||||
|
||||
func (svc namespace) handleUndelete(ctx context.Context, ns *types.Namespace) (namespaceChanges, error) {
|
||||
if !svc.ac.CanDeleteNamespace(ctx, ns) {
|
||||
return namespaceUnchanged, NamespaceErrNotAllowedToUndelete()
|
||||
}
|
||||
|
||||
if ns.DeletedAt == nil {
|
||||
// namespace not deleted
|
||||
return namespaceUnchanged, nil
|
||||
}
|
||||
|
||||
ns.DeletedAt = nil
|
||||
return namespaceChanged, nil
|
||||
}
|
||||
|
||||
func (svc namespace) canExport(ctx context.Context, namespace *types.Namespace) error {
|
||||
// Preload all of the relevant stuff for access control
|
||||
// - modules
|
||||
// no need to load fields
|
||||
mm, _, err := store.SearchComposeModules(ctx, svc.store, types.ModuleFilter{NamespaceID: namespace.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// - pages
|
||||
pp, _, err := store.SearchComposePages(ctx, svc.store, types.PageFilter{NamespaceID: namespace.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// - charts
|
||||
cc, _, err := store.SearchComposeCharts(ctx, svc.store, types.ChartFilter{NamespaceID: namespace.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// access control
|
||||
// - namespace
|
||||
if !svc.ac.CanReadNamespace(ctx, namespace) {
|
||||
return NamespaceErrNotAllowedToRead()
|
||||
}
|
||||
// - modules
|
||||
for _, m := range mm {
|
||||
if !svc.modAc.CanReadModule(ctx, m) {
|
||||
return ModuleErrNotAllowedToRead()
|
||||
}
|
||||
}
|
||||
// - pages
|
||||
for _, p := range pp {
|
||||
if !svc.pageAc.CanReadPage(ctx, p) {
|
||||
return PageErrNotAllowedToRead()
|
||||
}
|
||||
}
|
||||
// - charts
|
||||
for _, c := range cc {
|
||||
if !svc.chartAc.CanReadChart(ctx, c) {
|
||||
return ChartErrNotAllowedToRead()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc namespace) canImport(ctx context.Context) error {
|
||||
|
||||
// If a user is allowed to create a namespace, they are considered to be allowed
|
||||
// to create any underlying resource when it comes to importing.
|
||||
//
|
||||
// This was agreed upon internally and may change in the future.
|
||||
|
||||
if !svc.ac.CanCreateNamespace(ctx) {
|
||||
return NamespaceErrNotAllowedToCreate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc namespace) envoyRun(ctx context.Context, s store.Storer, nodes envoyx.NodeSet, oldNS, newNS *types.Namespace) (ns *types.Namespace, err error) {
|
||||
// Get the NS node
|
||||
oldRef := envoyx.Ref{
|
||||
ResourceType: types.NamespaceResourceType,
|
||||
Identifiers: envoyx.MakeIdentifiers(oldNS.Slug, oldNS.ID),
|
||||
Scope: envoyx.Scope{
|
||||
ResourceType: types.NamespaceResourceType,
|
||||
Identifiers: envoyx.MakeIdentifiers(oldNS.Slug, oldNS.ID),
|
||||
},
|
||||
}
|
||||
nsNode := envoyx.NodeForRef(oldRef, nodes...)
|
||||
auxNs := nsNode.Resource.(*types.Namespace)
|
||||
|
||||
// Handle renames and references
|
||||
auxNs.ID = 0
|
||||
auxNs.Name = newNS.Name
|
||||
auxNs.Slug = newNS.Slug
|
||||
newNS = auxNs
|
||||
ns = newNS
|
||||
|
||||
// Change the identifiers and references
|
||||
// - identifiers of the NS node
|
||||
nsNode.Identifiers = envoyx.MakeIdentifiers(newNS.Slug)
|
||||
nsNode.Scope.Identifiers = nsNode.Identifiers
|
||||
|
||||
// - all the child refs
|
||||
for _, n := range nodes {
|
||||
nr := make(map[string]envoyx.Ref)
|
||||
for k, r := range n.References {
|
||||
if r.ResourceType == types.NamespaceResourceType {
|
||||
r.Identifiers = nsNode.Identifiers
|
||||
}
|
||||
if r.Scope.ResourceType == types.NamespaceResourceType {
|
||||
r.Scope = nsNode.Scope
|
||||
}
|
||||
nr[k] = r
|
||||
}
|
||||
n.References = nr
|
||||
if n.Scope.ResourceType == nsNode.Scope.ResourceType {
|
||||
n.Scope = nsNode.Scope
|
||||
}
|
||||
}
|
||||
|
||||
// Get expected placeholder refs
|
||||
// - roles
|
||||
roles, _, err := svc.envoy.Decode(ctx, envoyx.DecodeParams{
|
||||
Type: envoyx.DecodeTypeStore,
|
||||
Params: map[string]any{
|
||||
"storer": s,
|
||||
"dal": dal.Service(),
|
||||
},
|
||||
Filter: map[string]envoyx.ResourceFilter{
|
||||
systemTypes.RoleResourceType: {},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, r := range roles {
|
||||
r.Placeholder = true
|
||||
}
|
||||
nodes = append(nodes, roles...)
|
||||
|
||||
// run the import
|
||||
gg, err := svc.envoy.Bake(ctx, envoyx.EncodeParams{
|
||||
Type: envoyx.EncodeTypeStore,
|
||||
Params: map[string]any{
|
||||
"storer": s,
|
||||
"dal": dal.Service(),
|
||||
},
|
||||
}, nil, nodes...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = svc.envoy.Encode(ctx, envoyx.EncodeParams{
|
||||
Type: envoyx.EncodeTypeStore,
|
||||
Params: map[string]any{
|
||||
"storer": s,
|
||||
"dal": dal.Service(),
|
||||
},
|
||||
}, gg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc namespace) reloadServices(ctx context.Context, ns *types.Namespace) (err error) {
|
||||
// Adjust name res. tr. since we're changing it
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, &locale.ResourceTranslation{
|
||||
Resource: ns.ResourceTranslation(),
|
||||
Key: types.LocaleKeyNamespaceName.Path,
|
||||
Msg: locale.SanitizeMessage(ns.Name),
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Reload RBAC rules (in case import brought in something new)
|
||||
rbac.Global().Reload(ctx)
|
||||
if err = locale.Global().ReloadResourceTranslations(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Reload workflow-triggers (in case import brought in something new)
|
||||
if err = automationService.DefaultWorkflow.Load(ctx); err != nil {
|
||||
// should not be a fatal error
|
||||
err = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func loadNamespace(ctx context.Context, s store.ComposeNamespaces, namespaceID uint64) (ns *types.Namespace, err error) {
|
||||
if namespaceID == 0 {
|
||||
return nil, ChartErrInvalidNamespaceID()
|
||||
}
|
||||
|
||||
if ns, err = store.LookupComposeNamespaceByID(ctx, s, namespaceID); errors.IsNotFound(err) {
|
||||
return nil, NamespaceErrNotFound()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// toLabeledNamespaces converts to []label.LabeledResource
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func toLabeledNamespaces(set []*types.Namespace) []label.LabeledResource {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ll := make([]label.LabeledResource, len(set))
|
||||
for i := range set {
|
||||
ll[i] = set[i]
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
1058
server/compose/service/namespace_actions.gen.go
generated
Normal file
1058
server/compose/service/namespace_actions.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
128
server/compose/service/namespace_actions.yaml
Normal file
128
server/compose/service/namespace_actions.yaml
Normal file
@@ -0,0 +1,128 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:namespace
|
||||
service: namespace
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID, enabled ]
|
||||
- name: changed
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID, meta, enabled ]
|
||||
- name: archiveFormat
|
||||
type: string
|
||||
- name: filter
|
||||
type: "*types.NamespaceFilter"
|
||||
fields: [ query, slug, sort, limit ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for namespaces"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{namespace}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{namespace}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{namespace}}"
|
||||
|
||||
- action: clone
|
||||
log: "cloned {namespace}"
|
||||
|
||||
- action: export
|
||||
log: "exported {namespace}"
|
||||
|
||||
- action: importInit
|
||||
log: "import initialized for {namespace}"
|
||||
|
||||
- action: importRun
|
||||
log: "imported {namespace}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{namespace}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{namespace}}"
|
||||
|
||||
- action: reorder
|
||||
log: "reordered {{namespace}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "namespace does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidHandle
|
||||
message: "invalid handle"
|
||||
severity: warning
|
||||
|
||||
- error: handleNotUnique
|
||||
message: "handle not unique"
|
||||
log: "used duplicate handle ({{namespace.slug}}) for namespace"
|
||||
severity: warning
|
||||
|
||||
- error: staleData
|
||||
message: "namespace was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: unsupportedExportFormat
|
||||
message: "unsupported export format"
|
||||
log: "could not export namespace {{namespace}}; unsupported format {{archiveFormat}}"
|
||||
|
||||
- error: unsupportedImportFormat
|
||||
message: "unsupported import format"
|
||||
log: "could not import namespace {{namespace}}; unsupported format {{archiveFormat}}"
|
||||
|
||||
- error: importMissingNamespace
|
||||
message: "import source does not contain a namespace"
|
||||
log: "could not import namespace; import source does not contain a namespace"
|
||||
|
||||
- error: importSessionNotFound
|
||||
message: "the import session does not exist"
|
||||
log: "could not import namespace {{namespace}}; the import session does not exist"
|
||||
|
||||
- error: cloneMultiple
|
||||
message: "not allowed to clone multiple namespaces at once"
|
||||
log: "could not clone namespaces; multiple duplications requested at once"
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this namespace"
|
||||
log: "could not read {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list namespaces"
|
||||
log: "could not search or list namespaces; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create namespaces"
|
||||
log: "could not create namespaces; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this namespace"
|
||||
log: "could not update {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this namespace"
|
||||
log: "could not delete {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this namespace"
|
||||
log: "could not undelete {{namespace}}; insufficient permissions"
|
||||
210
server/compose/service/notification.go
Normal file
210
server/compose/service/notification.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gomail "gopkg.in/mail.v2"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
httpClient "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"github.com/cortezaproject/corteza/server/pkg/mail"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
// notification is a service that relays notification to receipients
|
||||
// currently, we only support email notifications
|
||||
//
|
||||
// @todo due to initial architectural decisions, this service landed under compose
|
||||
// but should be moved to system
|
||||
// Warning: API endpoints on compose should be kept so that we do not break backward compatibility)
|
||||
notification struct {
|
||||
actionlog actionlog.Recorder
|
||||
users userFinder
|
||||
}
|
||||
|
||||
notificationUserFinder interface {
|
||||
FindByID(uint64) (*systemTypes.User, error)
|
||||
}
|
||||
)
|
||||
|
||||
func Notification(uf userFinder) *notification {
|
||||
return ¬ification{
|
||||
actionlog: DefaultActionlog,
|
||||
users: uf,
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmail sends email notification
|
||||
func (svc notification) SendEmail(ctx context.Context, n *types.EmailNotification) (err error) {
|
||||
var (
|
||||
aProps = ¬ificationActionProps{mail: n}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
msg := mail.New()
|
||||
|
||||
if len(n.To) == 0 {
|
||||
return NotificationErrNoRecipients()
|
||||
}
|
||||
|
||||
if err = svc.procEmailRecipients(ctx, msg, "To", n.To...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = svc.procEmailRecipients(ctx, msg, "Cc", n.Cc...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(n.ReplyTo) > 0 {
|
||||
// extra check for length because ReplyTo can only hold 1 address!
|
||||
if err = svc.procEmailRecipients(ctx, msg, "ReplyTo", n.ReplyTo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
msg.SetHeader("Subject", n.Subject)
|
||||
|
||||
if len(n.ContentHTML) > 0 {
|
||||
msg.SetBody("text/html", n.ContentHTML)
|
||||
|
||||
}
|
||||
|
||||
if len(n.ContentPlain) > 0 || len(n.ContentHTML) == 0 {
|
||||
// Make sure plain body is always set, even if empty
|
||||
msg.SetBody("text/plain", n.ContentPlain)
|
||||
}
|
||||
|
||||
if err = svc.procEmailAttachments(ctx, msg, n.RemoteAttachments...); err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return mail.Send(msg)
|
||||
}()
|
||||
|
||||
return svc.recordAction(ctx, aProps, NotificationActionSend, err)
|
||||
}
|
||||
|
||||
// procEmailRecipients validates, resolves, formats and attaches set of recipients to message
|
||||
//
|
||||
// Supports 3 input formats:
|
||||
// - <valid email>
|
||||
// - <valid email><space><name...>
|
||||
// - <userID>
|
||||
// Last one is then translated into valid email + name (when/if possible)
|
||||
func (svc notification) procEmailRecipients(ctx context.Context, m *gomail.Message, field string, rr ...string) (err error) {
|
||||
var (
|
||||
email string
|
||||
name string
|
||||
)
|
||||
|
||||
if len(rr) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for r, rcpt := range rr {
|
||||
aProps := ¬ificationActionProps{recipient: rcpt}
|
||||
|
||||
name, email = "", ""
|
||||
rcpt = strings.TrimSpace(rcpt)
|
||||
|
||||
if userID, err := strconv.ParseUint(rcpt, 10, 64); err == nil && userID > 0 {
|
||||
// proc <user ID>
|
||||
if user, err := svc.users.FindByID(ctx, userID); err != nil {
|
||||
return NotificationErrFailedToLoadUser(aProps).Wrap(err)
|
||||
} else {
|
||||
email = user.Email
|
||||
name = user.Name
|
||||
}
|
||||
|
||||
} else if spaceAt := strings.Index(rcpt, " "); spaceAt > -1 {
|
||||
// proc <email> <name> ("foo@bar.baz foo baz")
|
||||
email, name = rcpt[:spaceAt], strings.TrimSpace(rcpt[spaceAt+1:])
|
||||
} else {
|
||||
// proc <email>
|
||||
email = rcpt
|
||||
}
|
||||
|
||||
// Validate email here
|
||||
if !mail.IsValidAddress(email) {
|
||||
return NotificationErrInvalidReceipientFormat(aProps)
|
||||
}
|
||||
|
||||
rr[r] = m.FormatAddress(email, name)
|
||||
}
|
||||
|
||||
m.SetHeader(field, rr...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// procEmailAttachments treats given strings (URLs) as remote objects, downloads and attaches them to
|
||||
// the message
|
||||
//
|
||||
// This could/should be easily extended to support data URLs as well
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
|
||||
func (svc notification) procEmailAttachments(ctx context.Context, message *gomail.Message, aa ...string) error {
|
||||
var (
|
||||
// threading safely (when multiple objects are to be attached)
|
||||
l = &sync.Mutex{}
|
||||
wg = &sync.WaitGroup{}
|
||||
|
||||
client, err = httpClient.New(&httpClient.Config{
|
||||
Timeout: 10,
|
||||
})
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
get := func(url string) error {
|
||||
aProps := ¬ificationActionProps{attachmentURL: url}
|
||||
|
||||
req, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return NotificationErrFailedToDownloadAttachment(aProps).Wrap(err)
|
||||
}
|
||||
|
||||
aProps.setAttachmentType(resp.Header.Get("Content-Type"))
|
||||
aProps.setAttachmentSize(resp.ContentLength)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return NotificationErrFailedToDownloadAttachment(aProps).
|
||||
Wrap(fmt.Errorf("unexpected HTTP status: %s", resp.Status))
|
||||
}
|
||||
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
message.AttachReader(path.Base(req.URL.Path), resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, url := range aa {
|
||||
wg.Add(1)
|
||||
go func(url string) {
|
||||
defer wg.Done()
|
||||
_ = svc.recordAction(
|
||||
ctx,
|
||||
¬ificationActionProps{attachmentURL: url},
|
||||
NotificationActionAttachmentDownload,
|
||||
get(url),
|
||||
)
|
||||
}(url)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
452
server/compose/service/notifications_actions.gen.go
generated
Normal file
452
server/compose/service/notifications_actions.gen.go
generated
Normal file
@@ -0,0 +1,452 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
//
|
||||
// Definitions file that controls how this file is generated:
|
||||
// compose/service/notifications_actions.yaml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
notificationActionProps struct {
|
||||
mail *types.EmailNotification
|
||||
recipient string
|
||||
attachmentURL string
|
||||
attachmentSize int64
|
||||
attachmentType string
|
||||
}
|
||||
|
||||
notificationAction struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *notificationActionProps
|
||||
}
|
||||
|
||||
notificationLogMetaKey struct{}
|
||||
notificationPropsMetaKey struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
// just a placeholder to cover template cases w/o fmt package use
|
||||
_ = fmt.Println
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
// setMail updates notificationActionProps's mail
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *notificationActionProps) setMail(mail *types.EmailNotification) *notificationActionProps {
|
||||
p.mail = mail
|
||||
return p
|
||||
}
|
||||
|
||||
// setRecipient updates notificationActionProps's recipient
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *notificationActionProps) setRecipient(recipient string) *notificationActionProps {
|
||||
p.recipient = recipient
|
||||
return p
|
||||
}
|
||||
|
||||
// setAttachmentURL updates notificationActionProps's attachmentURL
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *notificationActionProps) setAttachmentURL(attachmentURL string) *notificationActionProps {
|
||||
p.attachmentURL = attachmentURL
|
||||
return p
|
||||
}
|
||||
|
||||
// setAttachmentSize updates notificationActionProps's attachmentSize
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *notificationActionProps) setAttachmentSize(attachmentSize int64) *notificationActionProps {
|
||||
p.attachmentSize = attachmentSize
|
||||
return p
|
||||
}
|
||||
|
||||
// setAttachmentType updates notificationActionProps's attachmentType
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *notificationActionProps) setAttachmentType(attachmentType string) *notificationActionProps {
|
||||
p.attachmentType = attachmentType
|
||||
return p
|
||||
}
|
||||
|
||||
// Serialize converts notificationActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p notificationActionProps) Serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
)
|
||||
|
||||
if p.mail != nil {
|
||||
m.Set("mail.subject", p.mail.Subject, true)
|
||||
m.Set("mail.to", p.mail.To, true)
|
||||
m.Set("mail.cc", p.mail.Cc, true)
|
||||
m.Set("mail.replyTo", p.mail.ReplyTo, true)
|
||||
m.Set("mail.contentPlain", p.mail.ContentPlain, true)
|
||||
m.Set("mail.contentHTML", p.mail.ContentHTML, true)
|
||||
m.Set("mail.remoteAttachments", p.mail.RemoteAttachments, true)
|
||||
}
|
||||
m.Set("recipient", p.recipient, true)
|
||||
m.Set("attachmentURL", p.attachmentURL, true)
|
||||
m.Set("attachmentSize", p.attachmentSize, true)
|
||||
m.Set("attachmentType", p.attachmentType, true)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p notificationActionProps) Format(in string, err error) string {
|
||||
var (
|
||||
pairs = []string{"{{err}}"}
|
||||
// first non-empty string
|
||||
fns = func(ii ...interface{}) string {
|
||||
for _, i := range ii {
|
||||
if s := fmt.Sprintf("%v", i); len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
|
||||
if p.mail != nil {
|
||||
// replacement for "{{mail}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{mail}}",
|
||||
fns(
|
||||
p.mail.Subject,
|
||||
p.mail.To,
|
||||
p.mail.Cc,
|
||||
p.mail.ReplyTo,
|
||||
p.mail.ContentPlain,
|
||||
p.mail.ContentHTML,
|
||||
p.mail.RemoteAttachments,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{mail.subject}}", fns(p.mail.Subject))
|
||||
pairs = append(pairs, "{{mail.to}}", fns(p.mail.To))
|
||||
pairs = append(pairs, "{{mail.cc}}", fns(p.mail.Cc))
|
||||
pairs = append(pairs, "{{mail.replyTo}}", fns(p.mail.ReplyTo))
|
||||
pairs = append(pairs, "{{mail.contentPlain}}", fns(p.mail.ContentPlain))
|
||||
pairs = append(pairs, "{{mail.contentHTML}}", fns(p.mail.ContentHTML))
|
||||
pairs = append(pairs, "{{mail.remoteAttachments}}", fns(p.mail.RemoteAttachments))
|
||||
}
|
||||
pairs = append(pairs, "{{recipient}}", fns(p.recipient))
|
||||
pairs = append(pairs, "{{attachmentURL}}", fns(p.attachmentURL))
|
||||
pairs = append(pairs, "{{attachmentSize}}", fns(p.attachmentSize))
|
||||
pairs = append(pairs, "{{attachmentType}}", fns(p.attachmentType))
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (a *notificationAction) String() string {
|
||||
var props = ¬ificationActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.Format(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *notificationAction) ToAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.Serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
// NotificationActionSend returns "compose:notification.send" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationActionSend(props ...*notificationActionProps) *notificationAction {
|
||||
a := ¬ificationAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:notification",
|
||||
action: "send",
|
||||
log: "notification sent",
|
||||
severity: actionlog.Info,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// NotificationActionAttachmentDownload returns "compose:notification.attachmentDownload" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationActionAttachmentDownload(props ...*notificationActionProps) *notificationAction {
|
||||
a := ¬ificationAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:notification",
|
||||
action: "attachmentDownload",
|
||||
log: "attachment downloaded",
|
||||
severity: actionlog.Debug,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
// NotificationErrGeneric returns "compose:notification.generic" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationErrGeneric(mm ...*notificationActionProps) *errors.Error {
|
||||
var p = ¬ificationActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("failed to complete request due to internal error", nil),
|
||||
|
||||
errors.Meta("type", "generic"),
|
||||
errors.Meta("resource", "compose:notification"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(notificationLogMetaKey{}, "{err}"),
|
||||
errors.Meta(notificationPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "notification.errors.generic"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// NotificationErrFailedToLoadUser returns "compose:notification.failedToLoadUser" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationErrFailedToLoadUser(mm ...*notificationActionProps) *errors.Error {
|
||||
var p = ¬ificationActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("could not load user for {{recipient}}", nil),
|
||||
|
||||
errors.Meta("type", "failedToLoadUser"),
|
||||
errors.Meta("resource", "compose:notification"),
|
||||
|
||||
errors.Meta(notificationPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "notification.errors.failedToLoadUser"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// NotificationErrInvalidReceipientFormat returns "compose:notification.invalidReceipientFormat" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationErrInvalidReceipientFormat(mm ...*notificationActionProps) *errors.Error {
|
||||
var p = ¬ificationActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid recipient format ({{recipient}})", nil),
|
||||
|
||||
errors.Meta("type", "invalidReceipientFormat"),
|
||||
errors.Meta("resource", "compose:notification"),
|
||||
|
||||
errors.Meta(notificationPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "notification.errors.invalidReceipientFormat"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// NotificationErrNoRecipients returns "compose:notification.noRecipients" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationErrNoRecipients(mm ...*notificationActionProps) *errors.Error {
|
||||
var p = ¬ificationActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("cannot send email message without recipients", nil),
|
||||
|
||||
errors.Meta("type", "noRecipients"),
|
||||
errors.Meta("resource", "compose:notification"),
|
||||
|
||||
errors.Meta(notificationPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "notification.errors.noRecipients"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// NotificationErrFailedToDownloadAttachment returns "compose:notification.failedToDownloadAttachment" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func NotificationErrFailedToDownloadAttachment(mm ...*notificationActionProps) *errors.Error {
|
||||
var p = ¬ificationActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("could not download attachment from {{attachmentURL}}: {{err}}", nil),
|
||||
|
||||
errors.Meta("type", "failedToDownloadAttachment"),
|
||||
errors.Meta("resource", "compose:notification"),
|
||||
|
||||
errors.Meta(notificationPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "notification.errors.failedToDownloadAttachment"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// It will wrap unrecognized/internal errors with generic errors.
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (svc notification) recordAction(ctx context.Context, props *notificationActionProps, actionFn func(...*notificationActionProps) *notificationAction, err error) error {
|
||||
if svc.actionlog == nil || actionFn == nil {
|
||||
// action log disabled or no action fn passed, return error as-is
|
||||
return err
|
||||
} else if err == nil {
|
||||
// action completed w/o error, record it
|
||||
svc.actionlog.Record(ctx, actionFn(props).ToAction())
|
||||
return nil
|
||||
}
|
||||
|
||||
a := actionFn(props).ToAction()
|
||||
|
||||
// Extracting error information and recording it as action
|
||||
a.Error = err.Error()
|
||||
|
||||
switch c := err.(type) {
|
||||
case *errors.Error:
|
||||
m := c.Meta()
|
||||
|
||||
a.Error = err.Error()
|
||||
a.Severity = actionlog.Severity(m.AsInt("severity"))
|
||||
a.Description = props.Format(m.AsString(notificationLogMetaKey{}), err)
|
||||
|
||||
if p, has := m[notificationPropsMetaKey{}]; has {
|
||||
a.Meta = p.(*notificationActionProps).Serialize()
|
||||
}
|
||||
|
||||
svc.actionlog.Record(ctx, a)
|
||||
default:
|
||||
svc.actionlog.Record(ctx, a)
|
||||
}
|
||||
|
||||
// Original error is passed on
|
||||
return err
|
||||
}
|
||||
44
server/compose/service/notifications_actions.yaml
Normal file
44
server/compose/service/notifications_actions.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:notification
|
||||
service: notification
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: info
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: mail
|
||||
type: "*types.EmailNotification"
|
||||
fields: [ subject, to, cc, replyTo, contentPlain, contentHTML, remoteAttachments ]
|
||||
- name: recipient
|
||||
- name: attachmentURL
|
||||
- name: attachmentSize
|
||||
type: int64
|
||||
- name: attachmentType
|
||||
|
||||
actions:
|
||||
- action: send
|
||||
log: "notification sent"
|
||||
|
||||
- action: attachmentDownload
|
||||
log: "attachment downloaded"
|
||||
severity: debug
|
||||
|
||||
errors:
|
||||
- error: failedToLoadUser
|
||||
message: "could not load user for {{recipient}}"
|
||||
|
||||
- error: invalidReceipientFormat
|
||||
message: "invalid recipient format ({{recipient}})"
|
||||
|
||||
- error: noRecipients
|
||||
message: "cannot send email message without recipients"
|
||||
|
||||
- error: failedToDownloadAttachment
|
||||
message: "could not download attachment from {{attachmentURL}}: {{err}}"
|
||||
762
server/compose/service/page.go
Normal file
762
server/compose/service/page.go
Normal file
@@ -0,0 +1,762 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/service/event"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/handle"
|
||||
"github.com/cortezaproject/corteza/server/pkg/label"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
page struct {
|
||||
actionlog actionlog.Recorder
|
||||
ac pageAccessController
|
||||
eventbus eventDispatcher
|
||||
store store.Storer
|
||||
locale ResourceTranslationsManagerService
|
||||
|
||||
pageSettings *pageSettings
|
||||
}
|
||||
|
||||
pageSettings struct {
|
||||
hideNew bool
|
||||
hideEdit bool
|
||||
hideSubmit bool
|
||||
hideDelete bool
|
||||
hideClone bool
|
||||
hideBack bool
|
||||
}
|
||||
|
||||
pageAccessController interface {
|
||||
CanManageResourceTranslations(ctx context.Context) bool
|
||||
CanSearchPagesOnNamespace(context.Context, *types.Namespace) bool
|
||||
CanReadNamespace(context.Context, *types.Namespace) bool
|
||||
CanCreatePageOnNamespace(context.Context, *types.Namespace) bool
|
||||
CanReadPage(context.Context, *types.Page) bool
|
||||
CanUpdatePage(context.Context, *types.Page) bool
|
||||
CanDeletePage(context.Context, *types.Page) bool
|
||||
}
|
||||
|
||||
pageUpdateHandler func(ctx context.Context, ns *types.Namespace, c *types.Page) (pageChanges, error)
|
||||
pageChanges uint8
|
||||
)
|
||||
|
||||
const (
|
||||
pageUnchanged pageChanges = 0
|
||||
pageChanged pageChanges = 1
|
||||
pageLabelsChanged pageChanges = 2
|
||||
)
|
||||
|
||||
func Page() *page {
|
||||
return &page{
|
||||
actionlog: DefaultActionlog,
|
||||
ac: DefaultAccessControl,
|
||||
eventbus: eventbus.Service(),
|
||||
store: DefaultStore,
|
||||
locale: DefaultResourceTranslation,
|
||||
pageSettings: &pageSettings{},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc page) FindByID(ctx context.Context, namespaceID, pageID uint64) (p *types.Page, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageActionProps) (*types.Page, error) {
|
||||
if pageID == 0 {
|
||||
return nil, PageErrInvalidID()
|
||||
}
|
||||
|
||||
aProps.page.ID = pageID
|
||||
return store.LookupComposePageByID(ctx, svc.store, pageID)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc page) FindByHandle(ctx context.Context, namespaceID uint64, h string) (c *types.Page, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageActionProps) (*types.Page, error) {
|
||||
if !handle.IsValid(h) {
|
||||
return nil, PageErrInvalidHandle()
|
||||
}
|
||||
|
||||
aProps.page.Handle = h
|
||||
return store.LookupComposePageByNamespaceIDHandle(ctx, svc.store, namespaceID, h)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc page) FindByPageID(ctx context.Context, namespaceID, pageID uint64) (p *types.Page, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageActionProps) (*types.Page, error) {
|
||||
if pageID == 0 {
|
||||
return nil, PageErrInvalidID()
|
||||
}
|
||||
|
||||
aProps.page.ID = pageID
|
||||
return store.LookupComposePageByID(ctx, svc.store, pageID)
|
||||
})
|
||||
}
|
||||
|
||||
func checkPage(ctx context.Context, ac pageAccessController) func(res *types.Page) (bool, error) {
|
||||
return func(res *types.Page) (bool, error) {
|
||||
if !ac.CanReadPage(ctx, res) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// search fn() orchestrates pages search, namespace preload and check
|
||||
func (svc page) search(ctx context.Context, filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error) {
|
||||
var (
|
||||
aProps = &pageActionProps{filter: &filter}
|
||||
ns *types.Namespace
|
||||
)
|
||||
|
||||
// For each fetched item, store backend will check if it is valid or not
|
||||
filter.Check = checkPage(ctx, svc.ac)
|
||||
|
||||
err = func() error {
|
||||
ns, err = loadNamespace(ctx, svc.store, filter.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
if !svc.ac.CanSearchPagesOnNamespace(ctx, ns) {
|
||||
return PageErrNotAllowedToSearch()
|
||||
}
|
||||
|
||||
if len(filter.Labels) > 0 {
|
||||
filter.LabeledIDs, err = label.Search(
|
||||
ctx,
|
||||
svc.store,
|
||||
types.Page{}.LabelResourceKind(),
|
||||
filter.Labels,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// labels specified but no labeled resources found
|
||||
if len(filter.LabeledIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if set, f, err = store.SearchComposePages(ctx, svc.store, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, toLabeledPages(set)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// i18n
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
set.Walk(func(p *types.Page) error {
|
||||
p.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, p.ResourceTranslation()))
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return set, f, svc.recordAction(ctx, aProps, PageActionSearch, err)
|
||||
}
|
||||
|
||||
func (svc page) FindBySelfID(ctx context.Context, namespaceID, parentID uint64) (pp types.PageSet, f types.PageFilter, err error) {
|
||||
return svc.search(ctx, types.PageFilter{
|
||||
NamespaceID: namespaceID,
|
||||
ParentID: parentID,
|
||||
|
||||
// This will enable parentID=0 query
|
||||
Root: true,
|
||||
|
||||
Check: checkPage(ctx, svc.ac),
|
||||
})
|
||||
}
|
||||
|
||||
func (svc page) Find(ctx context.Context, filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error) {
|
||||
return svc.search(ctx, filter)
|
||||
}
|
||||
|
||||
func (svc page) Tree(ctx context.Context, namespaceID uint64) (tree types.PageSet, err error) {
|
||||
var (
|
||||
pages types.PageSet
|
||||
filter = types.PageFilter{
|
||||
NamespaceID: namespaceID,
|
||||
Check: checkPage(ctx, svc.ac),
|
||||
}
|
||||
)
|
||||
|
||||
if err = filter.Sort.Set("weight ASC"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if pages, _, err = svc.search(ctx, filter); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// safe to ignore errors
|
||||
_ = pages.Walk(func(p *types.Page) error {
|
||||
if p.SelfID == 0 {
|
||||
tree = append(tree, p)
|
||||
} else if c := pages.FindByID(p.SelfID); c != nil {
|
||||
if c.Children == nil {
|
||||
c.Children = types.PageSet{}
|
||||
}
|
||||
|
||||
c.Children = append(c.Children, p)
|
||||
} else {
|
||||
// Move orphans to root
|
||||
p.SelfID = 0
|
||||
tree = append(tree, p)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// Reorder pages
|
||||
func (svc page) Reorder(ctx context.Context, namespaceID, parentID uint64, pageIDs []uint64) (err error) {
|
||||
var (
|
||||
aProps = &pageActionProps{page: &types.Page{ID: parentID}}
|
||||
ns *types.Namespace
|
||||
p *types.Page
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
||||
if ns, err = loadNamespace(ctx, s, namespaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if parentID == 0 {
|
||||
// Reordering on root mode -- check if user can create pages.
|
||||
if !svc.ac.CanCreatePageOnNamespace(ctx, ns) {
|
||||
return PageErrNotAllowedToUpdate()
|
||||
}
|
||||
} else {
|
||||
// Validate permissions on parent page
|
||||
if p, err = store.LookupComposePageByID(ctx, s, parentID); errors.IsNotFound(err) {
|
||||
return PageErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aProps.setPage(p)
|
||||
|
||||
if !svc.ac.CanUpdatePage(ctx, p) {
|
||||
return PageErrNotAllowedToUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
return store.ReorderComposePages(ctx, s, namespaceID, parentID, pageIDs)
|
||||
})
|
||||
|
||||
return svc.recordAction(ctx, aProps, PageActionReorder, err)
|
||||
|
||||
}
|
||||
|
||||
func (svc page) Create(ctx context.Context, new *types.Page) (*types.Page, error) {
|
||||
var (
|
||||
ns *types.Namespace
|
||||
aProps = &pageActionProps{page: new}
|
||||
)
|
||||
|
||||
new.ID = 0
|
||||
|
||||
err := store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if !handle.IsValid(new.Handle) {
|
||||
return PageErrInvalidID()
|
||||
}
|
||||
|
||||
if ns, err = loadNamespace(ctx, s, new.NamespaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !svc.ac.CanCreatePageOnNamespace(ctx, ns) {
|
||||
return PageErrNotAllowedToCreate()
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
|
||||
if err = svc.eventbus.WaitFor(ctx, event.PageBeforeCreate(new, nil, ns, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = svc.uniqueCheck(ctx, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
new.ID = nextID()
|
||||
new.CreatedAt = *now()
|
||||
new.UpdatedAt = nil
|
||||
new.DeletedAt = nil
|
||||
|
||||
// Ensure page-block IDs
|
||||
for i := range new.Blocks {
|
||||
new.Blocks[i].BlockID = uint64(i) + 1
|
||||
}
|
||||
|
||||
aProps.setChanged(new)
|
||||
|
||||
if err = store.CreateComposePage(ctx, s, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, new.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Create(ctx, s, new); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = svc.eventbus.WaitFor(ctx, event.PageAfterCreate(new, nil, ns, nil))
|
||||
return err
|
||||
})
|
||||
|
||||
return new, svc.recordAction(ctx, aProps, PageActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc page) Update(ctx context.Context, upd *types.Page) (c *types.Page, err error) {
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, res, err := loadPageCombo(ctx, s, upd.NamespaceID, upd.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c, err = svc.updater(ctx, svc.store, ns, res, PageActionUpdate, svc.handleUpdate(ctx, upd))
|
||||
return
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc page) DeleteByID(ctx context.Context, namespaceID, pageID uint64, strategy types.PageChildrenDeleteStrategy) error {
|
||||
var (
|
||||
validChildren, pp types.PageSet
|
||||
|
||||
ns *types.Namespace
|
||||
res *types.Page
|
||||
|
||||
skipUndeleted = func(p *types.Page) (bool, error) {
|
||||
return p.DeletedAt == nil, nil
|
||||
}
|
||||
)
|
||||
|
||||
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if strategy == types.PageChildrenOnDeleteForce {
|
||||
// simply delete the page and ignore the subpages
|
||||
ns, res, err = loadPageCombo(ctx, s, namespaceID, pageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Load all pages in the namespace and
|
||||
// try to figure out the family tree
|
||||
pp, _, err = store.SearchComposePages(ctx, s, types.PageFilter{
|
||||
NamespaceID: namespaceID,
|
||||
})
|
||||
|
||||
if res = pp.FindByID(pageID); res == nil {
|
||||
return PageErrNotFound()
|
||||
}
|
||||
|
||||
validChildren, _ = pp.FindByParent(res.ID).Filter(skipUndeleted)
|
||||
|
||||
switch strategy {
|
||||
case types.PageChildrenOnDeleteAbort:
|
||||
// Abort if there are any valid (undeleted) children
|
||||
if len(validChildren) > 0 {
|
||||
return PageErrDeleteAbortedForPageWithSubpages()
|
||||
}
|
||||
|
||||
case types.PageChildrenOnDeleteRebase:
|
||||
// update all our children to point to our parent
|
||||
err = validChildren.Walk(func(child *types.Page) (err error) {
|
||||
updChild := child.Clone()
|
||||
updChild.SelfID = res.SelfID
|
||||
_, err = svc.updater(ctx, s, ns, child, PageActionUpdate, svc.handleUpdate(ctx, updChild))
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case types.PageChildrenOnDeleteCascade:
|
||||
// update all our children to point to our parent
|
||||
err = pp.RecursiveWalk(res, func(child *types.Page, _ *types.Page) (err error) {
|
||||
if child.DeletedAt != nil {
|
||||
// skip the ones that are already deleted
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = svc.updater(ctx, s, ns, child, PageActionDelete, svc.handleDelete)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return PageErrUnknownDeleteStrategy()
|
||||
}
|
||||
|
||||
if ns, err = loadNamespace(ctx, s, namespaceID); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = svc.updater(ctx, svc.store, ns, res, PageActionDelete, svc.handleDelete)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (svc page) UndeleteByID(ctx context.Context, namespaceID, pageID uint64) error {
|
||||
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, res, err := loadPageCombo(ctx, s, namespaceID, pageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = svc.updater(ctx, svc.store, ns, res, PageActionUpdate, svc.handleUndelete)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (svc page) UpdateIcon(ctx context.Context, namespaceID, pageID uint64, icon *types.PageConfigIcon) (out *types.PageConfigIcon, err error) {
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, p, err := loadPageCombo(ctx, s, namespaceID, pageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.Config.NavItem.Icon = icon
|
||||
p, err = svc.updater(ctx, svc.store, ns, p, PageActionUpdate, svc.handleUpdate(ctx, p))
|
||||
out = p.Config.NavItem.Icon
|
||||
|
||||
return
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc page) updater(ctx context.Context, s store.Storer, ns *types.Namespace, res *types.Page, action func(...*pageActionProps) *pageAction, fn pageUpdateHandler) (*types.Page, error) {
|
||||
var (
|
||||
changes pageChanges
|
||||
old *types.Page
|
||||
aProps = &pageActionProps{page: res}
|
||||
err error
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, s, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if err = label.Load(ctx, svc.store, res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
old = res.Clone()
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setChanged(res)
|
||||
|
||||
if res.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageBeforeUpdate(old, res, ns, nil))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageBeforeDelete(old, res, ns, nil))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes, err = fn(ctx, ns, res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if changes&pageChanged > 0 {
|
||||
if err = store.UpdateComposePage(ctx, s, res); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, res.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes&pageLabelsChanged > 0 {
|
||||
if err = label.Update(ctx, s, res); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if res.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageAfterUpdate(res, res, ns, nil))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageAfterDelete(nil, res, ns, nil))
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return res, svc.recordAction(ctx, aProps, action, err)
|
||||
}
|
||||
|
||||
// lookup fn() orchestrates page lookup, namespace preload and check
|
||||
func (svc page) lookup(ctx context.Context, namespaceID uint64, lookup func(*pageActionProps) (*types.Page, error)) (p *types.Page, err error) {
|
||||
var aProps = &pageActionProps{page: &types.Page{NamespaceID: namespaceID}}
|
||||
|
||||
err = func() error {
|
||||
if ns, err := loadNamespace(ctx, svc.store, namespaceID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
aProps.setNamespace(ns)
|
||||
}
|
||||
|
||||
if p, err = lookup(aProps); errors.IsNotFound(err) {
|
||||
return PageErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.DecodeTranslations(svc.locale.Locale().ResourceTranslations(locale.GetAcceptLanguageFromContext(ctx), p.ResourceTranslation()))
|
||||
|
||||
aProps.setPage(p)
|
||||
|
||||
if !svc.ac.CanReadPage(ctx, p) {
|
||||
return PageErrNotAllowedToRead()
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return p, svc.recordAction(ctx, aProps, PageActionLookup, err)
|
||||
}
|
||||
|
||||
func (svc page) uniqueCheck(ctx context.Context, p *types.Page) (err error) {
|
||||
if p.Handle != "" {
|
||||
if e, _ := store.LookupComposePageByNamespaceIDHandle(ctx, svc.store, p.NamespaceID, p.Handle); e != nil && e.ID != p.ID {
|
||||
return PageErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
if p.ModuleID > 0 {
|
||||
if e, _ := store.LookupComposePageByNamespaceIDModuleID(ctx, svc.store, p.NamespaceID, p.ModuleID); e != nil && e.ID != p.ID {
|
||||
return PageErrModuleNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc page) handleUpdate(ctx context.Context, upd *types.Page) pageUpdateHandler {
|
||||
return func(ctx context.Context, ns *types.Namespace, res *types.Page) (changes pageChanges, err error) {
|
||||
if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) {
|
||||
return pageUnchanged, PageErrStaleData()
|
||||
}
|
||||
|
||||
if upd.Handle != res.Handle && !handle.IsValid(upd.Handle) {
|
||||
return pageUnchanged, PageErrInvalidHandle()
|
||||
}
|
||||
|
||||
if err := svc.uniqueCheck(ctx, upd); err != nil {
|
||||
return pageUnchanged, err
|
||||
}
|
||||
|
||||
if !svc.ac.CanUpdatePage(ctx, res) {
|
||||
return pageUnchanged, PageErrNotAllowedToUpdate()
|
||||
}
|
||||
|
||||
// Get max blockID for later use
|
||||
blockID := uint64(0)
|
||||
for _, b := range res.Blocks {
|
||||
if b.BlockID > blockID {
|
||||
blockID = b.BlockID
|
||||
}
|
||||
}
|
||||
|
||||
if res.ID != upd.ID {
|
||||
res.ID = upd.ID
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.SelfID != upd.SelfID {
|
||||
res.SelfID = upd.SelfID
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Config, upd.Config) {
|
||||
res.Config = upd.Config
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Blocks, upd.Blocks) {
|
||||
res.Blocks = upd.Blocks
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
// Assure blockIDs
|
||||
for i, b := range res.Blocks {
|
||||
if b.BlockID == 0 {
|
||||
blockID++
|
||||
b.BlockID = blockID
|
||||
res.Blocks[i] = b
|
||||
|
||||
changes |= pageChanged
|
||||
}
|
||||
}
|
||||
|
||||
if res.Meta.AllowPersonalLayouts != upd.Meta.AllowPersonalLayouts {
|
||||
res.Meta.AllowPersonalLayouts = upd.Meta.AllowPersonalLayouts
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Meta.Notifications, upd.Meta.Notifications) {
|
||||
res.Meta.Notifications = upd.Meta.Notifications
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.Title != upd.Title {
|
||||
res.Title = upd.Title
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.Handle != upd.Handle {
|
||||
res.Handle = upd.Handle
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.Description != upd.Description {
|
||||
res.Description = upd.Description
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.Visible != upd.Visible {
|
||||
res.Visible = upd.Visible
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if res.Weight != upd.Weight {
|
||||
res.Weight = upd.Weight
|
||||
changes |= pageChanged
|
||||
}
|
||||
|
||||
if upd.Labels != nil {
|
||||
if label.Changed(res.Labels, upd.Labels) {
|
||||
changes |= pageLabelsChanged
|
||||
res.Labels = upd.Labels
|
||||
}
|
||||
}
|
||||
|
||||
if changes&pageChanged > 0 {
|
||||
res.UpdatedAt = now()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (svc page) handleDelete(ctx context.Context, ns *types.Namespace, m *types.Page) (pageChanges, error) {
|
||||
if !svc.ac.CanDeletePage(ctx, m) {
|
||||
return pageUnchanged, PageErrNotAllowedToDelete()
|
||||
}
|
||||
|
||||
if m.DeletedAt != nil {
|
||||
// page already deleted
|
||||
return pageUnchanged, nil
|
||||
}
|
||||
|
||||
m.DeletedAt = now()
|
||||
return pageChanged, nil
|
||||
}
|
||||
|
||||
func (svc page) handleUndelete(ctx context.Context, ns *types.Namespace, m *types.Page) (pageChanges, error) {
|
||||
if !svc.ac.CanDeletePage(ctx, m) {
|
||||
return pageUnchanged, PageErrNotAllowedToUndelete()
|
||||
}
|
||||
|
||||
if m.DeletedAt == nil {
|
||||
// page not deleted
|
||||
return pageUnchanged, nil
|
||||
}
|
||||
|
||||
m.DeletedAt = nil
|
||||
return pageChanged, nil
|
||||
}
|
||||
|
||||
func (svc *page) UpdateConfig(ss *systemTypes.AppSettings) {
|
||||
a := ss.Compose.UI.RecordToolbar
|
||||
|
||||
svc.pageSettings = &pageSettings{
|
||||
hideNew: a.HideNew,
|
||||
hideEdit: a.HideEdit,
|
||||
hideSubmit: a.HideSubmit,
|
||||
hideDelete: a.HideDelete,
|
||||
hideClone: a.HideClone,
|
||||
hideBack: a.HideBack,
|
||||
}
|
||||
}
|
||||
|
||||
func loadPageCombo(ctx context.Context, s interface {
|
||||
store.ComposePages
|
||||
store.ComposeNamespaces
|
||||
}, namespaceID, pageID uint64) (ns *types.Namespace, c *types.Page, err error) {
|
||||
ns, err = loadNamespace(ctx, s, namespaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c, err = loadPage(ctx, s, namespaceID, pageID)
|
||||
return
|
||||
}
|
||||
|
||||
func loadPage(ctx context.Context, s store.ComposePages, namespaceID, pageID uint64) (res *types.Page, err error) {
|
||||
if pageID == 0 || namespaceID == 0 {
|
||||
return nil, PageErrInvalidID()
|
||||
}
|
||||
|
||||
if res, err = store.LookupComposePageByID(ctx, s, pageID); errors.IsNotFound(err) {
|
||||
err = PageErrNotFound()
|
||||
}
|
||||
|
||||
if err == nil && namespaceID != res.NamespaceID {
|
||||
// Make sure chart belongs to the right namespace
|
||||
return nil, PageErrNotFound()
|
||||
}
|
||||
|
||||
if err == nil && namespaceID != res.NamespaceID {
|
||||
// Make sure page belongs to the right namespace
|
||||
return nil, PageErrNotFound()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// toLabeledPages converts to []label.LabeledResource
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func toLabeledPages(set []*types.Page) []label.LabeledResource {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ll := make([]label.LabeledResource, len(set))
|
||||
for i := range set {
|
||||
ll[i] = set[i]
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
1083
server/compose/service/page_actions.gen.go
generated
Normal file
1083
server/compose/service/page_actions.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
124
server/compose/service/page_actions.yaml
Normal file
124
server/compose/service/page_actions.yaml
Normal file
@@ -0,0 +1,124 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:page
|
||||
service: page
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: page
|
||||
type: "*types.Page"
|
||||
fields: [ title, handle, ID, namespaceID, moduleID ]
|
||||
- name: changed
|
||||
type: "*types.Page"
|
||||
fields: [ title, handle, ID, namespaceID, description, moduleID, blocks, visible, weight ]
|
||||
- name: filter
|
||||
type: "*types.PageFilter"
|
||||
fields: [ query, handle, root, namespaceID, parentID, sort, limit ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for pages"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{page}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{page}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{page}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{page}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{page}}"
|
||||
|
||||
- action: reorder
|
||||
log: "reordered {{page}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "page does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: moduleNotFound
|
||||
message: "module does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidHandle
|
||||
message: "invalid handle"
|
||||
severity: warning
|
||||
|
||||
- error: handleNotUnique
|
||||
message: "handle not unique"
|
||||
log: "used duplicate handle ({{page.handle}}) for page"
|
||||
severity: warning
|
||||
|
||||
- error: staleData
|
||||
message: "page was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid or missing namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: deleteAbortedForPageWithSubpages
|
||||
message: "removal of page with subpages aborted"
|
||||
severity: debug
|
||||
|
||||
- error: unknownDeleteStrategy
|
||||
message: "unknown delete strategy"
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this page"
|
||||
log: "could not read {{page}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list pages"
|
||||
log: "could not search pages; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadNamespace
|
||||
message: "not allowed to read this namespace"
|
||||
log: "could not read namespace {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToListPages
|
||||
message: "not allowed to list pages"
|
||||
log: "could not list pages; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create pages"
|
||||
log: "could not create pages; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this page"
|
||||
log: "could not update {{page}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this page"
|
||||
log: "could not delete {{page}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this page"
|
||||
log: "could not undelete {{page}}; insufficient permissions"
|
||||
635
server/compose/service/page_layout.go
Normal file
635
server/compose/service/page_layout.go
Normal file
@@ -0,0 +1,635 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/service/event"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/handle"
|
||||
"github.com/cortezaproject/corteza/server/pkg/label"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
pageLayout struct {
|
||||
actionlog actionlog.Recorder
|
||||
ac pageLayoutAccessController
|
||||
eventbus eventDispatcher
|
||||
store store.Storer
|
||||
locale ResourceTranslationsManagerService
|
||||
|
||||
pageLayoutSettings *pageLayoutSettings
|
||||
}
|
||||
|
||||
pageLayoutSettings struct {
|
||||
hideNew bool
|
||||
hideEdit bool
|
||||
hideSubmit bool
|
||||
hideDelete bool
|
||||
hideClone bool
|
||||
hideBack bool
|
||||
}
|
||||
|
||||
pageLayoutAccessController interface {
|
||||
CanManageResourceTranslations(context.Context) bool
|
||||
|
||||
CanCreatePageLayoutOnPage(context.Context, *types.Page) bool
|
||||
CanSearchPageLayoutsOnPage(context.Context, *types.Page) bool
|
||||
CanReadPageLayout(context.Context, *types.PageLayout) bool
|
||||
CanUpdatePageLayout(context.Context, *types.PageLayout) bool
|
||||
CanDeletePageLayout(context.Context, *types.PageLayout) bool
|
||||
}
|
||||
|
||||
pageLayoutUpdateHandler func(ctx context.Context, ns *types.Namespace, pg *types.Page, c *types.PageLayout) (pageLayoutChanges, error)
|
||||
pageLayoutChanges uint8
|
||||
)
|
||||
|
||||
const (
|
||||
pageLayoutUnchanged pageLayoutChanges = 0
|
||||
pageLayoutChanged pageLayoutChanges = 1
|
||||
pageLayoutLabelsChanged pageLayoutChanges = 2
|
||||
)
|
||||
|
||||
func PageLayout() *pageLayout {
|
||||
return &pageLayout{
|
||||
actionlog: DefaultActionlog,
|
||||
ac: DefaultAccessControl,
|
||||
eventbus: eventbus.Service(),
|
||||
store: DefaultStore,
|
||||
locale: DefaultResourceTranslation,
|
||||
pageLayoutSettings: &pageLayoutSettings{},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc pageLayout) FindByID(ctx context.Context, namespaceID, pageLayoutID uint64) (p *types.PageLayout, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageLayoutActionProps) (*types.PageLayout, error) {
|
||||
if pageLayoutID == 0 {
|
||||
return nil, PageLayoutErrInvalidID()
|
||||
}
|
||||
|
||||
aProps.pageLayout.ID = pageLayoutID
|
||||
return store.LookupComposePageLayoutByID(ctx, svc.store, pageLayoutID)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc pageLayout) FindByHandle(ctx context.Context, namespaceID uint64, h string) (c *types.PageLayout, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageLayoutActionProps) (*types.PageLayout, error) {
|
||||
if !handle.IsValid(h) {
|
||||
return nil, PageLayoutErrInvalidHandle()
|
||||
}
|
||||
|
||||
aProps.pageLayout.Handle = h
|
||||
return store.LookupComposePageLayoutByNamespaceIDHandle(ctx, svc.store, namespaceID, h)
|
||||
})
|
||||
}
|
||||
|
||||
func (svc pageLayout) FindByPageLayoutID(ctx context.Context, namespaceID, pageLayoutID uint64) (p *types.PageLayout, err error) {
|
||||
return svc.lookup(ctx, namespaceID, func(aProps *pageLayoutActionProps) (*types.PageLayout, error) {
|
||||
if pageLayoutID == 0 {
|
||||
return nil, PageLayoutErrInvalidID()
|
||||
}
|
||||
|
||||
aProps.pageLayout.ID = pageLayoutID
|
||||
return store.LookupComposePageLayoutByID(ctx, svc.store, pageLayoutID)
|
||||
})
|
||||
}
|
||||
|
||||
func checkPageLayout(ctx context.Context, ac pageLayoutAccessController) func(res *types.PageLayout) (bool, error) {
|
||||
return func(res *types.PageLayout) (bool, error) {
|
||||
if !ac.CanReadPageLayout(ctx, res) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// search fn() orchestrates pageLayouts search, namespace preload and check
|
||||
func (svc pageLayout) search(ctx context.Context, filter types.PageLayoutFilter) (set types.PageLayoutSet, f types.PageLayoutFilter, err error) {
|
||||
var (
|
||||
aProps = &pageLayoutActionProps{filter: &filter}
|
||||
ns *types.Namespace
|
||||
pg *types.Page
|
||||
)
|
||||
|
||||
// For each fetched item, store backend will check if it is valid or not
|
||||
filter.Check = checkPageLayout(ctx, svc.ac)
|
||||
|
||||
err = func() error {
|
||||
if filter.PageID > 0 {
|
||||
ns, pg, err = loadPageCombo(ctx, svc.store, filter.NamespaceID, filter.PageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !svc.ac.CanSearchPageLayoutsOnPage(ctx, pg) {
|
||||
return PageLayoutErrNotAllowedToSearch()
|
||||
}
|
||||
} else {
|
||||
ns, err = loadNamespace(ctx, svc.store, filter.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
|
||||
if len(filter.Labels) > 0 {
|
||||
filter.LabeledIDs, err = label.Search(
|
||||
ctx,
|
||||
svc.store,
|
||||
types.PageLayout{}.LabelResourceKind(),
|
||||
filter.Labels,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// labels specified but no labeled resources found
|
||||
if len(filter.LabeledIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if set, f, err = store.SearchComposePageLayouts(ctx, svc.store, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, toLabeledPageLayouts(set)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// i18n
|
||||
tag := locale.GetAcceptLanguageFromContext(ctx)
|
||||
set.Walk(func(p *types.PageLayout) error {
|
||||
p.DecodeTranslations(svc.locale.Locale().ResourceTranslations(tag, p.ResourceTranslation()))
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return set, f, svc.recordAction(ctx, aProps, PageLayoutActionSearch, err)
|
||||
}
|
||||
|
||||
func (svc pageLayout) Find(ctx context.Context, filter types.PageLayoutFilter) (set types.PageLayoutSet, f types.PageLayoutFilter, err error) {
|
||||
return svc.search(ctx, filter)
|
||||
}
|
||||
|
||||
func (svc pageLayout) Create(ctx context.Context, new *types.PageLayout) (*types.PageLayout, error) {
|
||||
var (
|
||||
aProps = &pageLayoutActionProps{pageLayout: new}
|
||||
ns *types.Namespace
|
||||
pg *types.Page
|
||||
)
|
||||
|
||||
new.ID = 0
|
||||
|
||||
err := store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if !handle.IsValid(new.Handle) {
|
||||
return PageLayoutErrInvalidID()
|
||||
}
|
||||
|
||||
if ns, pg, err = loadPageCombo(ctx, s, new.NamespaceID, new.PageID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Allow users to manage their personal layouts regardless of RBAC (when enabled)
|
||||
if !svc.ac.CanCreatePageLayoutOnPage(ctx, pg) {
|
||||
if new.OwnedBy == 0 || !pg.Meta.AllowPersonalLayouts {
|
||||
return PageLayoutErrNotAllowedToCreate()
|
||||
}
|
||||
}
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
|
||||
if err = svc.eventbus.WaitFor(ctx, event.PageLayoutBeforeCreate(new, nil, ns, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = svc.uniqueCheck(ctx, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
new.ID = nextID()
|
||||
new.CreatedAt = *now()
|
||||
new.UpdatedAt = nil
|
||||
new.DeletedAt = nil
|
||||
|
||||
// Ensure pageLayout-block IDs
|
||||
for i := range new.Blocks {
|
||||
new.Blocks[i].BlockID = uint64(i) + 1
|
||||
}
|
||||
|
||||
aProps.setChanged(new)
|
||||
|
||||
if err = store.CreateComposePageLayout(ctx, s, new); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, new.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = label.Create(ctx, s, new); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = svc.eventbus.WaitFor(ctx, event.PageLayoutAfterCreate(new, nil, ns, nil))
|
||||
return err
|
||||
})
|
||||
|
||||
return new, svc.recordAction(ctx, aProps, PageLayoutActionCreate, err)
|
||||
}
|
||||
|
||||
func (svc pageLayout) Reorder(ctx context.Context, namespaceID, pageID uint64, pageLayoutIDs []uint64) (err error) {
|
||||
var (
|
||||
aProps = &pageLayoutActionProps{pageLayout: &types.PageLayout{ID: pageID}}
|
||||
p *types.Page
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
|
||||
// Get the page
|
||||
if p, err = store.LookupComposePageByID(ctx, svc.store, pageID); errors.IsNotFound(err) {
|
||||
return PageLayoutErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = p
|
||||
|
||||
// @note following the pattern of pages; should probably change this
|
||||
// on both resources since it doesn't sound right.
|
||||
if !svc.ac.CanCreatePageLayoutOnPage(ctx, p) {
|
||||
return PageLayoutErrNotAllowedToUpdate()
|
||||
}
|
||||
|
||||
return store.ReorderComposePageLayouts(ctx, s, namespaceID, pageID, pageLayoutIDs)
|
||||
})
|
||||
|
||||
return svc.recordAction(ctx, aProps, PageLayoutActionReorder, err)
|
||||
|
||||
}
|
||||
|
||||
func (svc pageLayout) Update(ctx context.Context, upd *types.PageLayout) (c *types.PageLayout, err error) {
|
||||
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, pg, res, err := loadPageLayoutCombo(ctx, s, upd.NamespaceID, upd.PageID, upd.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c, err = svc.updater(ctx, svc.store, ns, pg, res, PageLayoutActionUpdate, svc.handleUpdate(ctx, upd))
|
||||
return
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc pageLayout) DeleteByID(ctx context.Context, namespaceID, pageID, pageLayoutID uint64) error {
|
||||
var (
|
||||
ns *types.Namespace
|
||||
pg *types.Page
|
||||
res *types.PageLayout
|
||||
)
|
||||
|
||||
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
// simply delete the pageLayout and ignore the subpageLayouts
|
||||
ns, pg, res, err = loadPageLayoutCombo(ctx, s, namespaceID, pageID, pageLayoutID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = svc.updater(ctx, svc.store, ns, pg, res, PageLayoutActionDelete, svc.handleDelete)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (svc pageLayout) UndeleteByID(ctx context.Context, namespaceID, pageID, pageLayoutID uint64) error {
|
||||
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
|
||||
ns, pg, res, err := loadPageLayoutCombo(ctx, s, namespaceID, pageID, pageLayoutID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = svc.updater(ctx, svc.store, ns, pg, res, PageLayoutActionUpdate, svc.handleUndelete)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (svc pageLayout) updater(ctx context.Context, s store.Storer, ns *types.Namespace, pg *types.Page, res *types.PageLayout, action func(...*pageLayoutActionProps) *pageLayoutAction, fn pageLayoutUpdateHandler) (*types.PageLayout, error) {
|
||||
var (
|
||||
changes pageLayoutChanges
|
||||
old *types.PageLayout
|
||||
aProps = &pageLayoutActionProps{pageLayout: res}
|
||||
err error
|
||||
)
|
||||
|
||||
err = store.Tx(ctx, s, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if err = label.Load(ctx, svc.store, res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
old = res.Clone()
|
||||
|
||||
aProps.setNamespace(ns)
|
||||
aProps.setChanged(res)
|
||||
|
||||
if res.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageLayoutBeforeUpdate(old, res, ns, nil))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageLayoutBeforeDelete(old, res, ns, nil))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes, err = fn(ctx, ns, pg, res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if changes&pageLayoutChanged > 0 {
|
||||
if err = store.UpdateComposePageLayout(ctx, s, res); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = updateTranslations(ctx, svc.ac, svc.locale, res.EncodeTranslations()...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if changes&pageLayoutLabelsChanged > 0 {
|
||||
if err = label.Update(ctx, s, res); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if res.DeletedAt == nil {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageLayoutAfterUpdate(res, res, ns, nil))
|
||||
} else {
|
||||
err = svc.eventbus.WaitFor(ctx, event.PageLayoutAfterDelete(nil, res, ns, nil))
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return res, svc.recordAction(ctx, aProps, action, err)
|
||||
}
|
||||
|
||||
// lookup fn() orchestrates pageLayout lookup, namespace preload and check
|
||||
func (svc pageLayout) lookup(ctx context.Context, namespaceID uint64, lookup func(*pageLayoutActionProps) (*types.PageLayout, error)) (p *types.PageLayout, err error) {
|
||||
var aProps = &pageLayoutActionProps{pageLayout: &types.PageLayout{NamespaceID: namespaceID}}
|
||||
|
||||
err = func() error {
|
||||
if ns, err := loadNamespace(ctx, svc.store, namespaceID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
aProps.setNamespace(ns)
|
||||
}
|
||||
|
||||
if p, err = lookup(aProps); errors.IsNotFound(err) {
|
||||
return PageLayoutErrNotFound()
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.DecodeTranslations(svc.locale.Locale().ResourceTranslations(locale.GetAcceptLanguageFromContext(ctx), p.ResourceTranslation()))
|
||||
|
||||
aProps.setPageLayout(p)
|
||||
|
||||
if !svc.ac.CanReadPageLayout(ctx, p) {
|
||||
return PageLayoutErrNotAllowedToRead()
|
||||
}
|
||||
|
||||
if err = label.Load(ctx, svc.store, p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return p, svc.recordAction(ctx, aProps, PageLayoutActionLookup, err)
|
||||
}
|
||||
|
||||
func (svc pageLayout) uniqueCheck(ctx context.Context, p *types.PageLayout) (err error) {
|
||||
if p.Handle != "" {
|
||||
if e, _ := store.LookupComposePageLayoutByNamespaceIDPageIDHandle(ctx, svc.store, p.NamespaceID, p.PageID, p.Handle); e != nil && e.ID != p.ID {
|
||||
return PageLayoutErrHandleNotUnique()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc pageLayout) handleUpdate(ctx context.Context, upd *types.PageLayout) pageLayoutUpdateHandler {
|
||||
return func(ctx context.Context, ns *types.Namespace, pg *types.Page, res *types.PageLayout) (changes pageLayoutChanges, err error) {
|
||||
if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) {
|
||||
return pageLayoutUnchanged, PageLayoutErrStaleData()
|
||||
}
|
||||
|
||||
if upd.Handle != res.Handle && !handle.IsValid(upd.Handle) {
|
||||
return pageLayoutUnchanged, PageLayoutErrInvalidHandle()
|
||||
}
|
||||
|
||||
if err := svc.uniqueCheck(ctx, upd); err != nil {
|
||||
return pageLayoutUnchanged, err
|
||||
}
|
||||
|
||||
// Allow users to manage their personal layouts regardless of RBAC (when enabled)
|
||||
if !svc.ac.CanUpdatePageLayout(ctx, res) {
|
||||
if res.OwnedBy == 0 || !pg.Meta.AllowPersonalLayouts {
|
||||
return pageLayoutUnchanged, PageLayoutErrNotAllowedToUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// Get max actionID for later use
|
||||
actionID := uint64(0)
|
||||
for _, a := range res.Config.Actions {
|
||||
if a.ActionID > actionID {
|
||||
actionID = a.ActionID
|
||||
}
|
||||
}
|
||||
|
||||
if res.ID != upd.ID {
|
||||
res.ID = upd.ID
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.PageID != upd.PageID {
|
||||
res.PageID = upd.PageID
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.ParentID != upd.ParentID {
|
||||
res.ParentID = upd.ParentID
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.NamespaceID != upd.NamespaceID {
|
||||
res.NamespaceID = upd.NamespaceID
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Config, upd.Config) {
|
||||
res.Config = upd.Config
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Blocks, upd.Blocks) {
|
||||
res.Blocks = upd.Blocks
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
// Assure blockIDs
|
||||
for i, a := range res.Config.Actions {
|
||||
if a.ActionID == 0 {
|
||||
actionID++
|
||||
a.ActionID = actionID
|
||||
res.Config.Actions[i] = a
|
||||
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
}
|
||||
|
||||
if res.Meta.Title != upd.Meta.Title {
|
||||
res.Meta.Title = upd.Meta.Title
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.Handle != upd.Handle {
|
||||
res.Handle = upd.Handle
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.Meta.Description != upd.Meta.Description {
|
||||
res.Meta.Description = upd.Meta.Description
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(res.Meta.Style, upd.Meta.Style) {
|
||||
res.Meta.Style = upd.Meta.Style
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if res.Primary != upd.Primary {
|
||||
res.Primary = upd.Primary
|
||||
changes |= pageLayoutChanged
|
||||
}
|
||||
|
||||
if upd.Labels != nil {
|
||||
if label.Changed(res.Labels, upd.Labels) {
|
||||
changes |= pageLayoutLabelsChanged
|
||||
res.Labels = upd.Labels
|
||||
}
|
||||
}
|
||||
|
||||
if changes&pageLayoutChanged > 0 {
|
||||
res.UpdatedAt = now()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (svc pageLayout) handleDelete(ctx context.Context, ns *types.Namespace, pg *types.Page, m *types.PageLayout) (pageLayoutChanges, error) {
|
||||
// Allow users to manage their personal layouts regardless of RBAC (when enabled)
|
||||
if !svc.ac.CanDeletePageLayout(ctx, m) {
|
||||
if m.OwnedBy == 0 || !pg.Meta.AllowPersonalLayouts {
|
||||
return pageLayoutUnchanged, PageLayoutErrNotAllowedToDelete()
|
||||
}
|
||||
}
|
||||
|
||||
if m.DeletedAt != nil {
|
||||
// pageLayout already deleted
|
||||
return pageLayoutUnchanged, nil
|
||||
}
|
||||
|
||||
m.DeletedAt = now()
|
||||
return pageLayoutChanged, nil
|
||||
}
|
||||
|
||||
func (svc pageLayout) handleUndelete(ctx context.Context, ns *types.Namespace, pg *types.Page, m *types.PageLayout) (pageLayoutChanges, error) {
|
||||
// Allow users to manage their personal layouts regardless of RBAC (when enabled)
|
||||
if !svc.ac.CanDeletePageLayout(ctx, m) {
|
||||
if m.OwnedBy == 0 || !pg.Meta.AllowPersonalLayouts {
|
||||
return pageLayoutUnchanged, PageLayoutErrNotAllowedToUndelete()
|
||||
}
|
||||
}
|
||||
|
||||
if m.DeletedAt == nil {
|
||||
// pageLayout not deleted
|
||||
return pageLayoutUnchanged, nil
|
||||
}
|
||||
|
||||
m.DeletedAt = nil
|
||||
return pageLayoutChanged, nil
|
||||
}
|
||||
|
||||
func (svc *pageLayout) UpdateConfig(ss *systemTypes.AppSettings) {
|
||||
a := ss.Compose.UI.RecordToolbar
|
||||
|
||||
svc.pageLayoutSettings = &pageLayoutSettings{
|
||||
hideNew: a.HideNew,
|
||||
hideEdit: a.HideEdit,
|
||||
hideSubmit: a.HideSubmit,
|
||||
hideDelete: a.HideDelete,
|
||||
hideClone: a.HideClone,
|
||||
hideBack: a.HideBack,
|
||||
}
|
||||
}
|
||||
|
||||
func loadPageLayoutCombo(ctx context.Context, s interface {
|
||||
store.ComposePageLayouts
|
||||
store.ComposeNamespaces
|
||||
}, namespaceID, pageID, pageLayoutID uint64) (ns *types.Namespace, pg *types.Page, c *types.PageLayout, err error) {
|
||||
ns, err = loadNamespace(ctx, s, namespaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c, err = loadPageLayout(ctx, s, namespaceID, pageID, pageLayoutID)
|
||||
return
|
||||
}
|
||||
|
||||
func loadPageLayout(ctx context.Context, s store.ComposePageLayouts, namespaceID, pageID, pageLayoutID uint64) (res *types.PageLayout, err error) {
|
||||
if pageLayoutID == 0 || namespaceID == 0 {
|
||||
return nil, PageLayoutErrInvalidID()
|
||||
}
|
||||
|
||||
if res, err = store.LookupComposePageLayoutByID(ctx, s, pageLayoutID); errors.IsNotFound(err) {
|
||||
err = PageLayoutErrNotFound()
|
||||
}
|
||||
|
||||
if err == nil && namespaceID != res.NamespaceID {
|
||||
// Make sure chart belongs to the right namespace
|
||||
return nil, PageLayoutErrNotFound()
|
||||
}
|
||||
|
||||
if err == nil && namespaceID != res.NamespaceID {
|
||||
// Make sure pageLayout belongs to the right namespace
|
||||
return nil, PageLayoutErrNotFound()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// toLabeledPageLayouts converts to []label.LabeledResource
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func toLabeledPageLayouts(set []*types.PageLayout) []label.LabeledResource {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ll := make([]label.LabeledResource, len(set))
|
||||
for i := range set {
|
||||
ll[i] = set[i]
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
955
server/compose/service/page_layout_actions.gen.go
generated
Normal file
955
server/compose/service/page_layout_actions.gen.go
generated
Normal file
@@ -0,0 +1,955 @@
|
||||
package service
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
//
|
||||
// Definitions file that controls how this file is generated:
|
||||
// compose/service/page_layout_actions.yaml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
pageLayoutActionProps struct {
|
||||
pageLayout *types.PageLayout
|
||||
changed *types.PageLayout
|
||||
filter *types.PageLayoutFilter
|
||||
namespace *types.Namespace
|
||||
}
|
||||
|
||||
pageLayoutAction struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *pageLayoutActionProps
|
||||
}
|
||||
|
||||
pageLayoutLogMetaKey struct{}
|
||||
pageLayoutPropsMetaKey struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
// just a placeholder to cover template cases w/o fmt package use
|
||||
_ = fmt.Println
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
// setPageLayout updates pageLayoutActionProps's pageLayout
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *pageLayoutActionProps) setPageLayout(pageLayout *types.PageLayout) *pageLayoutActionProps {
|
||||
p.pageLayout = pageLayout
|
||||
return p
|
||||
}
|
||||
|
||||
// setChanged updates pageLayoutActionProps's changed
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *pageLayoutActionProps) setChanged(changed *types.PageLayout) *pageLayoutActionProps {
|
||||
p.changed = changed
|
||||
return p
|
||||
}
|
||||
|
||||
// setFilter updates pageLayoutActionProps's filter
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *pageLayoutActionProps) setFilter(filter *types.PageLayoutFilter) *pageLayoutActionProps {
|
||||
p.filter = filter
|
||||
return p
|
||||
}
|
||||
|
||||
// setNamespace updates pageLayoutActionProps's namespace
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p *pageLayoutActionProps) setNamespace(namespace *types.Namespace) *pageLayoutActionProps {
|
||||
p.namespace = namespace
|
||||
return p
|
||||
}
|
||||
|
||||
// Serialize converts pageLayoutActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p pageLayoutActionProps) Serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
)
|
||||
|
||||
if p.pageLayout != nil {
|
||||
m.Set("pageLayout.handle", p.pageLayout.Handle, true)
|
||||
m.Set("pageLayout.ID", p.pageLayout.ID, true)
|
||||
m.Set("pageLayout.namespaceID", p.pageLayout.NamespaceID, true)
|
||||
}
|
||||
if p.changed != nil {
|
||||
m.Set("changed.handle", p.changed.Handle, true)
|
||||
m.Set("changed.ID", p.changed.ID, true)
|
||||
m.Set("changed.namespaceID", p.changed.NamespaceID, true)
|
||||
m.Set("changed.blocks", p.changed.Blocks, true)
|
||||
}
|
||||
if p.filter != nil {
|
||||
m.Set("filter.query", p.filter.Query, true)
|
||||
m.Set("filter.handle", p.filter.Handle, true)
|
||||
m.Set("filter.namespaceID", p.filter.NamespaceID, true)
|
||||
m.Set("filter.sort", p.filter.Sort, true)
|
||||
m.Set("filter.limit", p.filter.Limit, true)
|
||||
}
|
||||
if p.namespace != nil {
|
||||
m.Set("namespace.slug", p.namespace.Slug, true)
|
||||
m.Set("namespace.ID", p.namespace.ID, true)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (p pageLayoutActionProps) Format(in string, err error) string {
|
||||
var (
|
||||
pairs = []string{"{{err}}"}
|
||||
// first non-empty string
|
||||
fns = func(ii ...interface{}) string {
|
||||
for _, i := range ii {
|
||||
if s := fmt.Sprintf("%v", i); len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
|
||||
if p.pageLayout != nil {
|
||||
// replacement for "{{pageLayout}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{pageLayout}}",
|
||||
fns(
|
||||
p.pageLayout.Handle,
|
||||
p.pageLayout.ID,
|
||||
p.pageLayout.NamespaceID,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{pageLayout.handle}}", fns(p.pageLayout.Handle))
|
||||
pairs = append(pairs, "{{pageLayout.ID}}", fns(p.pageLayout.ID))
|
||||
pairs = append(pairs, "{{pageLayout.namespaceID}}", fns(p.pageLayout.NamespaceID))
|
||||
}
|
||||
|
||||
if p.changed != nil {
|
||||
// replacement for "{{changed}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{changed}}",
|
||||
fns(
|
||||
p.changed.Handle,
|
||||
p.changed.ID,
|
||||
p.changed.NamespaceID,
|
||||
p.changed.Blocks,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{changed.handle}}", fns(p.changed.Handle))
|
||||
pairs = append(pairs, "{{changed.ID}}", fns(p.changed.ID))
|
||||
pairs = append(pairs, "{{changed.namespaceID}}", fns(p.changed.NamespaceID))
|
||||
pairs = append(pairs, "{{changed.blocks}}", fns(p.changed.Blocks))
|
||||
}
|
||||
|
||||
if p.filter != nil {
|
||||
// replacement for "{{filter}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{filter}}",
|
||||
fns(
|
||||
p.filter.Query,
|
||||
p.filter.Handle,
|
||||
p.filter.NamespaceID,
|
||||
p.filter.Sort,
|
||||
p.filter.Limit,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{filter.query}}", fns(p.filter.Query))
|
||||
pairs = append(pairs, "{{filter.handle}}", fns(p.filter.Handle))
|
||||
pairs = append(pairs, "{{filter.namespaceID}}", fns(p.filter.NamespaceID))
|
||||
pairs = append(pairs, "{{filter.sort}}", fns(p.filter.Sort))
|
||||
pairs = append(pairs, "{{filter.limit}}", fns(p.filter.Limit))
|
||||
}
|
||||
|
||||
if p.namespace != nil {
|
||||
// replacement for "{{namespace}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{namespace}}",
|
||||
fns(
|
||||
p.namespace.Slug,
|
||||
p.namespace.ID,
|
||||
),
|
||||
)
|
||||
pairs = append(pairs, "{{namespace.slug}}", fns(p.namespace.Slug))
|
||||
pairs = append(pairs, "{{namespace.ID}}", fns(p.namespace.ID))
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (a *pageLayoutAction) String() string {
|
||||
var props = &pageLayoutActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.Format(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *pageLayoutAction) ToAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.Serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
// PageLayoutActionSearch returns "compose:page-layout.search" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionSearch(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "search",
|
||||
log: "searched for pageLayouts",
|
||||
severity: actionlog.Info,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionLookup returns "compose:page-layout.lookup" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionLookup(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "lookup",
|
||||
log: "looked-up for a {{pageLayout}}",
|
||||
severity: actionlog.Info,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionCreate returns "compose:page-layout.create" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionCreate(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "create",
|
||||
log: "created {{pageLayout}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionUpdate returns "compose:page-layout.update" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionUpdate(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "update",
|
||||
log: "updated {{pageLayout}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionReorder returns "compose:page-layout.reorder" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionReorder(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "reorder",
|
||||
log: "reordered {{pageLayout}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionDelete returns "compose:page-layout.delete" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionDelete(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "delete",
|
||||
log: "deleted {{pageLayout}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// PageLayoutActionUndelete returns "compose:page-layout.undelete" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutActionUndelete(props ...*pageLayoutActionProps) *pageLayoutAction {
|
||||
a := &pageLayoutAction{
|
||||
timestamp: time.Now(),
|
||||
resource: "compose:page-layout",
|
||||
action: "undelete",
|
||||
log: "undeleted {{pageLayout}}",
|
||||
severity: actionlog.Notice,
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
// PageLayoutErrGeneric returns "compose:page-layout.generic" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrGeneric(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("failed to complete request due to internal error", nil),
|
||||
|
||||
errors.Meta("type", "generic"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "{err}"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.generic"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotFound returns "compose:page-layout.notFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotFound(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("page layout does not exist", nil),
|
||||
|
||||
errors.Meta("type", "notFound"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNamespaceNotFound returns "compose:page-layout.namespaceNotFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNamespaceNotFound(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("namespace does not exist", nil),
|
||||
|
||||
errors.Meta("type", "namespaceNotFound"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.namespaceNotFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrModuleNotFound returns "compose:page-layout.moduleNotFound" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrModuleNotFound(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("module does not exist", nil),
|
||||
|
||||
errors.Meta("type", "moduleNotFound"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.moduleNotFound"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrInvalidID returns "compose:page-layout.invalidID" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrInvalidID(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid ID", nil),
|
||||
|
||||
errors.Meta("type", "invalidID"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.invalidID"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrInvalidHandle returns "compose:page-layout.invalidHandle" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrInvalidHandle(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid handle", nil),
|
||||
|
||||
errors.Meta("type", "invalidHandle"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.invalidHandle"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrHandleNotUnique returns "compose:page-layout.handleNotUnique" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrHandleNotUnique(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("handle not unique", nil),
|
||||
|
||||
errors.Meta("type", "handleNotUnique"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "used duplicate handle ({{pageLayout.handle}}) for page layout"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.handleNotUnique"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrStaleData returns "compose:page-layout.staleData" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrStaleData(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("page layout was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version", nil),
|
||||
|
||||
errors.Meta("type", "staleData"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.staleData"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrInvalidNamespaceID returns "compose:page-layout.invalidNamespaceID" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrInvalidNamespaceID(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("invalid or missing namespace ID", nil),
|
||||
|
||||
errors.Meta("type", "invalidNamespaceID"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.invalidNamespaceID"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToRead returns "compose:page-layout.notAllowedToRead" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToRead(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to read this page layout", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToRead"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not read {{pageLayout}}; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToRead"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToSearch returns "compose:page-layout.notAllowedToSearch" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToSearch(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to search or list page layouts", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToSearch"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not search pageLayouts; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToSearch"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToListPageLayouts returns "compose:page-layout.notAllowedToListPageLayouts" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToListPageLayouts(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to list page layouts", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToListPageLayouts"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not list pageLayouts; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToListPageLayouts"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToCreate returns "compose:page-layout.notAllowedToCreate" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToCreate(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to create page layouts", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToCreate"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not create pageLayouts; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToCreate"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToUpdate returns "compose:page-layout.notAllowedToUpdate" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToUpdate(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to update this page layout", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToUpdate"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not update {{pageLayout}}; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToUpdate"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToDelete returns "compose:page-layout.notAllowedToDelete" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToDelete(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to delete this page layout", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToDelete"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not delete {{pageLayout}}; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToDelete"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// PageLayoutErrNotAllowedToUndelete returns "compose:page-layout.notAllowedToUndelete" as *errors.Error
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func PageLayoutErrNotAllowedToUndelete(mm ...*pageLayoutActionProps) *errors.Error {
|
||||
var p = &pageLayoutActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("not allowed to undelete this page layout", nil),
|
||||
|
||||
errors.Meta("type", "notAllowedToUndelete"),
|
||||
errors.Meta("resource", "compose:page-layout"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(pageLayoutLogMetaKey{}, "could not undelete {{pageLayout}}; insufficient permissions"),
|
||||
errors.Meta(pageLayoutPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "compose"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "page-layout.errors.notAllowedToUndelete"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// It will wrap unrecognized/internal errors with generic errors.
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (svc pageLayout) recordAction(ctx context.Context, props *pageLayoutActionProps, actionFn func(...*pageLayoutActionProps) *pageLayoutAction, err error) error {
|
||||
if svc.actionlog == nil || actionFn == nil {
|
||||
// action log disabled or no action fn passed, return error as-is
|
||||
return err
|
||||
} else if err == nil {
|
||||
// action completed w/o error, record it
|
||||
svc.actionlog.Record(ctx, actionFn(props).ToAction())
|
||||
return nil
|
||||
}
|
||||
|
||||
a := actionFn(props).ToAction()
|
||||
|
||||
// Extracting error information and recording it as action
|
||||
a.Error = err.Error()
|
||||
|
||||
switch c := err.(type) {
|
||||
case *errors.Error:
|
||||
m := c.Meta()
|
||||
|
||||
a.Error = err.Error()
|
||||
a.Severity = actionlog.Severity(m.AsInt("severity"))
|
||||
a.Description = props.Format(m.AsString(pageLayoutLogMetaKey{}), err)
|
||||
|
||||
if p, has := m[pageLayoutPropsMetaKey{}]; has {
|
||||
a.Meta = p.(*pageLayoutActionProps).Serialize()
|
||||
}
|
||||
|
||||
svc.actionlog.Record(ctx, a)
|
||||
default:
|
||||
svc.actionlog.Record(ctx, a)
|
||||
}
|
||||
|
||||
// Original error is passed on
|
||||
return err
|
||||
}
|
||||
113
server/compose/service/page_layout_actions.yaml
Normal file
113
server/compose/service/page_layout_actions.yaml
Normal file
@@ -0,0 +1,113 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:page-layout
|
||||
service: pageLayout
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: pageLayout
|
||||
type: "*types.PageLayout"
|
||||
fields: [ handle, ID, namespaceID ]
|
||||
- name: changed
|
||||
type: "*types.PageLayout"
|
||||
fields: [ handle, ID, namespaceID, blocks ]
|
||||
- name: filter
|
||||
type: "*types.PageLayoutFilter"
|
||||
fields: [ query, handle, namespaceID, sort, limit ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ slug, ID ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for pageLayouts"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{pageLayout}}"
|
||||
severity: info
|
||||
|
||||
- action: create
|
||||
log: "created {{pageLayout}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{pageLayout}}"
|
||||
|
||||
- action: reorder
|
||||
log: "reordered {{pageLayout}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{pageLayout}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{pageLayout}}"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "page layout does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: moduleNotFound
|
||||
message: "module does not exist"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidHandle
|
||||
message: "invalid handle"
|
||||
severity: warning
|
||||
|
||||
- error: handleNotUnique
|
||||
message: "handle not unique"
|
||||
log: "used duplicate handle ({{pageLayout.handle}}) for page layout"
|
||||
severity: warning
|
||||
|
||||
- error: staleData
|
||||
message: "page layout was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid or missing namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this page layout"
|
||||
log: "could not read {{pageLayout}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list page layouts"
|
||||
log: "could not search pageLayouts; insufficient permissions"
|
||||
|
||||
- error: notAllowedToListPageLayouts
|
||||
message: "not allowed to list page layouts"
|
||||
log: "could not list pageLayouts; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create page layouts"
|
||||
log: "could not create pageLayouts; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this page layout"
|
||||
log: "could not update {{pageLayout}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this page layout"
|
||||
log: "could not delete {{pageLayout}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this page layout"
|
||||
log: "could not undelete {{pageLayout}}; insufficient permissions"
|
||||
145
server/compose/service/page_test.go
Normal file
145
server/compose/service/page_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/rbac"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPageDeleting(t *testing.T) {
|
||||
var (
|
||||
//ctx = context.Background()
|
||||
//s, err = sqlite.ConnectInMemory(ctx)
|
||||
|
||||
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
|
||||
s, err = sqlite.ConnectInMemoryWithDebug(ctx)
|
||||
|
||||
namespaceID = nextID()
|
||||
ns *types.Namespace
|
||||
|
||||
pages = types.PageSet{
|
||||
// should be deleted w/o a problem
|
||||
&types.Page{ID: 1},
|
||||
&types.Page{ID: 2},
|
||||
&types.Page{ID: 3, SelfID: 2},
|
||||
//&types.Page{ID: 4},
|
||||
//&types.Page{ID: 5, SelfID: 4},
|
||||
|
||||
// will be used for rebase delete
|
||||
&types.Page{ID: 10},
|
||||
&types.Page{ID: 11, SelfID: 10},
|
||||
&types.Page{ID: 12, SelfID: 10},
|
||||
&types.Page{ID: 121, SelfID: 12},
|
||||
&types.Page{ID: 122, SelfID: 12},
|
||||
|
||||
// will be used for cascade delete
|
||||
&types.Page{ID: 20},
|
||||
&types.Page{ID: 21, SelfID: 20},
|
||||
&types.Page{ID: 22, SelfID: 20},
|
||||
&types.Page{ID: 221, SelfID: 22},
|
||||
&types.Page{ID: 222, SelfID: 22},
|
||||
}
|
||||
|
||||
svc = &page{
|
||||
store: s,
|
||||
ac: &accessControl{rbac: &rbac.ServiceAllowAll{}},
|
||||
eventbus: eventbus.New(),
|
||||
locale: ResourceTranslationsManager(locale.Static()),
|
||||
}
|
||||
|
||||
pageLookup = func(t *testing.T, pageID uint64) *types.Page {
|
||||
p, err := store.LookupComposePageByID(ctx, s, pageID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, p)
|
||||
return p
|
||||
}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init sqlite in-memory db: %v", err)
|
||||
}
|
||||
|
||||
//if err = store.Upgrade(ctx, zap.NewNop(), s); err != nil {
|
||||
if err = store.Upgrade(ctx, logger.MakeDebugLogger(), s); err != nil {
|
||||
t.Fatalf("failed to upgrade store: %v", err)
|
||||
}
|
||||
|
||||
if err = s.TruncateComposeNamespaces(ctx); err != nil {
|
||||
t.Fatalf("failed to truncate compose namespaces: %v", err)
|
||||
}
|
||||
|
||||
if err = s.TruncateComposeModules(ctx); err != nil {
|
||||
t.Fatalf("failed to truncate compose modules: %v", err)
|
||||
}
|
||||
|
||||
//
|
||||
ns = &types.Namespace{Name: "testing", ID: namespaceID, CreatedAt: *now()}
|
||||
if err = store.CreateComposeNamespace(ctx, s, ns); err != nil {
|
||||
t.Fatalf("failed to seed namespaces: %v", err)
|
||||
}
|
||||
|
||||
_ = pages.Walk(func(p *types.Page) error {
|
||||
p.NamespaceID = ns.ID
|
||||
return nil
|
||||
})
|
||||
|
||||
if err = store.CreateComposePage(ctx, s, pages...); err != nil {
|
||||
t.Fatalf("failed to seed pages: %v", err)
|
||||
}
|
||||
|
||||
t.Run("remove page without children", func(t *testing.T) {
|
||||
require.NoError(t, svc.DeleteByID(ctx, ns.ID, 1, types.PageChildrenOnDeleteAbort))
|
||||
require.NotNil(t, pageLookup(t, 1).DeletedAt, "parent should be deleted")
|
||||
})
|
||||
|
||||
t.Run("abort when children are present", func(t *testing.T) {
|
||||
require.ErrorIs(t, svc.DeleteByID(ctx, ns.ID, 2, types.PageChildrenOnDeleteAbort), PageErrDeleteAbortedForPageWithSubpages())
|
||||
require.Nil(t, pageLookup(t, 2).DeletedAt, "parent should be deleted")
|
||||
require.Nil(t, pageLookup(t, 3).DeletedAt, "child should be deleted")
|
||||
})
|
||||
|
||||
t.Run("remove only parent when forced", func(t *testing.T) {
|
||||
require.NoError(t, svc.DeleteByID(ctx, ns.ID, 2, types.PageChildrenOnDeleteForce))
|
||||
require.NotNil(t, pageLookup(t, 2).DeletedAt, "parent should be deleted")
|
||||
require.Nil(t, pageLookup(t, 3).DeletedAt, "child should not be deleted")
|
||||
})
|
||||
|
||||
t.Run("delete parent and rebase children", func(t *testing.T) {
|
||||
require.NoError(t, svc.DeleteByID(ctx, ns.ID, 10, types.PageChildrenOnDeleteRebase))
|
||||
require.NotNil(t, pageLookup(t, 10).DeletedAt)
|
||||
|
||||
for _, pageID := range []uint64{11, 12} {
|
||||
require.Nil(t, pageLookup(t, pageID).DeletedAt, "child page should not be deleted")
|
||||
require.Equal(t, uint64(0), pageLookup(t, pageID).SelfID, "child page should be moved one level lower")
|
||||
}
|
||||
|
||||
for _, pageID := range []uint64{121, 122} {
|
||||
require.Nil(t, pageLookup(t, pageID).DeletedAt, "grandchild page should not be deleted")
|
||||
require.Equal(t, uint64(12), pageLookup(t, pageID).SelfID, "grand child page should stay where it is")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete parent and all children", func(t *testing.T) {
|
||||
require.NoError(t, svc.DeleteByID(ctx, ns.ID, 20, types.PageChildrenOnDeleteCascade))
|
||||
require.NotNil(t, pageLookup(t, 20).DeletedAt, "parent page should be deleted")
|
||||
|
||||
for _, pageID := range []uint64{21, 22} {
|
||||
require.NotNil(t, pageLookup(t, pageID).DeletedAt, "child page should not be deleted")
|
||||
require.Equal(t, uint64(20), pageLookup(t, pageID).SelfID, "child page should be stay where it is")
|
||||
}
|
||||
|
||||
for _, pageID := range []uint64{221, 222} {
|
||||
require.NotNil(t, pageLookup(t, pageID).DeletedAt, "grandchild page should not be deleted")
|
||||
require.Equal(t, uint64(22), pageLookup(t, pageID).SelfID, "grandchild page should be stay where it is")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
2745
server/compose/service/record.go
Normal file
2745
server/compose/service/record.go
Normal file
File diff suppressed because it is too large
Load Diff
1991
server/compose/service/record_actions.gen.go
generated
Normal file
1991
server/compose/service/record_actions.gen.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
241
server/compose/service/record_actions.yaml
Normal file
241
server/compose/service/record_actions.yaml
Normal file
@@ -0,0 +1,241 @@
|
||||
# List of loggable service actions
|
||||
|
||||
resource: compose:record
|
||||
service: record
|
||||
|
||||
# Default sensitivity for actions
|
||||
defaultActionSeverity: notice
|
||||
|
||||
# default severity for errors
|
||||
defaultErrorSeverity: error
|
||||
|
||||
import:
|
||||
- github.com/cortezaproject/corteza/server/compose/types
|
||||
|
||||
props:
|
||||
- name: record
|
||||
type: "*types.Record"
|
||||
fields: [ ID, moduleID, namespaceID, ownedBy ]
|
||||
- name: changed
|
||||
type: "*types.Record"
|
||||
fields: [ ID, moduleID, namespaceID, ownedBy ]
|
||||
- name: filter
|
||||
type: "*types.RecordFilter"
|
||||
fields: [ query, namespaceID, moduleID, deleted, sort, limit ]
|
||||
- name: namespace
|
||||
type: "*types.Namespace"
|
||||
fields: [ name, slug, ID ]
|
||||
- name: module
|
||||
type: "*types.Module"
|
||||
fields: [ name, handle, ID, namespaceID ]
|
||||
- name: bulkOperation
|
||||
- name: field
|
||||
- name: positionField
|
||||
type: "*types.ModuleField"
|
||||
fields: [ name, label ]
|
||||
- name: groupField
|
||||
type: "*types.ModuleField"
|
||||
fields: [ name, label ]
|
||||
- name: value
|
||||
- name: valueErrors
|
||||
type: "*types.RecordValueErrorSet"
|
||||
fields: [ set ]
|
||||
|
||||
actions:
|
||||
- action: search
|
||||
log: "searched for records"
|
||||
severity: info
|
||||
|
||||
- action: searchSensitive
|
||||
log: "searched for records with sensitive data"
|
||||
severity: info
|
||||
|
||||
- action: lookup
|
||||
log: "looked-up for a {{record}}"
|
||||
severity: info
|
||||
|
||||
- action: report
|
||||
log: "report generated"
|
||||
severity: info
|
||||
|
||||
- action: bulk
|
||||
log: "bulk record operation"
|
||||
|
||||
- action: create
|
||||
log: "created {{record}}"
|
||||
|
||||
- action: update
|
||||
log: "updated {{record}}"
|
||||
|
||||
- action: delete
|
||||
log: "deleted {{record}}"
|
||||
|
||||
- action: patch
|
||||
log: "patched {{record}}"
|
||||
|
||||
- action: undelete
|
||||
log: "undeleted {{record}}"
|
||||
|
||||
- action: import
|
||||
log: "records imported"
|
||||
|
||||
- action: searchRevisions
|
||||
log: "record revisions searched"
|
||||
|
||||
- action: export
|
||||
log: "records exported"
|
||||
|
||||
- action: organize
|
||||
log: "records organized"
|
||||
|
||||
- action: iteratorInvoked
|
||||
log: "iterator invoked"
|
||||
|
||||
- action: iteratorIteration
|
||||
log: "processed record iteration"
|
||||
|
||||
- action: iteratorClone
|
||||
log: "cloned record in iteration"
|
||||
|
||||
- action: iteratorUpdate
|
||||
log: "updated record in iteration"
|
||||
|
||||
- action: iteratorDelete
|
||||
log: "deleted record in iteration"
|
||||
|
||||
- action: iteratorUndelete
|
||||
log: "undeleted record in iteration"
|
||||
|
||||
errors:
|
||||
- error: notFound
|
||||
message: "record not found"
|
||||
severity: warning
|
||||
|
||||
- error: namespaceNotFound
|
||||
message: "namespace not found"
|
||||
severity: warning
|
||||
|
||||
- error: moduleNotFoundModule
|
||||
message: "module not found"
|
||||
severity: warning
|
||||
|
||||
- error: invalidID
|
||||
message: "invalid ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidNamespaceID
|
||||
message: "invalid or missing namespace ID"
|
||||
severity: warning
|
||||
|
||||
- error: invalidModuleID
|
||||
message: "invalid or missing module ID"
|
||||
severity: warning
|
||||
|
||||
- error: staleData
|
||||
message: "record was modified by someone else or by a workflow after you've opened it. Please refresh to see the latest updated version"
|
||||
severity: warning
|
||||
|
||||
- error: notAllowedToFilterByField
|
||||
message: "not allowed to filter by field {{field}}; you do not have permission to read its values"
|
||||
log: "failed to filter by field {{field}}; insufficient permissions to read field values"
|
||||
|
||||
- error: notAllowedToSortByField
|
||||
message: "not allowed to sort by field {{field}}; you do not have permission to read its values"
|
||||
log: "failed to sort by field {{field}}; insufficient permissions to read field values"
|
||||
|
||||
|
||||
- error: notAllowedToRead
|
||||
message: "not allowed to read this record"
|
||||
log: "failed to read {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearch
|
||||
message: "not allowed to search or list records"
|
||||
log: "failed to search or list records; insufficient permissions"
|
||||
|
||||
- error: notAllowedToSearchRevisions
|
||||
message: "not allowed to search or list record revisions"
|
||||
log: "failed to search or list record revisions; insufficient permissions"
|
||||
|
||||
- error: revisionsDisabledOnModule
|
||||
message: "revisions are disabled on module"
|
||||
log: "failed to search or list record revisions; disabled on module"
|
||||
|
||||
- error: notAllowedToReadNamespace
|
||||
message: "not allowed to read this namespace"
|
||||
log: "failed to read namespace {{namespace}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToReadModule
|
||||
message: "not allowed to read module"
|
||||
log: "failed to read module {{module}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToListRecords
|
||||
message: "not allowed to list records"
|
||||
log: "failed to list record; insufficient permissions"
|
||||
|
||||
- error: notAllowedToCreate
|
||||
message: "not allowed to create records"
|
||||
log: "failed to create record; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUpdate
|
||||
message: "not allowed to update this record"
|
||||
log: "failed to update {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToDelete
|
||||
message: "not allowed to delete this record"
|
||||
log: "failed to delete {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToUndelete
|
||||
message: "not allowed to undelete this record"
|
||||
log: "failed to undelete {{record}}; insufficient permissions"
|
||||
|
||||
- error: notAllowedToChangeFieldValue
|
||||
message: "not allowed to change value of field {{field}}"
|
||||
log: "failed to change value of field {{field}}; insufficient permissions"
|
||||
|
||||
- error: maxRecordsReached
|
||||
message: "maximum number of records per namespace reached"
|
||||
log: "maximum number of records per namespace reached"
|
||||
|
||||
# Organizer
|
||||
- error: missingPositionField
|
||||
message: "position module field not found"
|
||||
log: "position module field not found"
|
||||
|
||||
- error: invalidPositionFieldKind
|
||||
message: "invalid position field {{positionField}} kind; kind must be 'Number'"
|
||||
log: "invalid position field {{positionField}} kind; kind must be 'Number'"
|
||||
|
||||
- error: invalidPositionFieldConfigMultiValue
|
||||
message: "invalid position field {{positionField}} configuration; field must not be multi-value"
|
||||
log: "invalid position field {{positionField}} configuration; field must not be multi-value"
|
||||
|
||||
- error: invalidPositionValueType
|
||||
message: "invalid position value data type; value must be numeric"
|
||||
log: "invalid position value data type; value must be numeric"
|
||||
|
||||
- error: missingGroupField
|
||||
message: "group module field not found"
|
||||
log: "group module field not found"
|
||||
- error: invalidGroupFieldConfigMultiValue
|
||||
message: "invalid group field {{groupField}} configuration; field must not be multi-value"
|
||||
log: "invalid group field {{groupField}} configuration; field must not be multi-value"
|
||||
|
||||
# Importing
|
||||
- error: importSessionAlreadActive
|
||||
message: "import session already active"
|
||||
log: "failed to start import session"
|
||||
|
||||
- error: fieldNotFound
|
||||
message: "no such field {{field}}"
|
||||
|
||||
- error: invalidValueStructure
|
||||
message: "more than one value for a single-value field {{field}}"
|
||||
|
||||
- error: unknownBulkOperation
|
||||
message: "unknown bulk operation {{bulkOperation}}"
|
||||
|
||||
- error: invalidReferenceFormat
|
||||
message: "invalid reference format"
|
||||
|
||||
- error: valueInput
|
||||
message: "invalid record value input"
|
||||
157
server/compose/service/record_revisions.go
Normal file
157
server/compose/service/record_revisions.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/revisions"
|
||||
)
|
||||
|
||||
type (
|
||||
// wrapper around revision.service
|
||||
//
|
||||
// handles all module-to-model conversion
|
||||
recordRevisions struct {
|
||||
r interface {
|
||||
Search(ctx context.Context, mf dal.ModelRef, f filter.Filter) (_ dal.Iterator, err error)
|
||||
Create(ctx context.Context, mf dal.ModelRef, revision *revisions.Revision) error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// constructs DAL model-ref from module
|
||||
//
|
||||
// Since we're using revisions model resource type needs to be altered.
|
||||
func (svc *recordRevisions) modelRef(mod *types.Module) (mf dal.ModelRef) {
|
||||
mf = mod.ModelRef()
|
||||
|
||||
// removing resource ID from model filter
|
||||
// this will force DAL service to use FindModelByResourceIdent
|
||||
// to find model with same Resource value and RevisionResourceType
|
||||
// for ResourceType
|
||||
mf.ResourceID = 0
|
||||
mf.ResourceType = revisions.RevisionResourceType
|
||||
return
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) search(ctx context.Context, rec *types.Record, sorting filter.Sorting) (_ dal.Iterator, err error) {
|
||||
var (
|
||||
revModRef = svc.modelRef(rec.GetModule())
|
||||
sortExpr = sorting.OrderBy()
|
||||
)
|
||||
|
||||
if len(sortExpr) == 0 {
|
||||
sortExpr = filter.SortExprSet{
|
||||
&filter.SortExpr{Column: "revision", Descending: true},
|
||||
}
|
||||
}
|
||||
|
||||
revFilter := filter.Generic(
|
||||
filter.WithConstraint("rel_resource", rec.ID),
|
||||
filter.WithOrderBy(sortExpr),
|
||||
)
|
||||
|
||||
return svc.r.Search(ctx, revModRef, revFilter)
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) created(ctx context.Context, new *types.Record) (err error) {
|
||||
var (
|
||||
skipList = svc.skippedField(new.GetModule())
|
||||
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
rev *revisions.Revision
|
||||
)
|
||||
|
||||
rev = revisions.Make(revisions.Created, new.Revision, new.ID, invokerID)
|
||||
err = rev.CollectChanges(new, nil, skipList...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return svc.r.Create(ctx, svc.modelRef(new.GetModule()), rev)
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) updated(ctx context.Context, upd, old *types.Record) (err error) {
|
||||
var (
|
||||
skipList = svc.skippedField(old.GetModule())
|
||||
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
rev *revisions.Revision
|
||||
)
|
||||
|
||||
rev = revisions.Make(revisions.Updated, upd.Revision, upd.ID, invokerID)
|
||||
err = rev.CollectChanges(upd, old, skipList...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return svc.r.Create(ctx, svc.modelRef(upd.GetModule()), rev)
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) softDeleted(ctx context.Context, del *types.Record) (err error) {
|
||||
var (
|
||||
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
rev *revisions.Revision
|
||||
)
|
||||
|
||||
rev = revisions.Make(revisions.SoftDeleted, del.Revision, del.ID, invokerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev)
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) undeleted(ctx context.Context, undel *types.Record) (err error) {
|
||||
var (
|
||||
invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
rev *revisions.Revision
|
||||
)
|
||||
|
||||
rev = revisions.Make(revisions.Undeleted, undel.Revision, undel.ID, invokerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return svc.r.Create(ctx, svc.modelRef(undel.GetModule()), rev)
|
||||
}
|
||||
|
||||
func (svc *recordRevisions) skippedField(mod *types.Module) []string {
|
||||
list := []string{
|
||||
"ID",
|
||||
"namespaceID",
|
||||
"moduleID",
|
||||
"revision",
|
||||
"meta",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
"deletedBy",
|
||||
"deletedAt",
|
||||
}
|
||||
|
||||
for _, f := range mod.Fields {
|
||||
if f.Config.RecordRevisions.Skip {
|
||||
list = append(list, f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// @todo uncomment when supported
|
||||
//func (svc *recordRevisions) hardDeleted(ctx context.Context, del *types.Record) (err error) {
|
||||
// var (
|
||||
// invokerID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
// rev *revisions.Revision
|
||||
// )
|
||||
//
|
||||
// rev, err = revisions.Make(revisions.HardDeleted, del.Revision, del.ID, invokerID, nil, nil)
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev)
|
||||
//}
|
||||
63
server/compose/service/record_revisions_test.go
Normal file
63
server/compose/service/record_revisions_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/revisions"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type (
|
||||
mockRecordRevisionsDAL struct {
|
||||
search func(ctx context.Context, mf dal.ModelRef, f filter.Filter) (_ dal.Iterator, err error)
|
||||
create func(ctx context.Context, mf dal.ModelRef, revision *revisions.Revision) error
|
||||
}
|
||||
)
|
||||
|
||||
func (svc *mockRecordRevisionsDAL) Search(ctx context.Context, mf dal.ModelRef, f filter.Filter) (_ dal.Iterator, err error) {
|
||||
return svc.search(ctx, mf, f)
|
||||
}
|
||||
|
||||
func (svc *mockRecordRevisionsDAL) Create(ctx context.Context, mf dal.ModelRef, rev *revisions.Revision) error {
|
||||
return svc.create(ctx, mf, rev)
|
||||
}
|
||||
|
||||
func TestRecordRevisions(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ctx = context.Background()
|
||||
skip = types.ModuleFieldConfig{RecordRevisions: types.ModuleFieldConfigRecordRevisions{Skip: true}}
|
||||
mod = &types.Module{
|
||||
ID: 1,
|
||||
Name: "test",
|
||||
Fields: []*types.ModuleField{
|
||||
{Name: "rev1", Kind: "string"},
|
||||
{Name: "rev2", Kind: "string"},
|
||||
{Name: "rev3", Kind: "string", Config: skip},
|
||||
},
|
||||
}
|
||||
|
||||
rec = &types.Record{ID: 2, ModuleID: 1}
|
||||
|
||||
dalMock = &mockRecordRevisionsDAL{}
|
||||
|
||||
svc = &recordRevisions{r: dalMock}
|
||||
)
|
||||
|
||||
rec.SetModule(mod)
|
||||
req.NoError(rec.SetValue("rev1", 0, "val1"))
|
||||
req.NoError(rec.SetValue("rev2", 0, "val2"))
|
||||
req.NoError(rec.SetValue("rev3", 0, "val3"))
|
||||
|
||||
var changes int
|
||||
dalMock.create = func(ctx context.Context, mf dal.ModelRef, r *revisions.Revision) error {
|
||||
changes = len(r.Changes)
|
||||
return nil
|
||||
}
|
||||
|
||||
req.NoError(svc.created(ctx, rec))
|
||||
req.Equal(3, changes, "expecting ownedBy, rev1 and rev2")
|
||||
}
|
||||
1068
server/compose/service/record_test.go
Normal file
1068
server/compose/service/record_test.go
Normal file
File diff suppressed because it is too large
Load Diff
320
server/compose/service/service.go
Normal file
320
server/compose/service/service.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
automationService "github.com/cortezaproject/corteza/server/automation/service"
|
||||
"github.com/cortezaproject/corteza/server/compose/automation"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
discoveryService "github.com/cortezaproject/corteza/server/discovery/service"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/corredor"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/healthcheck"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/objstore"
|
||||
"github.com/cortezaproject/corteza/server/pkg/objstore/minio"
|
||||
"github.com/cortezaproject/corteza/server/pkg/objstore/plain"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
systemService "github.com/cortezaproject/corteza/server/system/service"
|
||||
systemTypes "github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
userFinder interface {
|
||||
FindByID(context.Context, uint64) (*systemTypes.User, error)
|
||||
}
|
||||
|
||||
schemaAltManager interface {
|
||||
ModelAlterations(context.Context, *dal.Model) (out []*dal.Alteration, err error)
|
||||
SetAlterations(ctx context.Context, s store.Storer, m *dal.Model, stale []*dal.Alteration, set ...*dal.Alteration) (err error)
|
||||
}
|
||||
|
||||
Config struct {
|
||||
ActionLog options.ActionLogOpt
|
||||
Discovery options.DiscoveryOpt
|
||||
Storage options.ObjectStoreOpt
|
||||
Limit options.LimitOpt
|
||||
UserFinder userFinder
|
||||
SchemaAltManager schemaAltManager
|
||||
}
|
||||
|
||||
eventDispatcher interface {
|
||||
WaitFor(ctx context.Context, ev eventbus.Event) (err error)
|
||||
Dispatch(ctx context.Context, ev eventbus.Event)
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultObjectStore objstore.Store
|
||||
|
||||
// DefaultStore is an interface to storage backend(s)
|
||||
// ng (next-gen) is a temporary prefix
|
||||
// so that we can differentiate between it and the file-only store
|
||||
DefaultStore store.Storer
|
||||
|
||||
DefaultLogger *zap.Logger
|
||||
|
||||
DefaultActionlog actionlog.Recorder
|
||||
|
||||
// DefaultAccessControl Access control checking
|
||||
DefaultAccessControl *accessControl
|
||||
|
||||
DefaultNamespace NamespaceService
|
||||
DefaultImportSession ImportSessionService
|
||||
DefaultExportSession ExportSessionService
|
||||
DefaultRecord *record
|
||||
DefaultModule ModuleService
|
||||
DefaultChart *chart
|
||||
DefaultPage *page
|
||||
DefaultPageLayout *pageLayout
|
||||
DefaultAttachment AttachmentService
|
||||
DefaultNotification *notification
|
||||
DefaultResourceTranslation ResourceTranslationsManagerService
|
||||
DefaultDataPrivacy DataPrivacyService
|
||||
|
||||
// wrapper around time.Now() that will aid service testing
|
||||
now = func() *time.Time {
|
||||
c := time.Now().Round(time.Second)
|
||||
return &c
|
||||
}
|
||||
|
||||
// wrapper around time.Now() that will aid service testing
|
||||
nowUTC = func() *time.Time {
|
||||
c := time.Now().Round(time.Second).UTC()
|
||||
return &c
|
||||
}
|
||||
|
||||
// wrapper around nextID that will aid service testing
|
||||
nextID = func() uint64 {
|
||||
return id.Next()
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize compose-only services
|
||||
func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, c Config) (err error) {
|
||||
var (
|
||||
hcd = healthcheck.Defaults()
|
||||
)
|
||||
|
||||
DefaultStore = s
|
||||
|
||||
DefaultLogger = log.Named("service")
|
||||
|
||||
{
|
||||
tee := zap.NewNop()
|
||||
policy := actionlog.MakeProductionPolicy()
|
||||
|
||||
if !c.ActionLog.Enabled {
|
||||
policy = actionlog.MakeDisabledPolicy()
|
||||
} else if c.ActionLog.Debug {
|
||||
policy = actionlog.MakeDebugPolicy()
|
||||
tee = logger.MakeDebugLogger()
|
||||
}
|
||||
|
||||
DefaultActionlog = actionlog.NewService(DefaultStore, log, tee, policy)
|
||||
}
|
||||
|
||||
// Activity log for compose resources
|
||||
{
|
||||
l := log
|
||||
if !c.Discovery.Debug {
|
||||
l = zap.NewNop()
|
||||
}
|
||||
|
||||
DefaultResourceActivity := discoveryService.ResourceActivity(l, c.Discovery, DefaultStore, eventbus.Service())
|
||||
err = DefaultResourceActivity.InitResourceActivityLog(ctx, []string{
|
||||
(types.Namespace{}).LabelResourceKind(),
|
||||
(types.Module{}).LabelResourceKind(),
|
||||
"compose:record",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
DefaultAccessControl = AccessControl(s)
|
||||
DefaultResourceTranslation = ResourceTranslationsManager(locale.Global())
|
||||
|
||||
if DefaultObjectStore == nil {
|
||||
var (
|
||||
opt = c.Storage
|
||||
bucket string
|
||||
)
|
||||
const svcPath = "compose"
|
||||
if opt.MinioEndpoint != "" {
|
||||
bucket = minio.GetBucket(opt.MinioBucket, svcPath)
|
||||
|
||||
DefaultObjectStore, err = minio.New(bucket, opt.MinioPathPrefix, svcPath, minio.Options{
|
||||
Endpoint: opt.MinioEndpoint,
|
||||
Secure: opt.MinioSecure,
|
||||
Strict: opt.MinioStrict,
|
||||
AccessKeyID: opt.MinioAccessKey,
|
||||
SecretAccessKey: opt.MinioSecretKey,
|
||||
|
||||
ServerSideEncryptKey: []byte(opt.MinioSSECKey),
|
||||
})
|
||||
|
||||
log.Info("initializing minio",
|
||||
zap.String("bucket", bucket),
|
||||
zap.String("endpoint", opt.MinioEndpoint),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
path := opt.Path + "/" + svcPath
|
||||
DefaultObjectStore, err = plain.New(path)
|
||||
log.Info("initializing store",
|
||||
zap.String("path", path),
|
||||
zap.Error(err))
|
||||
|
||||
}
|
||||
|
||||
hcd.Add(objstore.Healthcheck(DefaultObjectStore), "ObjectStore/Compose")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
DefaultNamespace = Namespace()
|
||||
DefaultModule = Module(c.SchemaAltManager)
|
||||
|
||||
DefaultImportSession = ImportSession()
|
||||
DefaultExportSession = ExportSession()
|
||||
DefaultRecord = Record(RecordOptions{LimitRecords: c.Limit.RecordCountPerModule})
|
||||
DefaultPage = Page()
|
||||
DefaultPageLayout = PageLayout()
|
||||
DefaultChart = Chart()
|
||||
DefaultNotification = Notification(c.UserFinder)
|
||||
DefaultAttachment = Attachment(DefaultObjectStore, dal.Service())
|
||||
DefaultDataPrivacy = DataPrivacy()
|
||||
|
||||
RegisterIteratorProviders()
|
||||
|
||||
automationService.Registry().AddTypes(
|
||||
automation.ComposeNamespace{},
|
||||
automation.ComposeModule{},
|
||||
automation.ComposeRecord{},
|
||||
automation.ComposeRecordValues{},
|
||||
automation.Attachment{},
|
||||
)
|
||||
|
||||
automation.RecordsHandler(
|
||||
automationService.Registry(),
|
||||
DefaultNamespace,
|
||||
DefaultModule,
|
||||
DefaultRecord,
|
||||
)
|
||||
|
||||
automation.ModulesHandler(
|
||||
automationService.Registry(),
|
||||
DefaultNamespace,
|
||||
DefaultModule,
|
||||
)
|
||||
|
||||
automation.NamespacesHandler(
|
||||
automationService.Registry(),
|
||||
DefaultNamespace,
|
||||
)
|
||||
|
||||
automation.AttachmentHandler(
|
||||
automationService.Registry(),
|
||||
DefaultAttachment,
|
||||
)
|
||||
|
||||
automation.NotificationHandler(
|
||||
automationService.Registry(),
|
||||
systemService.DefaultNotification,
|
||||
systemService.DefaultUser,
|
||||
DefaultNamespace,
|
||||
DefaultModule,
|
||||
log,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Activate(ctx context.Context) (err error) {
|
||||
err = DefaultModule.ReloadDALModels(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func Watchers(ctx context.Context) {
|
||||
//
|
||||
}
|
||||
|
||||
func RegisterIteratorProviders() {
|
||||
// Register resource finders on iterator
|
||||
corredor.Service().RegisterIteratorProvider(
|
||||
"compose:record",
|
||||
func(ctx context.Context, f map[string]string, h eventbus.HandlerFn, action string) (err error) {
|
||||
rf := types.RecordFilter{Query: f["query"]}
|
||||
rf.Sort.Set(f["sort"])
|
||||
|
||||
var limit uint
|
||||
if lString, has := f["limit"]; has {
|
||||
if limit64, err := strconv.ParseUint(lString, 10, 32); err != nil {
|
||||
return fmt.Errorf("cannot parse iterator limit param: %w", err)
|
||||
} else {
|
||||
// We specify the bit size during parsing, so this is fine
|
||||
limit = uint(limit64)
|
||||
}
|
||||
}
|
||||
|
||||
page := f["page"]
|
||||
rf.Paging, err = filter.NewPaging(uint(limit), page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if nsLookup, has := f["namespace"]; !has {
|
||||
return fmt.Errorf("namespace for record iteration filter not defined")
|
||||
} else if ns, err := DefaultNamespace.FindByAny(ctx, nsLookup); err != nil {
|
||||
return err
|
||||
} else {
|
||||
rf.NamespaceID = ns.ID
|
||||
}
|
||||
|
||||
if mLookup, has := f["module"]; !has {
|
||||
return fmt.Errorf("module for record iteration filter not defined")
|
||||
} else if m, err := DefaultModule.FindByAny(ctx, rf.NamespaceID, mLookup); err != nil {
|
||||
return err
|
||||
} else {
|
||||
rf.ModuleID = m.ID
|
||||
}
|
||||
|
||||
return DefaultRecord.Iterator(ctx, rf, h, action)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Data is stale when new date does not match updatedAt or createdAt (before first update)
|
||||
func isStale(new *time.Time, updatedAt *time.Time, createdAt time.Time) bool {
|
||||
if new == nil {
|
||||
// Change to true for stale-data-check
|
||||
return false
|
||||
}
|
||||
|
||||
if updatedAt != nil {
|
||||
return !new.Equal(*updatedAt)
|
||||
}
|
||||
|
||||
return new.Equal(createdAt)
|
||||
}
|
||||
|
||||
// trim1st removes 1st param and returns only error
|
||||
func trim1st(_ interface{}, err error) error {
|
||||
return err
|
||||
}
|
||||
117
server/compose/service/values/expr.go
Normal file
117
server/compose/service/values/expr.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/modern-go/reflect2"
|
||||
)
|
||||
|
||||
func makeInvalidExprErr(field *types.ModuleField, expr string, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "valueExpression",
|
||||
Message: fmt.Sprintf("invalid expression %q: %v", expr, err.Error()),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
func makeExprEvalErr(field *types.ModuleField, expr string, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "valueExpression",
|
||||
Message: fmt.Sprintf("failed to evaluate formula expression %q: %v", expr, err.Error()),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
func makeValueExprIncompErr(field *types.ModuleField) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "evaluatedValueIncompatible",
|
||||
Message: "evaluated results incompatible",
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
// Expression evaluates expression in ModuleField.Expressions.Value and
|
||||
// assigns results to the record on that field
|
||||
func Expression(ctx context.Context, m *types.Module, r *types.Record, old *types.Record, rve *types.RecordValueErrorSet) {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
|
||||
scope = make(map[string]interface{})
|
||||
|
||||
reserved = map[string]bool{
|
||||
"new": true,
|
||||
"old": true,
|
||||
}
|
||||
)
|
||||
|
||||
// base scope with field=value(s) from new record
|
||||
scope = r.Values.Dict(m.Fields)
|
||||
|
||||
// new record
|
||||
r.SetModule(m)
|
||||
scope["new"] = r.Dict()
|
||||
|
||||
if old != nil {
|
||||
// old values on record (before update)
|
||||
// this will not be set for new records
|
||||
old.SetModule(m)
|
||||
scope["old"] = old.Dict()
|
||||
}
|
||||
|
||||
for _, f := range m.Fields {
|
||||
if f.Expressions.ValueExpr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
expr := f.Expressions.ValueExpr
|
||||
|
||||
eval, err := exprParser.NewEvaluable(expr)
|
||||
if err != nil {
|
||||
rve.Push(makeInvalidExprErr(f, expr, err))
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := eval(ctx, scope)
|
||||
if err != nil {
|
||||
rve.Push(makeExprEvalErr(f, expr, err))
|
||||
return
|
||||
}
|
||||
|
||||
var strings []string
|
||||
// When the expression results in null (nil) delete that record value.
|
||||
// The below Values.Replace already does it so we simply ignore strings
|
||||
if !reflect2.IsNil(tmp) {
|
||||
if values, isSlice := tmp.([]interface{}); isSlice {
|
||||
if !f.Multi {
|
||||
rve.Push(makeValueExprIncompErr(f))
|
||||
continue
|
||||
}
|
||||
|
||||
strings = make([]string, len(values))
|
||||
for i, value := range values {
|
||||
strings[i] = sanitize(f, value)
|
||||
}
|
||||
} else {
|
||||
if f.Multi {
|
||||
rve.Push(makeValueExprIncompErr(f))
|
||||
continue
|
||||
}
|
||||
|
||||
strings = []string{sanitize(f, tmp)}
|
||||
}
|
||||
}
|
||||
|
||||
r.Values = r.Values.Replace(f.Name, strings...)
|
||||
|
||||
if !reserved[f.Name] {
|
||||
// make sure we do not overrider reserved fields
|
||||
scope[f.Name] = tmp
|
||||
}
|
||||
|
||||
// Reset $new with updated data
|
||||
r.SetModule(m)
|
||||
scope["new"] = r.Dict()
|
||||
}
|
||||
}
|
||||
179
server/compose/service/values/expr_test.go
Normal file
179
server/compose/service/values/expr_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExpressions(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
|
||||
// pairs = [<fieldname>, <value expression>, ...]
|
||||
makeModule = func(pairs ...string) *types.Module {
|
||||
var (
|
||||
m = &types.Module{}
|
||||
)
|
||||
|
||||
for i := 0; i < len(pairs); i += 3 {
|
||||
f := &types.ModuleField{Name: pairs[i], Kind: pairs[i+1], Options: map[string]interface{}{}}
|
||||
f.Expressions.ValueExpr = pairs[i+2]
|
||||
m.Fields = append(m.Fields, f)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
)
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = &types.Module{}
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.True(rve.IsValid())
|
||||
})
|
||||
|
||||
t.Run("string", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `"abc"`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("abc", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("use fields", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `fname + " " + lname`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "fname", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "lname", Kind: "String"})
|
||||
r.Values = r.Values.Replace("fname", "Cor")
|
||||
r.Values = r.Values.Replace("lname", "Teza")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("Cor Teza", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("math", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "Number", `n1 * n2 * 1.511`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields.FindByName("f1").Options.SetPrecision(1)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "n1", Kind: "Number"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "n2", Kind: "Number"})
|
||||
r.Values = r.Values.Replace("n1", "10")
|
||||
r.Values = r.Values.Replace("n2", "20")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("302.2", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("booleans", func(t *testing.T) {
|
||||
var (
|
||||
m = makeModule("f1", "string", `b1 ? "yes" : "no"`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "b1", Kind: "Bool"})
|
||||
|
||||
t.Run("true", func(t *testing.T) {
|
||||
var req = require.New(t)
|
||||
r.Values = r.Values.Replace("b1", "true")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("yes", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("false", func(t *testing.T) {
|
||||
var req = require.New(t)
|
||||
r.Values = r.Values.Replace("b1", "false")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("no", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("old vs new", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `new.values.test == old.values.test ? "same":"different"`)
|
||||
new = &types.Record{}
|
||||
old = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "test", Kind: "String"})
|
||||
|
||||
new.Values = new.Values.Replace("test", "a")
|
||||
old.Values = old.Values.Replace("test", "a")
|
||||
|
||||
Expression(ctx, m, new, old, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("same", new.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("multi value expressions", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `[t1,t2,t3]`)
|
||||
new = &types.Record{}
|
||||
old = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields.FindByName("f1").Multi = true
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t1", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t2", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t3", Kind: "String"})
|
||||
|
||||
new.Values = new.Values.Replace("t1", "a")
|
||||
new.Values = new.Values.Replace("t2", "b")
|
||||
new.Values = new.Values.Replace("t3", "c")
|
||||
|
||||
Expression(ctx, m, new, old, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("a", new.Values.Get("f1", 0).Value)
|
||||
req.Equal("b", new.Values.Get("f1", 1).Value)
|
||||
req.Equal("c", new.Values.Get("f1", 2).Value)
|
||||
})
|
||||
|
||||
t.Run("omit record value when expr returns null (nil)", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `null`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.True(rve.IsValid())
|
||||
req.Empty(r.Values)
|
||||
})
|
||||
}
|
||||
126
server/compose/service/values/formatter.go
Normal file
126
server/compose/service/values/formatter.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
formatter struct{}
|
||||
)
|
||||
|
||||
// Formatter initializes formatter
|
||||
//
|
||||
// Not really needed, following pattern in the package
|
||||
func Formatter() *formatter {
|
||||
return &formatter{}
|
||||
}
|
||||
|
||||
func (f formatter) Run(m *types.Module, vv types.RecordValueSet) types.RecordValueSet {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
|
||||
log = logger.Default().
|
||||
With(logger.Uint64("module", m.ID))
|
||||
)
|
||||
|
||||
for _, v := range vv {
|
||||
fld := m.Fields.FindByName(v.Name)
|
||||
if fld == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// formatter does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
if fld.Expressions.ValueExpr != "" {
|
||||
// do not do any validation if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if !(fld.Expressions.DisableDefaultFormatters && len(fld.Expressions.Formatters) > 0) {
|
||||
// Per field type validators
|
||||
switch strings.ToLower(fld.Kind) {
|
||||
case "bool":
|
||||
v = f.fBool(v)
|
||||
case "datetime":
|
||||
v = f.fDatetime(v, fld)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fld.Expressions.Formatters) > 0 {
|
||||
for _, expr := range fld.Expressions.Formatters {
|
||||
rval, err := exprParser.Evaluate(expr, map[string]interface{}{"value": v.Value})
|
||||
if err != nil {
|
||||
log.Error(
|
||||
"failed to evaluate sanitizer expression",
|
||||
zap.String("field", fld.Name),
|
||||
zap.String("expr", expr),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
v.Value = sanitize(fld, rval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
// Boolean values are outputed as "1" (true) and "" (false)
|
||||
func (formatter) fBool(v *types.RecordValue) *types.RecordValue {
|
||||
if v.Value != strBoolTrue {
|
||||
v.Value = ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (formatter) fDatetime(v *types.RecordValue, f *types.ModuleField) *types.RecordValue {
|
||||
var (
|
||||
// database formats
|
||||
dbFormats []string
|
||||
|
||||
// output format
|
||||
internalFormat string
|
||||
)
|
||||
|
||||
if f.Options.Bool("onlyDate") {
|
||||
internalFormat = datetimeInternalFormatDate
|
||||
dbFormats = []string{
|
||||
datetimeInternalFormatDate,
|
||||
}
|
||||
} else if f.Options.Bool("onlyTime") {
|
||||
internalFormat = datetimeIntenralFormatTime
|
||||
dbFormats = []string{
|
||||
datetimeIntenralFormatTime,
|
||||
"15:04",
|
||||
}
|
||||
} else {
|
||||
internalFormat = datetimeInternalFormatFull
|
||||
// date & time
|
||||
dbFormats = []string{
|
||||
datetimeInternalFormatFull,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range dbFormats {
|
||||
parsed, err := time.Parse(format, v.Value)
|
||||
if err == nil {
|
||||
v.Value = parsed.UTC().Format(internalFormat)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
v.Value = ""
|
||||
return v
|
||||
|
||||
}
|
||||
285
server/compose/service/values/sanitizer.go
Normal file
285
server/compose/service/values/sanitizer.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/xss"
|
||||
"github.com/spf13/cast"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
)
|
||||
|
||||
type (
|
||||
sanitizer struct{}
|
||||
)
|
||||
|
||||
// Sanitizer initializes sanitizer
|
||||
//
|
||||
// Not really needed, following pattern in the package
|
||||
func Sanitizer() *sanitizer {
|
||||
return &sanitizer{}
|
||||
}
|
||||
|
||||
// Run cleans up input data
|
||||
// - fix multi-value order/place index
|
||||
// - trim all the strings!
|
||||
// - parse & format input values to match field specific -- nullify/falsify invalid
|
||||
// - field kind specific, no errors raised, data is modified
|
||||
//
|
||||
// Existing data (when updating record) is not yet loaded at this point
|
||||
func (s sanitizer) Run(m *types.Module, vv types.RecordValueSet) (out types.RecordValueSet) {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
)
|
||||
|
||||
out = make([]*types.RecordValue, 0, len(vv))
|
||||
|
||||
for _, f := range m.Fields {
|
||||
// Reorder and sanitize place value (no gaps)
|
||||
//
|
||||
// Values are ordered when received so we treat them like it
|
||||
// and assign the appropriate place no.
|
||||
var i = 0
|
||||
for _, v := range vv.FilterByName(f.Name) {
|
||||
if v.IsDeleted() {
|
||||
continue
|
||||
}
|
||||
|
||||
c := v.Clone()
|
||||
c.Place = uint(i)
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
f *types.ModuleField
|
||||
kind string
|
||||
|
||||
log = logger.Default().
|
||||
With(logger.Uint64("module", m.ID))
|
||||
)
|
||||
|
||||
for _, v := range out {
|
||||
f = m.Fields.FindByName(v.Name)
|
||||
if f == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// sanitizer does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
if f.Expressions.ValueExpr != "" {
|
||||
// do not do any sanitization if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if v.IsDeleted() || !v.Updated {
|
||||
// Ignore unchanged and deleted
|
||||
continue
|
||||
}
|
||||
|
||||
kind = strings.ToLower(f.Kind)
|
||||
|
||||
if len(f.Expressions.Sanitizers) > 0 {
|
||||
for _, expr := range f.Expressions.Sanitizers {
|
||||
rval, err := exprParser.Evaluate(expr, map[string]interface{}{"value": v.Value})
|
||||
if err != nil {
|
||||
log.Error(
|
||||
"failed to evaluate sanitizer expression",
|
||||
zap.String("field", f.Name),
|
||||
zap.String("expr", expr),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
v.Value = sanitize(f, rval)
|
||||
}
|
||||
}
|
||||
|
||||
if kind != "string" {
|
||||
// Trim all but string
|
||||
v.Value = strings.TrimSpace(v.Value)
|
||||
}
|
||||
|
||||
if f.IsRef() {
|
||||
if refy.MatchString(v.Value) {
|
||||
v.Ref, _ = strconv.ParseUint(v.Value, 10, 64)
|
||||
}
|
||||
|
||||
if v.Ref == 0 {
|
||||
v.Value = ""
|
||||
}
|
||||
}
|
||||
|
||||
v.Value = sanitize(f, v.Value)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s sanitizer) RunXSS(m *types.Module, vv types.RecordValueSet) types.RecordValueSet {
|
||||
var (
|
||||
f *types.ModuleField
|
||||
)
|
||||
|
||||
for _, v := range vv {
|
||||
f = m.Fields.FindByName(v.Name)
|
||||
if f == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// sanitizer does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "string":
|
||||
v.Value = sString(v.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
func sBool(v interface{}) string {
|
||||
switch c := v.(type) {
|
||||
case bool:
|
||||
if c {
|
||||
return strBoolTrue
|
||||
}
|
||||
|
||||
case string:
|
||||
if truthy.MatchString(strings.ToLower(c)) {
|
||||
return strBoolTrue
|
||||
}
|
||||
}
|
||||
|
||||
// Returning empty string here to align false value with everything else
|
||||
return strBoolFalseAlt
|
||||
}
|
||||
|
||||
func sDatetime(v interface{}, onlyDate, onlyTime bool) string {
|
||||
var (
|
||||
// input format set
|
||||
inputFormats []string
|
||||
|
||||
// output format
|
||||
internalFormat string
|
||||
|
||||
datetime = fmt.Sprintf("%v", v)
|
||||
)
|
||||
|
||||
if onlyTime {
|
||||
internalFormat = datetimeIntenralFormatTime
|
||||
inputFormats = []string{
|
||||
datetimeIntenralFormatTime,
|
||||
"15:04",
|
||||
"15:04:05Z07:00",
|
||||
"15:04:05 MST",
|
||||
"15:04:05 -0700",
|
||||
"15:04 MST",
|
||||
"15:04Z07:00",
|
||||
"15:04 -0700",
|
||||
time.Kitchen,
|
||||
}
|
||||
} else {
|
||||
if onlyDate {
|
||||
// In case only date is used, make sure we format it properly
|
||||
internalFormat = datetimeInternalFormatDate
|
||||
} else {
|
||||
internalFormat = datetimeInternalFormatFull
|
||||
}
|
||||
|
||||
// date & time
|
||||
inputFormats = []string{
|
||||
datetimeInternalFormatFull,
|
||||
"2006-01-02T15:04:05", // iso8601 without timezone
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.RFC822Z,
|
||||
time.RFC822,
|
||||
time.RFC850,
|
||||
time.ANSIC,
|
||||
time.UnixDate,
|
||||
time.RubyDate,
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", // Time.String()
|
||||
"2006-01-02",
|
||||
"02 Jan 2006",
|
||||
"2006-01-02T15:04:05-0700", // RFC3339 without timezone hh:mm colon
|
||||
"2006-01-02 15:04:05 -07:00",
|
||||
"2006-01-02 15:04:05 -0700",
|
||||
"2006-01-02 15:04:05Z07:00", // RFC3339 without T
|
||||
"2006-01-02 15:04:05Z0700", // RFC3339 without T or timezone hh:mm colon
|
||||
"2006-01-02 15:04:05",
|
||||
time.Kitchen,
|
||||
time.Stamp,
|
||||
time.StampMilli,
|
||||
time.StampMicro,
|
||||
time.StampNano,
|
||||
datetimeInternalFormatDate,
|
||||
"02 Jan 06",
|
||||
"Monday, 02-Jan-06",
|
||||
"Mon, 02 Jan 2006",
|
||||
"2006/_1/_2",
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range inputFormats {
|
||||
parsed, err := time.Parse(format, datetime)
|
||||
if err == nil {
|
||||
return parsed.UTC().Format(internalFormat)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func sNumber(num interface{}, p uint, s uint) string {
|
||||
base, err := strconv.ParseFloat(fmt.Sprintf("%v", num), 64)
|
||||
if err != nil {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// Format the value to the desired precision
|
||||
str := strconv.FormatFloat(base, 'f', int(s), 64)
|
||||
|
||||
// In case of fractures, remove trailing 0's
|
||||
if strings.Contains(str, ".") {
|
||||
str = strings.TrimRight(str, "0")
|
||||
str = strings.TrimRight(str, ".")
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
func sString(str interface{}) string {
|
||||
base, err := cast.ToStringE(str)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return xss.RichText(base)
|
||||
}
|
||||
|
||||
// sanitize casts value to field kind format
|
||||
func sanitize(f *types.ModuleField, v interface{}) string {
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "bool":
|
||||
return sBool(v)
|
||||
case "datetime":
|
||||
v = sDatetime(v, f.Options.Bool("onlyDate"), f.Options.Bool("onlyTime"))
|
||||
case "number":
|
||||
// @todo !! This is temporary; precision and scale require a rework
|
||||
v = sNumber(v, maxPrecision, f.Options.Precision())
|
||||
case "string":
|
||||
v = sString(v)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
402
server/compose/service/values/sanitizer_test.go
Normal file
402
server/compose/service/values/sanitizer_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
)
|
||||
|
||||
func Test_sanitizer_Run(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
options map[string]interface{}
|
||||
input string
|
||||
output string
|
||||
outref uint64
|
||||
}{
|
||||
{
|
||||
name: "numbers should be trimmed",
|
||||
kind: "Number",
|
||||
input: " 42 ",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "object reference should be processed",
|
||||
kind: "Record",
|
||||
input: " 133569629112020995 ",
|
||||
output: "133569629112020995",
|
||||
outref: 133569629112020995,
|
||||
},
|
||||
{
|
||||
name: "object reference should be numeric",
|
||||
kind: "Record",
|
||||
input: " foo ",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "user reference should be processed",
|
||||
kind: "User",
|
||||
input: " 133569629112020995 ",
|
||||
output: "133569629112020995",
|
||||
outref: 133569629112020995,
|
||||
},
|
||||
{
|
||||
name: "user reference should be numeric",
|
||||
kind: "User",
|
||||
input: " foo ",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "strings should be kept intact",
|
||||
kind: "String",
|
||||
input: " The answer ",
|
||||
output: " The answer ",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (t)",
|
||||
kind: "Bool",
|
||||
input: "t",
|
||||
output: "1",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (false)",
|
||||
kind: "Bool",
|
||||
input: "false",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (garbage)",
|
||||
kind: "Bool",
|
||||
input: "%%#)%)')$)'",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "dates should be converted to ISO",
|
||||
kind: "DateTime",
|
||||
input: "Mon Jan 2 15:04:05 2006",
|
||||
output: "2006-01-02T15:04:05Z",
|
||||
},
|
||||
{
|
||||
name: "dates should be converted to UTC",
|
||||
kind: "DateTime",
|
||||
input: "2020-03-02T20:20:20+05:00",
|
||||
output: "2020-03-02T15:20:20Z",
|
||||
},
|
||||
{
|
||||
name: "micro/mili seconds should be cut off",
|
||||
kind: "DateTime",
|
||||
input: "2020-03-11T11:20:08.471Z",
|
||||
output: "2020-03-11T11:20:08Z",
|
||||
},
|
||||
{
|
||||
name: "number space trim",
|
||||
kind: "Number",
|
||||
input: " 42 ",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number negative",
|
||||
kind: "Number",
|
||||
input: "-42",
|
||||
output: "-42",
|
||||
},
|
||||
{
|
||||
name: "number positive",
|
||||
kind: "Number",
|
||||
input: "+42",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 3},
|
||||
input: "42.44455",
|
||||
output: "42.445",
|
||||
},
|
||||
{
|
||||
name: "number precision; bigger precision then provided fracture",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 3},
|
||||
input: "42.4",
|
||||
output: "42.4",
|
||||
},
|
||||
{
|
||||
name: "number precision; default to 0",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{},
|
||||
input: "42.4",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; clamped between [0, 6]",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 10},
|
||||
input: "42.5555555555",
|
||||
output: "42.555556",
|
||||
},
|
||||
{
|
||||
name: "number precision; round",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 0},
|
||||
input: "41.6",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; trailing .00",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 2},
|
||||
input: "42.00",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; trailing .040",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 2},
|
||||
input: "42.040",
|
||||
output: "42.04",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: "<span onerror=alert()>Title here</span>",
|
||||
output: "<span>Title here</span>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html a.href with javascript alert",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<a href="javascript:alert('XSS1')" onmouseover="alert('XSS2')">XSS<a>`,
|
||||
output: "XSS",
|
||||
},
|
||||
{
|
||||
name: "string escaping; a.href with javascript",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<a href="javascript:document.location='https://cortezaproject.org/'">XSS</A>`,
|
||||
output: "XSS",
|
||||
},
|
||||
{
|
||||
name: "string escaping; script with script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<script>document.write("<scri");</script>pt src="https://cortezaproject.org/script.js"></script>`,
|
||||
output: `pt src="https://cortezaproject.org/script.js">`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; inline styles unchanged",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<span style="background-color: #00ff00"><span style="color: #4a86e8">nasty looking content</span></span>`,
|
||||
output: `<span style="background-color: #00ff00"><span style="color: #4a86e8">nasty looking content</span></span>`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; script with a",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<script a=">'>" src="https://cortezaproject.org/xss.js"></script>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; meta with script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<meta http-equiv="set-cookie" content="<script>alert('xss')</script>">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; object",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<object type="text/x-scriptlet" data="https://cortezaproject.org/xss.html"></object>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; base href",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<base href="javascript:alert('xss');//">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<!--[if gte ie 4]><script>alert('xss');</script><![endif]-->`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; div",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<div style="background-image: url(javascript:alert('XSS'))"></div>`,
|
||||
output: "<div></div>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; frameset",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<frameset><frame src="javascript:alert('XSS');"></frameset>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; iframe",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<iframe src=# onmouseover="alert(document.cookie)"></iframe>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; meta",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<meta http-equiv="refresh" content="0; url=https://;url=javascript:alert('xss');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; br",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<br size="&{alert('XSS')}">`,
|
||||
output: "<br>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; bgsound",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<bgsound src="javascript:alert('XSS');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; input type image",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<input type="image" src="javascript:alert('XSS');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; style",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<style>@import 'https://cortezaproject.org/xss.css';</style>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; link",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<link rel="stylesheet" href="javascript:alert('xss');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html body onload event",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<body onload=alert('XSS')>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `'';!--"<XSS>=&{()}`,
|
||||
output: `'';!--"=&{()}`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `Hello <span class="><script src='https://cortezaproject.org/XSS.js'></script>">there</span> world.`,
|
||||
output: "Hello <span>there</span> world.",
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<tag1>cor<tag2></tag2>teza</tag1><tag1>server</tag1><tag2>123</tag2>`,
|
||||
output: "cortezaserver123",
|
||||
},
|
||||
{
|
||||
name: "string escaping; preserve necessary chars",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `a < b ; "'"`,
|
||||
output: `a < b ; "'"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := sanitizer{}
|
||||
m := &types.Module{Fields: types.ModuleFieldSet{&types.ModuleField{Name: "testField", Kind: tt.kind, Options: tt.options}}}
|
||||
v := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.input}}
|
||||
o := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.output, Ref: tt.outref}}
|
||||
|
||||
// Need to mark values as updated to trigger sanitization.
|
||||
v.SetUpdatedFlag(true)
|
||||
o.SetUpdatedFlag(true)
|
||||
if sanitized := s.Run(m, v); !reflect.DeepEqual(sanitized, o) {
|
||||
t.Errorf("\ninput value:\n%v\n\nresult of sanitization:\n%v\n\nexpected:\n%v\n", tt.input, sanitized, o)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizerExpr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
expr []string
|
||||
input string
|
||||
output string
|
||||
outref uint64
|
||||
}{
|
||||
{
|
||||
name: "cap numbers",
|
||||
kind: "Number",
|
||||
expr: []string{`value > 42 ? 42 : value`},
|
||||
input: "43",
|
||||
output: "42",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := sanitizer{}
|
||||
m := &types.Module{Fields: types.ModuleFieldSet{&types.ModuleField{Name: "testField", Kind: tt.kind}}}
|
||||
m.Fields.FindByName("testField").Expressions.Sanitizers = tt.expr
|
||||
v := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.input}}
|
||||
o := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.output, Ref: tt.outref}}
|
||||
|
||||
// Need to mark values as updated to trigger sanitization.
|
||||
v.SetUpdatedFlag(true)
|
||||
o.SetUpdatedFlag(true)
|
||||
if sanitized := s.Run(m, v); !reflect.DeepEqual(sanitized, o) {
|
||||
t.Errorf("\ninput value:\n%v\n\nresult of sanitization:\n%v\n\nexpected:\n%v\n", tt.input, sanitized, o)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatetimeSanitizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
input interface{}
|
||||
onlyDate, onlyTime bool
|
||||
rval string
|
||||
}{
|
||||
{time.Date(1999, 9, 9, 9, 9, 9, 9, time.UTC), false, false, "1999-09-09T09:09:09Z"},
|
||||
{"2021-03-23T20:21:15Z", false, false, "2021-03-23T20:21:15Z"},
|
||||
{"2021-03-23T20:21:15+01:00", false, false, "2021-03-23T19:21:15Z"},
|
||||
{"2021-03-23", true, false, "2021-03-23"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
|
||||
assert.New(t).Equal(tt.rval, sDatetime(tt.input, tt.onlyDate, tt.onlyTime))
|
||||
})
|
||||
}
|
||||
}
|
||||
37
server/compose/service/values/shared.go
Normal file
37
server/compose/service/values/shared.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
strBoolTrue = "1"
|
||||
strBoolFalse = "0"
|
||||
strBoolFalseAlt = ""
|
||||
|
||||
datetimeInternalFormatDate = "2006-01-02"
|
||||
datetimeIntenralFormatTime = "15:04:05"
|
||||
datetimeInternalFormatFull = time.RFC3339
|
||||
|
||||
fieldOpt_Datetime_onlyDate = "onlyDate"
|
||||
fieldOpt_Datetime_onlyTime = "onlyTime"
|
||||
fieldOpt_Datetime_onlyFutureValues = "onlyFutureValues"
|
||||
fieldOpt_Datetime_onlyPastValues = "onlyPastValues"
|
||||
|
||||
fieldOpt_Url_onlySecure = "onlySecure"
|
||||
|
||||
maxPrecision = 15
|
||||
)
|
||||
|
||||
var (
|
||||
// value resembles something that can be true
|
||||
truthy = regexp.MustCompile(`^(t(rue)?|y(es)?|1)$`)
|
||||
|
||||
// value resembles something that can be a reference
|
||||
refy = regexp.MustCompile(`^[1-9](\d*)$`)
|
||||
|
||||
// value resembels something that can be parsed as ISO 8601
|
||||
isoDaty = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
|
||||
hasTimezone = regexp.MustCompile(`(Z|\+\d{2}:\d{2})$`)
|
||||
)
|
||||
472
server/compose/service/values/validator.go
Normal file
472
server/compose/service/values/validator.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
)
|
||||
|
||||
// Validator package provides tooling to validate
|
||||
// record and it's values against field constraints
|
||||
//
|
||||
// Structures and basic logic is similar to what we offer on the frontend
|
||||
// (see corteza-js/validator package) but with less features as there
|
||||
// is no need for such level of interaction and dynamic we require on the frontend
|
||||
|
||||
type (
|
||||
UniqueChecker func(context.Context, store.Storer, *types.RecordValue, *types.ModuleField, *types.Module) (uint64, error)
|
||||
ReferenceChecker func(context.Context, store.Storer, *types.RecordValue, *types.ModuleField, *types.Module) (bool, error)
|
||||
|
||||
localeService interface {
|
||||
T(ctx context.Context, ns, key string, rr ...string) string
|
||||
TResource(ctx context.Context, ns, key string, rr ...string) string
|
||||
}
|
||||
|
||||
validator struct {
|
||||
uniqueCheckerFn UniqueChecker
|
||||
recordRefCheckerFn ReferenceChecker
|
||||
userRefCheckerFn ReferenceChecker
|
||||
fileRefCheckerFn ReferenceChecker
|
||||
|
||||
localeSvc localeService
|
||||
|
||||
now func() time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func makeInternalErr(field *types.ModuleField, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "internal",
|
||||
Message: err.Error(),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
func makeEmptyErr(ctx context.Context, field *types.ModuleField, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "empty",
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.empty"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeInvalidValueErr(ctx context.Context, field *types.ModuleField, value string, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "invalidValue",
|
||||
Meta: map[string]interface{}{"field": field.Name, "value": value},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.invalidValue"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeInvalidRefErr(ctx context.Context, field *types.ModuleField, ref uint64, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "invalidRef",
|
||||
Meta: map[string]interface{}{"field": field.Name, "ref": ref},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.invalidRef"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeDuplicateValueInSetErr(ctx context.Context, field *types.ModuleField, value string, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "duplicateValueInSet",
|
||||
Meta: map[string]interface{}{"field": field.Name, "value": value},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.duplicateValueInSet"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeDuplicateValueErr(ctx context.Context, field *types.ModuleField, recordID uint64, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "duplicateValue",
|
||||
Meta: map[string]interface{}{"field": field.Name, "recordID": recordID},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.duplicateValue"),
|
||||
}
|
||||
}
|
||||
|
||||
// Simple wrapper for easier error returning from validation functions
|
||||
func e2s(ee ...types.RecordValueError) []types.RecordValueError {
|
||||
return ee
|
||||
}
|
||||
|
||||
func Validator() *validator {
|
||||
return &validator{
|
||||
now: func() time.Time { return time.Now() },
|
||||
localeSvc: locale.Global(),
|
||||
}
|
||||
}
|
||||
|
||||
func (vldtr *validator) UniqueChecker(fn UniqueChecker) {
|
||||
vldtr.uniqueCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) RecordRefChecker(fn ReferenceChecker) {
|
||||
vldtr.recordRefCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) UserRefChecker(fn ReferenceChecker) {
|
||||
vldtr.userRefCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) FileRefChecker(fn ReferenceChecker) {
|
||||
vldtr.fileRefCheckerFn = fn
|
||||
}
|
||||
|
||||
// Run validates record and it's values against module & module fields options
|
||||
//
|
||||
// Validation is done in phases for optimal resource usage:
|
||||
// - check if required values are present
|
||||
// - check for unique-multi-value in multi value fields
|
||||
// - field-kind specific validation on all values
|
||||
// - unique check on all values
|
||||
func (vldtr validator) Run(ctx context.Context, s store.Storer, m *types.Module, r *types.Record) (out *types.RecordValueErrorSet) {
|
||||
var (
|
||||
f *types.ModuleField
|
||||
|
||||
valParser = expr.Parser()
|
||||
valDict = r.Values.Dict(m.Fields)
|
||||
)
|
||||
|
||||
out = &types.RecordValueErrorSet{}
|
||||
|
||||
fields:
|
||||
for _, f := range m.Fields {
|
||||
vv := r.Values.FilterByName(f.Name)
|
||||
|
||||
if f.Required {
|
||||
if len(vv) == 0 {
|
||||
out.Push(makeEmptyErr(ctx, f, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
|
||||
for _, v := range vv {
|
||||
if len(v.Value) == 0 || (f.IsRef() && v.Ref == 0) {
|
||||
out.Push(makeEmptyErr(ctx, f, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if f.Multi && f.Options.IsUniqueMultiValue() {
|
||||
flipped := make(map[string]bool)
|
||||
for _, v := range vv {
|
||||
if flipped[v.Value] {
|
||||
out.Push(makeDuplicateValueInSetErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
|
||||
flipped[v.Value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range r.Values {
|
||||
if !v.IsUpdated() || v.IsDeleted() {
|
||||
// We'll validate only updated (and non-deleted) values
|
||||
continue
|
||||
}
|
||||
|
||||
if f = m.Fields.FindByName(v.Name); f == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.Expressions.ValueExpr != "" {
|
||||
// do not do any validation if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if !(f.Expressions.DisableDefaultValidators && len(f.Expressions.Validators) > 0) {
|
||||
if v.Value == "" {
|
||||
// Nothing to do with empty value
|
||||
return nil
|
||||
}
|
||||
|
||||
// Per field type validators
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "bool":
|
||||
out.Push(vldtr.vBool(ctx, v, f, r, m)...)
|
||||
case "datetime":
|
||||
out.Push(vldtr.vDatetime(ctx, v, f, r, m)...)
|
||||
case "email":
|
||||
out.Push(vldtr.vEmail(ctx, v, f, r, m)...)
|
||||
case "file":
|
||||
out.Push(vldtr.vFile(ctx, s, v, f, r, m)...)
|
||||
case "number":
|
||||
out.Push(vldtr.vNumber(ctx, v, f, r, m)...)
|
||||
case "record":
|
||||
out.Push(vldtr.vRecord(ctx, s, v, f, r, m)...)
|
||||
case "select":
|
||||
out.Push(vldtr.vSelect(ctx, v, f, r, m)...)
|
||||
//case "string":
|
||||
// out.Push(vldtr.vString(v, f, r, m)...)
|
||||
case "url":
|
||||
out.Push(vldtr.vUrl(ctx, v, f, r, m)...)
|
||||
case "user":
|
||||
out.Push(vldtr.vUser(ctx, s, v, f, r, m)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.Expressions.Validators) > 0 {
|
||||
for i, cv := range f.Expressions.Validators {
|
||||
eval, err := valParser.NewEvaluable(cv.Test)
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
break
|
||||
}
|
||||
|
||||
invalid, err := eval.EvalBool(ctx, map[string]interface{}{
|
||||
"value": v.Value,
|
||||
"oldValue": v.OldValue,
|
||||
"values": valDict,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
break
|
||||
}
|
||||
|
||||
if invalid {
|
||||
message := cv.Error
|
||||
|
||||
// In case tests mit this for easier testing; falling back to whatever
|
||||
// the field validator has stored.
|
||||
if vldtr.localeSvc != nil {
|
||||
validatorID := locale.ContentID(cv.ValidatorID, i)
|
||||
rpl := strings.NewReplacer(
|
||||
"{{validatorID}}", strconv.FormatUint(validatorID, 10),
|
||||
)
|
||||
|
||||
k := rpl.Replace(types.LocaleKeyModuleFieldExpressionValidatorValidatorIDError.Path)
|
||||
message = vldtr.localeSvc.TResource(ctx, f.ResourceTranslation(), k)
|
||||
}
|
||||
|
||||
out.Push(types.RecordValueError{
|
||||
Kind: "error",
|
||||
Message: message,
|
||||
Meta: map[string]interface{}{"field": f.Name}},
|
||||
)
|
||||
|
||||
// break at first failed test
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is the most resource-heavy operation
|
||||
// we'll do in at the end
|
||||
for _, v := range r.Values {
|
||||
if f = m.Fields.FindByName(v.Name); f == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !f.Options.IsUnique() {
|
||||
// Only interested in unique fields
|
||||
continue
|
||||
}
|
||||
|
||||
if vldtr.uniqueCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
duplicateRecordID, err := vldtr.uniqueCheckerFn(ctx, s, v, f, m)
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
} else if duplicateRecordID > 0 && duplicateRecordID != r.ID {
|
||||
out.Push(makeDuplicateValueErr(ctx, f, duplicateRecordID, vldtr.localeSvc))
|
||||
}
|
||||
}
|
||||
|
||||
if out.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (vldtr validator) vBool(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if v.Value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.Value != strBoolTrue && v.Value != strBoolFalse && v.Value != strBoolFalseAlt {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vDatetime(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
var (
|
||||
inputFormat string
|
||||
t time.Time
|
||||
err error
|
||||
|
||||
// We'll validate against this value
|
||||
refTime = vldtr.now()
|
||||
)
|
||||
|
||||
if f.Options.Bool(fieldOpt_Datetime_onlyDate) {
|
||||
inputFormat = datetimeInternalFormatDate
|
||||
|
||||
// Round down ref time to midnight
|
||||
refTime = time.Date(refTime.Year(), refTime.Month(), refTime.Day(), 0, 0, 0, 0, refTime.Location())
|
||||
} else if f.Options.Bool(fieldOpt_Datetime_onlyTime) {
|
||||
inputFormat = datetimeIntenralFormatTime
|
||||
|
||||
// Round down ref time to day one
|
||||
refTime = time.Date(0, 1, 1, refTime.Hour(), refTime.Minute(), refTime.Second(), refTime.Nanosecond(), refTime.Location())
|
||||
} else {
|
||||
inputFormat = datetimeInternalFormatFull
|
||||
}
|
||||
|
||||
t, err = time.Parse(inputFormat, v.Value)
|
||||
if err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
if f.Options.Bool(fieldOpt_Datetime_onlyFutureValues) {
|
||||
if !t.After(refTime) {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
} else if f.Options.Bool(fieldOpt_Datetime_onlyPastValues) {
|
||||
if !t.Before(refTime) {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
}
|
||||
|
||||
// @todo check past/future
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vEmail(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if _, err := mail.ParseAddress(v.Value); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vFile(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.fileRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, err := vldtr.fileRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vNumber(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
// @todo !! This is temporary; precision and scale require a rework
|
||||
if _, _, err := big.ParseFloat(v.Value, 0, maxPrecision, big.ToNearestEven); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vRecord(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.recordRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok, err := vldtr.recordRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vSelect(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
var (
|
||||
sbm = make(map[string]bool)
|
||||
options, has = f.Options["options"]
|
||||
)
|
||||
|
||||
if !has {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch oo := options.(type) {
|
||||
case []string:
|
||||
sbm = slice.ToStringBoolMap(oo)
|
||||
case []interface{}:
|
||||
for _, o := range oo {
|
||||
switch c := o.(type) {
|
||||
case string:
|
||||
sbm[c] = true
|
||||
case map[string]string:
|
||||
if value, has := c["value"]; has {
|
||||
sbm[value] = true
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if value, has := c["value"]; has {
|
||||
if value, ok := value.(string); ok {
|
||||
sbm[value] = true
|
||||
}
|
||||
}
|
||||
case types.ModuleFieldOptions:
|
||||
if value, has := c["value"]; has {
|
||||
if value, ok := value.(string); ok {
|
||||
sbm[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(sbm) == 0 {
|
||||
return e2s(makeInternalErr(f, fmt.Errorf("invalid select options definition")))
|
||||
}
|
||||
|
||||
if !sbm[v.Value] {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//func (vldtr validator) vString(v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (vldtr validator) vUrl(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if p, err := url.Parse(v.Value); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
} else if p.Scheme == "" || p.Host == "" {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
} else if f.Options.Bool(fieldOpt_Url_onlySecure) && p.Scheme != "https" {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vUser(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.userRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, err := vldtr.userRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
258
server/compose/service/values/validator_test.go
Normal file
258
server/compose/service/values/validator_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/stretchr/testify/require"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeLocaleService() localeService {
|
||||
return locale.Global()
|
||||
}
|
||||
|
||||
func Test_validator_vDatetime(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
now: func() time.Time {
|
||||
t, err := time.Parse(time.RFC3339, "2020-02-20T10:10:10Z")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
},
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "unparsable",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "unparsable"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid datetime value",
|
||||
val: "2020-02-20T10:10:10Z",
|
||||
},
|
||||
{
|
||||
name: "valid date value",
|
||||
val: "2020-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid time value",
|
||||
val: "10:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
{
|
||||
name: "valid future datetime value",
|
||||
val: "2021-02-20T10:10:10Z",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true},
|
||||
},
|
||||
{
|
||||
name: "valid future date value",
|
||||
val: "2021-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true, fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid future time value",
|
||||
val: "11:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true, fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
{
|
||||
name: "valid past datetime value",
|
||||
val: "2019-02-20T10:10:10Z",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true},
|
||||
},
|
||||
{
|
||||
name: "valid past date value",
|
||||
val: "2019-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true, fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid past time value",
|
||||
val: "09:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true, fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vDatetime(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vDatetime() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vNumber(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "unparsable",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "unparsable"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "42",
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "42.123",
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "412412412.322894325892365",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vNumber(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vNumber() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vUrl(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "invalid-url",
|
||||
val: "invalid-url",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "invalid-url"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "https://crust.tech/",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vUrl(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vUrl() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vEmail(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "un pars able",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "un pars able"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "qa@crust.tech",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vEmail(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vEmail() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vSelect(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "dummy",
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "crust",
|
||||
opt: types.ModuleFieldOptions{"options": []string{"crust", "corteza"}},
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "the rest",
|
||||
opt: types.ModuleFieldOptions{"options": []string{"crust", "corteza"}},
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "the rest"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vSelect(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vSelect() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_customExpr(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{}
|
||||
m = &types.Module{}
|
||||
f = &types.ModuleField{Name: "num", Kind: "Number"}
|
||||
r = &types.Record{}
|
||||
)
|
||||
|
||||
f.Expressions.Validators = []types.ModuleFieldValidator{
|
||||
{Test: "value < 5", Error: "value is lower than 5"},
|
||||
}
|
||||
m.Fields = append(m.Fields, f)
|
||||
r.Values = r.Values.Replace("num", "1")
|
||||
|
||||
rve := vldtr.Run(context.Background(), nil, m, r)
|
||||
require.False(t, rve.IsValid())
|
||||
|
||||
r.Values = r.Values.Replace("num", "10")
|
||||
rve = vldtr.Run(context.Background(), nil, m, r)
|
||||
require.True(t, rve.IsValid())
|
||||
}
|
||||
Reference in New Issue
Block a user