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

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

File diff suppressed because it is too large Load Diff

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

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

View 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"

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

File diff suppressed because it is too large Load Diff

View 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"

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

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

View 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"

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

View 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.*

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"

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

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

File diff suppressed because it is too large Load Diff

View 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"

View 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 &notification{
actionlog: DefaultActionlog,
users: uf,
}
}
// SendEmail sends email notification
func (svc notification) SendEmail(ctx context.Context, n *types.EmailNotification) (err error) {
var (
aProps = &notificationActionProps{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 := &notificationActionProps{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 := &notificationActionProps{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,
&notificationActionProps{attachmentURL: url},
NotificationActionAttachmentDownload,
get(url),
)
}(url)
}
wg.Wait()
return nil
}

View 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 = &notificationActionProps{}
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 := &notificationAction{
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 := &notificationAction{
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 = &notificationActionProps{}
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 = &notificationActionProps{}
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 = &notificationActionProps{}
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 = &notificationActionProps{}
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 = &notificationActionProps{}
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
}

View 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}}"

View 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

File diff suppressed because it is too large Load Diff

View 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"

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

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

View 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"

View 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")
}
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"

View 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)
//}

View 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")
}

File diff suppressed because it is too large Load Diff

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

View 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()
}
}

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

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

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

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

View 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})$`)
)

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

View 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())
}