diff --git a/app/boot_levels.go b/app/boot_levels.go index 2929cda41..78b02490e 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -218,6 +218,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { err = sysService.Initialize(ctx, app.Log, app.Store, sysService.Config{ ActionLog: app.Opt.ActionLog, Storage: app.Opt.ObjStore, + Template: app.Opt.Template, }) if err != nil { diff --git a/app/options.go b/app/options.go index 6318611aa..5eb711787 100644 --- a/app/options.go +++ b/app/options.go @@ -12,6 +12,7 @@ type ( Auth options.AuthOpt HTTPClient options.HTTPClientOpt DB options.DBOpt + Template options.TemplateOpt Upgrade options.UpgradeOpt Provision options.ProvisionOpt Sentry options.SentryOpt @@ -35,6 +36,7 @@ func NewOptions() *Options { SMTP: *options.SMTP(), HTTPClient: *options.HTTPClient(), DB: *options.DB(), + Template: *options.Template(), Upgrade: *options.Upgrade(), Provision: *options.Provision(), Sentry: *options.Sentry(), diff --git a/pkg/options/template.gen.go b/pkg/options/template.gen.go new file mode 100644 index 000000000..26ddc75aa --- /dev/null +++ b/pkg/options/template.gen.go @@ -0,0 +1,37 @@ +package options + +// 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: +// pkg/options/template.yaml + +type ( + TemplateOpt struct { + RendererGotenbergAddress string `env:"TEMPLATE_RENDERER_GOTENBERG_ADDRESS"` + RendererGotenbergEnabled bool `env:"TEMPLATE_RENDERER_GOTENBERG_ENABLED"` + } +) + +// Template initializes and returns a TemplateOpt with default values +func Template() (o *TemplateOpt) { + o = &TemplateOpt{ + RendererGotenbergAddress: "", + RendererGotenbergEnabled: false, + } + + fill(o) + + // Function that allows access to custom logic inside the parent function. + // The custom logic in the other file should be like: + // func (o *Template) Defaults() {...} + func(o interface{}) { + if def, ok := o.(interface{ Defaults() }); ok { + def.Defaults() + } + }(o) + + return +} diff --git a/pkg/options/template.yaml b/pkg/options/template.yaml new file mode 100644 index 000000000..013ae7636 --- /dev/null +++ b/pkg/options/template.yaml @@ -0,0 +1,11 @@ +docs: + title: Rendering engine + +props: + - name: rendererGotenbergAddress + default: "" + description: Gotenberg rendering container address. + - name: rendererGotenbergEnabled + type: bool + default: false + description: Is Gotenberg rendering container enabled. diff --git a/store/interfaces.gen.go b/store/interfaces.gen.go index e2ca065f6..e0bac5638 100644 --- a/store/interfaces.gen.go +++ b/store/interfaces.gen.go @@ -35,6 +35,7 @@ package store // - store/role_members.yaml // - store/roles.yaml // - store/settings.yaml +// - store/templates.yaml // - store/users.yaml // // Changes to this file may cause incorrect behavior and will be lost if @@ -75,6 +76,7 @@ type ( RoleMembers Roles Settings + Templates Users } ) diff --git a/store/rdbms/rdbms_schema.go b/store/rdbms/rdbms_schema.go index 1dd95d479..1053ce86e 100644 --- a/store/rdbms/rdbms_schema.go +++ b/store/rdbms/rdbms_schema.go @@ -58,6 +58,7 @@ func (s Schema) Tables() []*Table { s.RbacRules(), s.Settings(), s.Labels(), + s.Templates(), s.ComposeAttachment(), s.ComposeChart(), s.ComposeModule(), @@ -238,6 +239,23 @@ func (Schema) Labels() *Table { ) } +func (Schema) Templates() *Table { + return TableDef("templates", + ID, + ColumnDef("rel_owner", ColumnTypeIdentifier), + ColumnDef("handle", ColumnTypeVarchar, ColumnTypeLength(handleLength)), + ColumnDef("language", ColumnTypeText), + ColumnDef("type", ColumnTypeText), + ColumnDef("partial", ColumnTypeBoolean), + ColumnDef("meta", ColumnTypeJson), + ColumnDef("template", ColumnTypeText), + CUDTimestamps, + ColumnDef("last_used_at", ColumnTypeTimestamp, Null), + + AddIndex("unique_language_handle", IColumn("language"), IExpr("LOWER(handle)"), IWhere("LENGTH(handle) > 0 AND deleted_at IS NULL")), + ) +} + func (Schema) ComposeAttachment() *Table { // @todo merge with general attachment table diff --git a/store/rdbms/templates.gen.go b/store/rdbms/templates.gen.go new file mode 100644 index 000000000..786566464 --- /dev/null +++ b/store/rdbms/templates.gen.go @@ -0,0 +1,634 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/templates.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/store/rdbms/builders" + "github.com/cortezaproject/corteza-server/system/types" +) + +var _ = errors.Is + +// SearchTemplates returns all matching rows +// +// This function calls convertTemplateFilter with the given +// types.TemplateFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchTemplates(ctx context.Context, f types.TemplateFilter) (types.TemplateSet, types.TemplateFilter, error) { + var ( + err error + set []*types.Template + q squirrel.SelectBuilder + ) + + return set, f, func() error { + q, err = s.convertTemplateFilter(f) + if err != nil { + return err + } + + // Paging enabled + // {search: {enablePaging:true}} + // Cleanup unwanted cursor values (only relevant is f.PageCursor, next&prev are reset and returned) + f.PrevPage, f.NextPage = nil, nil + + if f.PageCursor != nil { + // Page cursor exists so we need to validate it against used sort + // To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions + // from the cursor. + // This (extracted sorting info) is then returned as part of response + if f.Sort, err = f.PageCursor.Sort(f.Sort); err != nil { + return err + } + } + + // Make sure results are always sorted at least by primary keys + if f.Sort.Get("id") == nil { + f.Sort = append(f.Sort, &filter.SortExpr{ + Column: "id", + Descending: f.Sort.LastDescending(), + }) + } + + // Cloned sorting instructions for the actual sorting + // Original are passed to the fetchFullPageOfUsers fn used for cursor creation so it MUST keep the initial + // direction information + sort := f.Sort.Clone() + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + if f.PageCursor != nil && f.PageCursor.ROrder { + sort.Reverse() + } + + // Apply sorting expr from filter to query + if q, err = setOrderBy(q, sort, s.sortableTemplateColumns()); err != nil { + return err + } + + set, f.PrevPage, f.NextPage, err = s.fetchFullPageOfTemplates( + ctx, + q, f.Sort, f.PageCursor, + f.Limit, + f.Check, + func(cur *filter.PagingCursor) squirrel.Sqlizer { + return builders.CursorCondition(cur, nil) + }, + ) + + if err != nil { + return err + } + + f.PageCursor = nil + return nil + }() +} + +// fetchFullPageOfTemplates collects all requested results. +// +// Function applies: +// - cursor conditions (where ...) +// - limit +// +// Main responsibility of this function is to perform additional sequential queries in case when not enough results +// are collected due to failed check on a specific row (by check fn). +// +// Function then moves cursor to the last item fetched +func (s Store) fetchFullPageOfTemplates( + ctx context.Context, + q squirrel.SelectBuilder, + sort filter.SortExprSet, + cursor *filter.PagingCursor, + reqItems uint, + check func(*types.Template) (bool, error), + cursorCond func(*filter.PagingCursor) squirrel.Sqlizer, +) (set []*types.Template, prev, next *filter.PagingCursor, err error) { + var ( + aux []*types.Template + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + reversedOrder = cursor != nil && cursor.ROrder + + // copy of the select builder + tryQuery squirrel.SelectBuilder + + // Copy no. of required items to limit + // Limit will change when doing subsequent queries to fill + // the set with all required items + limit = reqItems + + // cursor to prev. page is only calculated when cursor is used + hasPrev = cursor != nil + + // next cursor is calculated when there are more pages to come + hasNext bool + ) + + set = make([]*types.Template, 0, DefaultSliceCapacity) + + for try := 0; try < MaxRefetches; try++ { + if cursor != nil { + tryQuery = q.Where(cursorCond(cursor)) + } else { + tryQuery = q + } + + if limit > 0 { + // fetching + 1 so we know if there are more items + // we can fetch (next-page cursor) + tryQuery = tryQuery.Limit(uint64(limit + 1)) + } + + if aux, err = s.QueryTemplates(ctx, tryQuery, check); err != nil { + return nil, nil, nil, err + } + + if len(aux) == 0 { + // nothing fetched + break + } + + // append fetched items + set = append(set, aux...) + + if reqItems == 0 { + // no max requested items specified, break out + break + } + + collected := uint(len(set)) + + if reqItems > collected { + // not enough items fetched, try again with adjusted limit + limit = reqItems - collected + + if limit < MinEnsureFetchLimit { + // In case limit is set very low and we've missed records in the first fetch, + // make sure next fetch limit is a bit higher + limit = MinEnsureFetchLimit + } + + // Update cursor so that it points to the last item fetched + cursor = s.collectTemplateCursorValues(set[collected-1], sort...) + + // Copy reverse flag from sorting + cursor.LThen = sort.Reversed() + continue + } + + if reqItems < collected { + set = set[:reqItems] + hasNext = true + } + + break + } + + collected := len(set) + + if collected == 0 { + return nil, nil, nil, nil + } + + if reversedOrder { + // Fetched set needs to be reversed because we've forced a descending order to get the previous page + for i, j := 0, collected-1; i < j; i, j = i+1, j-1 { + set[i], set[j] = set[j], set[i] + } + + // when in reverse-order rules on what cursor to return change + hasPrev, hasNext = hasNext, hasPrev + } + + if hasPrev { + prev = s.collectTemplateCursorValues(set[0], sort...) + prev.ROrder = true + prev.LThen = !sort.Reversed() + } + + if hasNext { + next = s.collectTemplateCursorValues(set[collected-1], sort...) + next.LThen = sort.Reversed() + } + + return set, prev, next, nil +} + +// QueryTemplates queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryTemplates( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Template) (bool, error), +) ([]*types.Template, error) { + var ( + set = make([]*types.Template, 0, DefaultSliceCapacity) + res *types.Template + + // Query rows with + rows, err = s.Query(ctx, q) + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + if err = rows.Err(); err == nil { + res, err = s.internalTemplateRowScanner(rows) + } + + if err != nil { + return nil, err + } + + // check fn set, call it and see if it passed the test + // if not, skip the item + if check != nil { + if chk, err := check(res); err != nil { + return nil, err + } else if !chk { + continue + } + } + + set = append(set, res) + } + + return set, rows.Err() +} + +// LookupTemplateByID searches for template by ID +// +// It also returns deleted templates. +func (s Store) LookupTemplateByID(ctx context.Context, id uint64) (*types.Template, error) { + return s.execLookupTemplate(ctx, squirrel.Eq{ + s.preprocessColumn("tpl.id", ""): store.PreprocessValue(id, ""), + }) +} + +// LookupTemplateByHandle searches for template by the handle +// +// It returns only valid templates (not deleted) +func (s Store) LookupTemplateByHandle(ctx context.Context, handle string) (*types.Template, error) { + return s.execLookupTemplate(ctx, squirrel.Eq{ + s.preprocessColumn("tpl.handle", "lower"): store.PreprocessValue(handle, "lower"), + + "tpl.deleted_at": nil, + }) +} + +// CreateTemplate creates one or more rows in templates table +func (s Store) CreateTemplate(ctx context.Context, rr ...*types.Template) (err error) { + for _, res := range rr { + err = s.checkTemplateConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateTemplates(ctx, s.internalTemplateEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateTemplate updates one or more existing rows in templates +func (s Store) UpdateTemplate(ctx context.Context, rr ...*types.Template) error { + return s.partialTemplateUpdate(ctx, nil, rr...) +} + +// partialTemplateUpdate updates one or more existing rows in templates +func (s Store) partialTemplateUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Template) (err error) { + for _, res := range rr { + err = s.checkTemplateConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateTemplates( + ctx, + squirrel.Eq{ + s.preprocessColumn("tpl.id", ""): store.PreprocessValue(res.ID, ""), + }, + s.internalTemplateEncoder(res).Skip("id").Only(onlyColumns...)) + if err != nil { + return err + } + } + + return +} + +// UpsertTemplate updates one or more existing rows in templates +func (s Store) UpsertTemplate(ctx context.Context, rr ...*types.Template) (err error) { + for _, res := range rr { + err = s.checkTemplateConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpsertTemplates(ctx, s.internalTemplateEncoder(res)) + if err != nil { + return err + } + } + + return nil +} + +// DeleteTemplate Deletes one or more rows from templates table +func (s Store) DeleteTemplate(ctx context.Context, rr ...*types.Template) (err error) { + for _, res := range rr { + + err = s.execDeleteTemplates(ctx, squirrel.Eq{ + s.preprocessColumn("tpl.id", ""): store.PreprocessValue(res.ID, ""), + }) + if err != nil { + return err + } + } + + return nil +} + +// DeleteTemplateByID Deletes row from the templates table +func (s Store) DeleteTemplateByID(ctx context.Context, ID uint64) error { + return s.execDeleteTemplates(ctx, squirrel.Eq{ + s.preprocessColumn("tpl.id", ""): store.PreprocessValue(ID, ""), + }) +} + +// TruncateTemplates Deletes all rows from the templates table +func (s Store) TruncateTemplates(ctx context.Context) error { + return s.Truncate(ctx, s.templateTable()) +} + +// execLookupTemplate prepares Template query and executes it, +// returning types.Template (or error) +func (s Store) execLookupTemplate(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Template, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.templatesSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalTemplateRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateTemplates updates all matched (by cnd) rows in templates with given data +func (s Store) execCreateTemplates(ctx context.Context, payload store.Payload) error { + return s.Exec(ctx, s.InsertBuilder(s.templateTable()).SetMap(payload)) +} + +// execUpdateTemplates updates all matched (by cnd) rows in templates with given data +func (s Store) execUpdateTemplates(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.Exec(ctx, s.UpdateBuilder(s.templateTable("tpl")).Where(cnd).SetMap(set)) +} + +// execUpsertTemplates inserts new or updates matching (by-primary-key) rows in templates with given data +func (s Store) execUpsertTemplates(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.templateTable(), + set, + s.preprocessColumn("id", ""), + ) + + if err != nil { + return err + } + + return s.Exec(ctx, upsert) +} + +// execDeleteTemplates Deletes all matched (by cnd) rows in templates with given data +func (s Store) execDeleteTemplates(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.Exec(ctx, s.DeleteBuilder(s.templateTable("tpl")).Where(cnd)) +} + +func (s Store) internalTemplateRowScanner(row rowScanner) (res *types.Template, err error) { + res = &types.Template{} + + if _, has := s.config.RowScanners["template"]; has { + scanner := s.config.RowScanners["template"].(func(_ rowScanner, _ *types.Template) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.ID, + &res.Handle, + &res.Language, + &res.Type, + &res.Partial, + &res.Meta, + &res.Template, + &res.OwnerID, + &res.CreatedAt, + &res.UpdatedAt, + &res.DeletedAt, + &res.LastUsedAt, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound.Stack(1) + } + + if err != nil { + return nil, errors.Store("could not scan template db row").Wrap(err) + } else { + return res, nil + } +} + +// QueryTemplates returns squirrel.SelectBuilder with set table and all columns +func (s Store) templatesSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.templateTable("tpl"), s.templateColumns("tpl")...) +} + +// templateTable name of the db table +func (Store) templateTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "templates" + alias +} + +// TemplateColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) templateColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "id", + alias + "handle", + alias + "language", + alias + "type", + alias + "partial", + alias + "meta", + alias + "template", + alias + "rel_owner", + alias + "created_at", + alias + "updated_at", + alias + "deleted_at", + alias + "last_used_at", + } +} + +// {true true false true true true} + +// sortableTemplateColumns returns all Template columns flagged as sortable +// +// With optional string arg, all columns are returned aliased +func (Store) sortableTemplateColumns() map[string]string { + return map[string]string{ + "id": "id", "handle": "handle", "created_at": "created_at", + "createdat": "created_at", + "updated_at": "updated_at", + "updatedat": "updated_at", + "deleted_at": "deleted_at", + "deletedat": "deleted_at", + "last_used_at": "last_used_at", + "lastusedat": "last_used_at", + } +} + +// internalTemplateEncoder encodes fields from types.Template to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeTemplate +// func when rdbms.customEncoder=true +func (s Store) internalTemplateEncoder(res *types.Template) store.Payload { + return store.Payload{ + "id": res.ID, + "handle": res.Handle, + "language": res.Language, + "type": res.Type, + "partial": res.Partial, + "meta": res.Meta, + "template": res.Template, + "rel_owner": res.OwnerID, + "created_at": res.CreatedAt, + "updated_at": res.UpdatedAt, + "deleted_at": res.DeletedAt, + "last_used_at": res.LastUsedAt, + } +} + +// collectTemplateCursorValues collects values from the given resource that and sets them to the cursor +// to be used for pagination +// +// Values that are collected must come from sortable, unique or primary columns/fields +// At least one of the collected columns must be flagged as unique, otherwise fn appends primary keys at the end +// +// Known issue: +// when collecting cursor values for query that sorts by unique column with partial index (ie: unique handle on +// undeleted items) +func (s Store) collectTemplateCursorValues(res *types.Template, cc ...*filter.SortExpr) *filter.PagingCursor { + var ( + cursor = &filter.PagingCursor{LThen: filter.SortExprSet(cc).Reversed()} + + hasUnique bool + + // All known primary key columns + + pkId bool + + collect = func(cc ...*filter.SortExpr) { + for _, c := range cc { + switch c.Column { + case "id": + cursor.Set(c.Column, res.ID, c.Descending) + + pkId = true + case "handle": + cursor.Set(c.Column, res.Handle, c.Descending) + hasUnique = true + + case "created_at": + cursor.Set(c.Column, res.CreatedAt, c.Descending) + + case "updated_at": + cursor.Set(c.Column, res.UpdatedAt, c.Descending) + + case "deleted_at": + cursor.Set(c.Column, res.DeletedAt, c.Descending) + + case "last_used_at": + cursor.Set(c.Column, res.LastUsedAt, c.Descending) + + } + } + } + ) + + collect(cc...) + if !hasUnique || !(pkId && true) { + collect(&filter.SortExpr{Column: "id", Descending: false}) + } + + return cursor +} + +// checkTemplateConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkTemplateConstraints(ctx context.Context, res *types.Template) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + valid = valid && len(res.Handle) > 0 + + if !valid { + return nil + } + + { + ex, err := s.LookupTemplateByHandle(ctx, res.Handle) + if err == nil && ex != nil && ex.ID != res.ID { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + return nil +} diff --git a/store/rdbms/templates.go b/store/rdbms/templates.go new file mode 100644 index 000000000..08e996f12 --- /dev/null +++ b/store/rdbms/templates.go @@ -0,0 +1,36 @@ +package rdbms + +import ( + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/system/types" +) + +func (s Store) convertTemplateFilter(f types.TemplateFilter) (query squirrel.SelectBuilder, err error) { + query = s.templatesSelectBuilder() + + query = filter.StateCondition(query, "tpl.deleted_at", f.Deleted) + + if len(f.LabeledIDs) > 0 { + query = query.Where(squirrel.Eq{"tpl.id": f.LabeledIDs}) + } + + if f.Partial { + query = query.Where(squirrel.Eq{"tpl.partial": true}) + } + + if len(f.TemplateID) > 0 { + query = query.Where(squirrel.Eq{"tpl.id": f.TemplateID}) + } + if f.Handle != "" { + query = query.Where(squirrel.Eq{"tpl.handle": f.Handle}) + } + if f.Type != "" { + query = query.Where(squirrel.Eq{"tpl.type": f.Type}) + } + if f.OwnerID > 0 { + query = query.Where(squirrel.Eq{"tpl.rel_owner": f.OwnerID}) + } + + return +} diff --git a/store/templates.gen.go b/store/templates.gen.go new file mode 100644 index 000000000..3e97aeacf --- /dev/null +++ b/store/templates.gen.go @@ -0,0 +1,85 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/templates.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + Templates interface { + SearchTemplates(ctx context.Context, f types.TemplateFilter) (types.TemplateSet, types.TemplateFilter, error) + LookupTemplateByID(ctx context.Context, id uint64) (*types.Template, error) + LookupTemplateByHandle(ctx context.Context, handle string) (*types.Template, error) + + CreateTemplate(ctx context.Context, rr ...*types.Template) error + + UpdateTemplate(ctx context.Context, rr ...*types.Template) error + + UpsertTemplate(ctx context.Context, rr ...*types.Template) error + + DeleteTemplate(ctx context.Context, rr ...*types.Template) error + DeleteTemplateByID(ctx context.Context, ID uint64) error + + TruncateTemplates(ctx context.Context) error + } +) + +var _ *types.Template +var _ context.Context + +// SearchTemplates returns all matching Templates from store +func SearchTemplates(ctx context.Context, s Templates, f types.TemplateFilter) (types.TemplateSet, types.TemplateFilter, error) { + return s.SearchTemplates(ctx, f) +} + +// LookupTemplateByID searches for template by ID +// +// It also returns deleted templates. +func LookupTemplateByID(ctx context.Context, s Templates, id uint64) (*types.Template, error) { + return s.LookupTemplateByID(ctx, id) +} + +// LookupTemplateByHandle searches for template by the handle +// +// It returns only valid templates (not deleted) +func LookupTemplateByHandle(ctx context.Context, s Templates, handle string) (*types.Template, error) { + return s.LookupTemplateByHandle(ctx, handle) +} + +// CreateTemplate creates one or more Templates in store +func CreateTemplate(ctx context.Context, s Templates, rr ...*types.Template) error { + return s.CreateTemplate(ctx, rr...) +} + +// UpdateTemplate updates one or more (existing) Templates in store +func UpdateTemplate(ctx context.Context, s Templates, rr ...*types.Template) error { + return s.UpdateTemplate(ctx, rr...) +} + +// UpsertTemplate creates new or updates existing one or more Templates in store +func UpsertTemplate(ctx context.Context, s Templates, rr ...*types.Template) error { + return s.UpsertTemplate(ctx, rr...) +} + +// DeleteTemplate Deletes one or more Templates from store +func DeleteTemplate(ctx context.Context, s Templates, rr ...*types.Template) error { + return s.DeleteTemplate(ctx, rr...) +} + +// DeleteTemplateByID Deletes Template from store +func DeleteTemplateByID(ctx context.Context, s Templates, ID uint64) error { + return s.DeleteTemplateByID(ctx, ID) +} + +// TruncateTemplates Deletes all Templates from store +func TruncateTemplates(ctx context.Context, s Templates) error { + return s.TruncateTemplates(ctx) +} diff --git a/store/templates.yaml b/store/templates.yaml new file mode 100644 index 000000000..7c3b69611 --- /dev/null +++ b/store/templates.yaml @@ -0,0 +1,34 @@ +import: + - github.com/cortezaproject/corteza-server/system/types + +fields: + - { field: ID } + - { field: Handle, sortable: true, unique: true, lookupFilterPreprocessor: lower } + - { field: Language } + - { field: Type } + - { field: Partial } + - { field: Meta, type: "*types.TemplateMeta" } + - { field: Template } + - { field: OwnerID } + - { field: CreatedAt, sortable: true } + - { field: UpdatedAt, sortable: true } + - { field: DeletedAt, sortable: true } + - { field: LastUsedAt, sortable: true } + +lookups: + - fields: [ ID ] + description: |- + searches for template by ID + + It also returns deleted templates. + - fields: [ Handle ] + filter: { DeletedAt: nil } + uniqueConstraintCheck: true + description: |- + searches for template by the handle + + It returns only valid templates (not deleted) +rdbms: + alias: tpl + table: templates + customFilterConverter: true diff --git a/store/tests/gen_test.go b/store/tests/gen_test.go index 6f7d91175..57b336455 100644 --- a/store/tests/gen_test.go +++ b/store/tests/gen_test.go @@ -33,6 +33,7 @@ package tests // - store/role_members.yaml // - store/roles.yaml // - store/settings.yaml +// - store/templates.yaml // - store/users.yaml // @@ -201,6 +202,11 @@ func testAllGenerated(t *testing.T, s store.Storer) { testSettings(t, s) }) + // Run generated tests for Templates + t.Run("Templates", func(t *testing.T) { + testTemplates(t, s) + }) + // Run generated tests for Users t.Run("Users", func(t *testing.T) { testUsers(t, s) diff --git a/store/tests/templates_test.go b/store/tests/templates_test.go new file mode 100644 index 000000000..2f0a0cbdd --- /dev/null +++ b/store/tests/templates_test.go @@ -0,0 +1,156 @@ +package tests + +import ( + "context" + "testing" + "time" + + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/pkg/rand" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/types" + _ "github.com/joho/godotenv/autoload" + "github.com/stretchr/testify/require" +) + +func testTemplates(t *testing.T, s store.Templates) { + var ( + ctx = context.Background() + req = require.New(t) + + makeNew = func(handle string) *types.Template { + // minimum data set for new template + return &types.Template{ + ID: id.Next(), + CreatedAt: time.Now(), + Handle: handle, + } + } + + truncAndCreate = func(t *testing.T) (*require.Assertions, *types.Template) { + req := require.New(t) + req.NoError(s.TruncateTemplates(ctx)) + res := makeNew(string(rand.Bytes(10))) + req.NoError(s.CreateTemplate(ctx, res)) + return req, res + } + ) + + t.Run("create", func(t *testing.T) { + template := makeNew("TemplateCRUD") + req.NoError(s.CreateTemplate(ctx, template)) + }) + + t.Run("lookup by ID", func(t *testing.T) { + req, template := truncAndCreate(t) + fetched, err := s.LookupTemplateByID(ctx, template.ID) + req.NoError(err) + req.Equal(template.Handle, fetched.Handle) + req.Equal(template.ID, fetched.ID) + req.NotNil(fetched.CreatedAt) + req.Nil(fetched.UpdatedAt) + req.Nil(fetched.DeletedAt) + }) + + t.Run("lookup by handle", func(t *testing.T) { + req, template := truncAndCreate(t) + fetched, err := s.LookupTemplateByHandle(ctx, template.Handle) + req.NoError(err) + req.Equal(template.Handle, fetched.Handle) + req.Equal(template.ID, fetched.ID) + req.NotNil(fetched.CreatedAt) + req.Nil(fetched.UpdatedAt) + req.Nil(fetched.DeletedAt) + }) + + t.Run("update", func(t *testing.T) { + req, template := truncAndCreate(t) + template.Handle = "TemplateCRUD+2" + + req.NoError(s.UpdateTemplate(ctx, template)) + + updated, err := s.LookupTemplateByID(ctx, template.ID) + req.NoError(err) + req.Equal(template.Handle, updated.Handle) + }) + + t.Run("upsert", func(t *testing.T) { + t.Run("existing", func(t *testing.T) { + req, template := truncAndCreate(t) + template.Handle = "TemplateCRUD+2" + + req.NoError(s.UpsertTemplate(ctx, template)) + + updated, err := s.LookupTemplateByID(ctx, template.ID) + req.NoError(err) + req.Equal(template.Handle, updated.Handle) + }) + + t.Run("new", func(t *testing.T) { + template := makeNew("upsert me") + template.Handle = "ComposeChartCRUD+2" + + req.NoError(s.UpsertTemplate(ctx, template)) + + upserted, err := s.LookupTemplateByID(ctx, template.ID) + req.NoError(err) + req.Equal(template.Handle, upserted.Handle) + }) + }) + + t.Run("delete", func(t *testing.T) { + t.Run("by Template", func(t *testing.T) { + req, template := truncAndCreate(t) + req.NoError(s.DeleteTemplate(ctx, template)) + _, err := s.LookupTemplateByID(ctx, template.ID) + req.EqualError(err, store.ErrNotFound.Error()) + }) + + t.Run("by ID", func(t *testing.T) { + req, template := truncAndCreate(t) + req.NoError(s.DeleteTemplateByID(ctx, template.ID)) + _, err := s.LookupTemplateByID(ctx, template.ID) + req.EqualError(err, store.ErrNotFound.Error()) + }) + }) + + t.Run("search", func(t *testing.T) { + prefill := []*types.Template{ + makeNew("one-one"), + makeNew("one-two"), + makeNew("two-one"), + makeNew("two-two"), + makeNew("two-deleted"), + } + + count := len(prefill) + + prefill[4].DeletedAt = &prefill[4].CreatedAt + valid := count - 1 + + req.NoError(s.TruncateTemplates(ctx)) + req.NoError(s.CreateTemplate(ctx, prefill...)) + + // search for all valid + set, f, err := s.SearchTemplates(ctx, types.TemplateFilter{}) + req.NoError(err) + req.Len(set, valid) // we've deleted one + + // search for ALL + set, f, err = s.SearchTemplates(ctx, types.TemplateFilter{Deleted: filter.StateInclusive}) + req.NoError(err) + req.Len(set, count) // we've deleted one + + // search for deleted only + set, f, err = s.SearchTemplates(ctx, types.TemplateFilter{Deleted: filter.StateExclusive}) + req.NoError(err) + req.Len(set, 1) // we've deleted one + + set, f, err = s.SearchTemplates(ctx, types.TemplateFilter{Handle: "two-one"}) + req.NoError(err) + req.Len(set, 1) + + _ = f // dummy + }) +} diff --git a/system/renderer/genericHTML.go b/system/renderer/genericHTML.go new file mode 100644 index 000000000..45811a8c4 --- /dev/null +++ b/system/renderer/genericHTML.go @@ -0,0 +1,42 @@ +package renderer + +import ( + "bytes" + "context" + "io" + + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + genericHTML struct{} + genericHTMLDriver struct{} +) + +func newGenericHTML() driverFactory { + return &genericHTML{} +} + +func (d *genericHTML) CanRender(t types.DocumentType) bool { + return t == types.DocumentTypeHTML || t == types.DocumentTypePlain +} + +func (d *genericHTML) CanProduce(t types.DocumentType) bool { + return t == types.DocumentTypeHTML +} + +func (d *genericHTML) Driver() driver { + return &genericHTMLDriver{} +} + +func (d *genericHTMLDriver) Render(ctx context.Context, pl *driverPayload) (io.ReadSeeker, error) { + t, err := preprocHTMLTemplate(pl) + if err != nil { + return nil, err + } + + dd := &bytes.Buffer{} + err = t.Execute(dd, pl.Variables) + + return bytes.NewReader(dd.Bytes()), err +} diff --git a/system/renderer/genericTXT.go b/system/renderer/genericTXT.go new file mode 100644 index 000000000..c5b63a465 --- /dev/null +++ b/system/renderer/genericTXT.go @@ -0,0 +1,47 @@ +package renderer + +import ( + "bytes" + "context" + "io" + "regexp" + + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + genericTXT struct{} + genericTXTDriver struct{} +) + +var ( + plainTextRegex = regexp.MustCompile("text(/)?.*") +) + +func newGenericTXT() driverFactory { + return &genericTXT{} +} + +func (d *genericTXT) CanRender(t types.DocumentType) bool { + return t == types.DocumentTypePlain || t == types.DocumentTypeHTML +} + +func (d *genericTXT) CanProduce(t types.DocumentType) bool { + return t == types.DocumentTypePlain +} + +func (d *genericTXT) Driver() driver { + return &genericTXTDriver{} +} + +func (d *genericTXTDriver) Render(ctx context.Context, pl *driverPayload) (io.ReadSeeker, error) { + t, err := preprocPlainTemplate(pl.Template, pl.Partials) + if err != nil { + return nil, err + } + + dd := &bytes.Buffer{} + err = t.Execute(dd, pl.Variables) + + return bytes.NewReader(dd.Bytes()), err +} diff --git a/system/renderer/gotenbergPDF.go b/system/renderer/gotenbergPDF.go new file mode 100644 index 000000000..3c86d9d0b --- /dev/null +++ b/system/renderer/gotenbergPDF.go @@ -0,0 +1,291 @@ +package renderer + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "strings" + + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + gotenbergPDF struct { + url string + } + gotenbergPDFDriver struct { + url string + } +) + +// @todo healthcheck, different input data formats +func newGotenbergPDF(url string) driverFactory { + return &gotenbergPDF{ + url: url, + } +} + +func (d *gotenbergPDF) CanRender(t types.DocumentType) bool { + return t == types.DocumentTypeHTML || t == types.DocumentTypePlain +} + +func (d *gotenbergPDF) CanProduce(t types.DocumentType) bool { + return t == types.DocumentTypePDF +} + +func (d *gotenbergPDF) Driver() driver { + return &gotenbergPDFDriver{ + url: d.url, + } +} + +func (d *gotenbergPDFDriver) Render(ctx context.Context, pl *driverPayload) (io.ReadSeeker, error) { + // HTTP request body stuff + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + // index.html is required by the rendering container + part, err := writer.CreateFormFile("file", "index.html") + if err != nil { + return nil, err + } + err = d.prepareContent(part, pl) + if err != nil { + return nil, err + } + + // Document configurations + err = d.applyOptions(writer, pl.Options) + if err != nil { + return nil, err + } + + err = writer.Close() + if err != nil { + return nil, err + } + + // HTTP request header stuff + // @todo make sure to use the propper endpoint when you add support + // for different inputs. + url := d.url + if n, has := pl.Options["url"]; has { + url = n + } + req, err := http.NewRequest("POST", url+"/convert/html", body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + ss, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return bytes.NewReader(ss), err +} + +func (d *gotenbergPDFDriver) prepareContent(w io.Writer, pl *driverPayload) error { + t, err := preprocHTMLTemplate(pl) + if err != nil { + return err + } + + return t.Execute(w, pl.Variables) +} + +func (d *gotenbergPDFDriver) applyOptions(mw *multipart.Writer, opts map[string]string) (err error) { + if opts == nil { + return nil + } + + for k, v := range opts { + switch k { + case "marginTop", + "marginBottom", + "marginLeft", + "marginRight": + err = d.addFormField(mw, k, v) + + case "marginY": + err = d.addFormField(mw, "marginTop", v) + if err != nil { + return err + } + err = d.addFormField(mw, "marginBottom", v) + + case "marginX": + err = d.addFormField(mw, "marginLeft", v) + if err != nil { + return err + } + err = d.addFormField(mw, "marginRight", v) + + case "margin": + err = d.addFormField(mw, "marginTop", v) + if err != nil { + return err + } + err = d.addFormField(mw, "marginBottom", v) + if err != nil { + return err + } + err = d.addFormField(mw, "marginLeft", v) + if err != nil { + return err + } + err = d.addFormField(mw, "marginRight", v) + + case "documentSize": + w, h := d.documentDimensions(v) + if w+h != "" { + err = d.addFormField(mw, "paperWidth", w) + if err != nil { + return err + } + err = d.addFormField(mw, "paperHeight", h) + } + + case "documentWidth": + err = d.addFormField(mw, "paperWidth", v) + + case "documentHeight": + err = d.addFormField(mw, "paperHeight", v) + + case "contentScale": + err = d.addFormField(mw, "scale", v) + + case "orientation": + if v == "landscape" { + err = d.addFormField(mw, "landscape", "true") + } else { + err = d.addFormField(mw, "landscape", "false") + } + } + + if err != nil { + return err + } + } + return nil +} + +func (d *gotenbergPDFDriver) addFormField(mw *multipart.Writer, k, v string) error { + w, err := mw.CreateFormField(k) + if err != nil { + return err + } + + _, err = w.Write([]byte(v)) + return err +} + +// documentDimensions returns the ISO216 standard document dimensions in inches (Gotenberg uses inches) +func (d *gotenbergPDFDriver) documentDimensions(isoDoc string) (string, string) { + switch strings.ToLower(isoDoc) { + // A series + case "a0": + return "33.1", "46.8" + case "a1": + return "23.4", "33.1" + case "a2": + return "16.5", "23.4" + case "a3": + return "11.7", "16.5" + case "a4": + return "8.3", "11.7" + case "a5": + return "5.8", "8.3" + case "a6": + return "4.1", "5.8" + case "a7": + return "2.9", "4.1" + case "a8": + return "2.0", "2.9" + case "a9": + return "1.5", "2.0" + case "a10": + return "1.0", "1.5" + + // B series + case "b0": + return "39.4", "55.7" + case "b1": + return "27.8", "39.4" + case "b2": + return "19.7", "27.8" + case "b3": + return "13.9", "19.7" + case "b4": + return "9.8", "13.9" + case "b5": + return "6.9", "9.8" + case "b6": + return "4.9", "6.9" + case "b7": + return "3.5", "4.9" + case "b8": + return "2.4", "3.5" + case "b9": + return "1.7", "2.4" + case "b10": + return "1.2", "1.7" + + // C series + case "c0": + return "36.1", "51.1" + case "c1": + return "25.5", "36.1" + case "c2": + return "18.0", "25.5" + case "c3": + return "12.8", "18.0" + case "c4": + return "9.0", "12.8" + case "c5": + return "6.4", "9.0" + case "c6": + return "4.5", "6.4" + case "c7": + return "3.2", "4.5" + case "c8": + return "2.2", "3.2" + case "c9": + return "1.6", "2.2" + case "c10": + return "1.1", "1.6" + + // ANSI + case "ansi a": + return "8.5", "11" + case "ansi b": + return "11", "17" + case "ansi c": + return "17", "22" + case "ansi d": + return "22", "34" + case "ansi e": + return "34", "44" + + // Proprietary NA + case "junior legal": + return "8", "5" + case "letter": + return "8.5", "11" + case "legal": + return "8.5", "14" + case "tabloid": + return "11", "17" + } + + // This will fallback to the default (A4) + return "", "" +} diff --git a/system/renderer/renderer.go b/system/renderer/renderer.go new file mode 100644 index 000000000..c04120a97 --- /dev/null +++ b/system/renderer/renderer.go @@ -0,0 +1,49 @@ +package renderer + +import ( + "context" + "errors" + "io" + + "github.com/cortezaproject/corteza-server/pkg/options" +) + +type ( + renderer struct { + factories []driverFactory + } +) + +func Renderer(cfg options.TemplateOpt) *renderer { + ff := make([]driverFactory, 0, 3) + ff = append(ff, newGenericTXT(), newGenericHTML()) + if cfg.RendererGotenbergEnabled { + ff = append(ff, newGotenbergPDF(cfg.RendererGotenbergAddress)) + } + + return &renderer{ + factories: []driverFactory{}, + } +} + +func (r *renderer) Render(ctx context.Context, pl *RendererPayload) (io.ReadSeeker, error) { + for _, f := range r.factories { + if f.CanRender(pl.TemplateType) && f.CanProduce(pl.TargetType) { + pp := make(map[string]io.Reader) + for _, prt := range pl.Partials { + pp[prt.Handle] = prt.Template + } + dpl := &driverPayload{ + Template: pl.Template, + Variables: pl.Variables, + Options: pl.Options, + Partials: pp, + Attachments: pl.Attachments, + } + + return f.Driver().Render(ctx, dpl) + } + } + + return nil, errors.New("rendering failed: driver not found") +} diff --git a/system/renderer/types.go b/system/renderer/types.go new file mode 100644 index 000000000..a97e8f4b7 --- /dev/null +++ b/system/renderer/types.go @@ -0,0 +1,50 @@ +package renderer + +import ( + "context" + "io" + + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + RendererPayload struct { + Template io.Reader + TemplateType types.DocumentType + TargetType types.DocumentType + Variables map[string]interface{} + Options map[string]string + Partials []*TemplatePartial + Attachments AttachmentIndex + } + + TemplatePartial struct { + Handle string + Template io.Reader + TemplateType types.DocumentType + } + + driverPayload struct { + Template io.Reader + Variables map[string]interface{} + Options map[string]string + Partials map[string]io.Reader + Attachments AttachmentIndex + } + + AttachmentIndex map[string]*Attachment + Attachment struct { + Source io.Reader + Mime string + Name string + } + + driverFactory interface { + CanRender(t types.DocumentType) bool + CanProduce(t types.DocumentType) bool + Driver() driver + } + driver interface { + Render(ctx context.Context, pl *driverPayload) (io.ReadSeeker, error) + } +) diff --git a/system/renderer/util.go b/system/renderer/util.go new file mode 100644 index 000000000..516929540 --- /dev/null +++ b/system/renderer/util.go @@ -0,0 +1,107 @@ +package renderer + +import ( + "encoding/base64" + htpl "html/template" + ttpl "html/template" + "io" + "io/ioutil" + "net/http" + + "github.com/Masterminds/sprig" +) + +func preprocHTMLTemplate(pl *driverPayload) (*htpl.Template, error) { + bb, err := ioutil.ReadAll(pl.Template) + if err != nil { + return nil, err + } + + gtpl := htpl.New("text/html_render"). + Funcs(sprig.FuncMap()). + Funcs(htpl.FuncMap{ + // "attachDataURL": func(name string) htpl.URL { + // // Find the attachment + // att, has := pl.Attachments[name] + // if !has { + // return htpl.URL(fmt.Sprintf("error: attachment not found: %s", name)) + // } + + // // Process source + // bb, err := ioutil.ReadAll(att.Source) + // if err != nil { + // return htpl.URL(fmt.Sprintf("error: %s", err.Error())) + // } + + // return htpl.URL("data:" + att.Mime + ";base64," + base64.RawStdEncoding.EncodeToString(bb)) + // }, + + "inlineRemote": func(url string) (htpl.URL, error) { + rsp, err := http.Get(url) + if err != nil { + return "", err + } + + defer rsp.Body.Close() + + bb, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return "", err + } + + raw := base64.RawStdEncoding.EncodeToString(bb) + return htpl.URL("data:" + rsp.Header.Get("Content-Type") + ";base64," + raw), nil + }, + }) + + // Prep the original template + t, err := gtpl.Parse(string(bb)) + if err != nil { + return nil, err + } + + // Prep partials + for _, p := range pl.Partials { + bb, err = ioutil.ReadAll(p) + if err != nil { + return nil, err + } + + t, err = gtpl.Parse(string(bb)) + if err != nil { + return nil, err + } + } + + return t, nil +} + +func preprocPlainTemplate(tpl io.Reader, pp map[string]io.Reader) (*ttpl.Template, error) { + bb, err := ioutil.ReadAll(tpl) + if err != nil { + return nil, err + } + + gtpl := ttpl.New("text/plain_render") + + // Prep the original template + t, err := gtpl.Parse(string(bb)) + if err != nil { + return nil, err + } + + // Prep partials + for _, p := range pp { + bb, err = ioutil.ReadAll(p) + if err != nil { + return nil, err + } + + t, err = gtpl.Parse(string(bb)) + if err != nil { + return nil, err + } + } + + return t, nil +} diff --git a/system/rest.yaml b/system/rest.yaml index b4a2d8f78..0ad7d29ba 100644 --- a/system/rest.yaml +++ b/system/rest.yaml @@ -1122,6 +1122,178 @@ endpoints: type: string required: true title: Preview extension/format +- title: Template + path: "/template" + entrypoint: template + imports: + - github.com/cortezaproject/corteza-server/system/types + - github.com/cortezaproject/corteza-server/pkg/label + authentication: + - Client ID + - SessionID + apis: + - name: list + method: GET + title: List templates + path: "/" + parameters: + get: + - name: handle + type: string + title: Handle + - name: type + type: string + title: Type + - name: ownerID + type: uint64 + title: OwnerID + - name: partial + required: false + title: Show partial templates + type: bool + - name: deleted + required: false + title: Exclude (0, default), include (1) or return only (2) deleted templates + type: uint + - name: labels + type: map[string]string + title: Labels + parser: label.ParseStrings + - name: limit + type: uint + title: Limit + - name: pageCursor + type: string + title: Page cursor + - name: sort + type: string + title: Sort items + - name: create + method: POST + title: Create template + path: "/" + parameters: + post: + - name: handle + type: string + title: Handle + - name: language + type: string + title: Language + - name: type + type: string + title: Type + - name: partial + type: bool + title: Partial + - name: meta + type: "types.TemplateMeta" + parser: "types.ParseTemplateMeta" + title: Meta + - name: template + type: string + title: Template + - name: ownerID + type: uint64 + title: OwnerID + - name: labels + type: map[string]string + title: Labels + parser: label.ParseStrings + - name: read + method: GET + title: Read template + path: "/{templateID}" + parameters: + path: + - type: uint64 + name: templateID + required: true + title: ID + - name: update + method: PUT + title: Update template + path: "/{templateID}" + parameters: + path: + - type: uint64 + name: templateID + required: true + title: ID + post: + - name: handle + type: string + title: Handle + - name: language + type: string + title: Language + - name: type + type: string + title: Type + - name: partial + type: bool + title: Partial + - name: meta + type: "types.TemplateMeta" + parser: "types.ParseTemplateMeta" + title: Meta + - name: template + type: string + title: Template + - name: ownerID + type: uint64 + title: OwnerID + - name: labels + type: map[string]string + title: Labels + parser: label.ParseStrings + - name: delete + method: DELETE + title: Delete template + path: "/{templateID}" + parameters: + path: + - type: uint64 + name: templateID + required: true + title: ID + - name: undelete + path: "/{templateID}/undelete" + method: POST + title: Undelete template + parameters: + path: + - name: templateID + type: uint64 + required: true + title: Template ID + - name: render + method: POST + title: Render template + path: "/{templateID}/render/{filename}.{ext}" + parameters: + path: + - type: uint64 + name: templateID + required: true + title: Render template to use + - type: string + name: filename + required: true + title: Filename to use + - type: string + name: ext + required: true + title: Export format + post: + - name: variables + type: json.RawMessage + required: true + title: Variables defined by import file + - name: options + type: json.RawMessage + required: false + title: Rendering options - title: Statistics entrypoint: stats path: "/stats" diff --git a/system/rest/handlers/template.go b/system/rest/handlers/template.go new file mode 100644 index 000000000..f69ed934b --- /dev/null +++ b/system/rest/handlers/template.go @@ -0,0 +1,171 @@ +package handlers + +// 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: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/system/rest/request" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + TemplateAPI interface { + List(context.Context, *request.TemplateList) (interface{}, error) + Create(context.Context, *request.TemplateCreate) (interface{}, error) + Read(context.Context, *request.TemplateRead) (interface{}, error) + Update(context.Context, *request.TemplateUpdate) (interface{}, error) + Delete(context.Context, *request.TemplateDelete) (interface{}, error) + Undelete(context.Context, *request.TemplateUndelete) (interface{}, error) + Render(context.Context, *request.TemplateRender) (interface{}, error) + } + + // HTTP API interface + Template struct { + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Undelete func(http.ResponseWriter, *http.Request) + Render func(http.ResponseWriter, *http.Request) + } +) + +func NewTemplate(h TemplateAPI) *Template { + return &Template{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateCreate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Create(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateRead() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateUpdate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Update(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Undelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateUndelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Undelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Render: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTemplateRender() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Render(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Template) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/template/", h.List) + r.Post("/template/", h.Create) + r.Get("/template/{templateID}", h.Read) + r.Put("/template/{templateID}", h.Update) + r.Delete("/template/{templateID}", h.Delete) + r.Post("/template/{templateID}/undelete", h.Undelete) + r.Post("/template/{templateID}/render/{filename}.{ext}", h.Render) + }) +} diff --git a/system/rest/request/template.go b/system/rest/request/template.go new file mode 100644 index 000000000..77ee2ef79 --- /dev/null +++ b/system/rest/request/template.go @@ -0,0 +1,917 @@ +package request + +// 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: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + TemplateList struct { + // Handle GET parameter + // + // Handle + Handle string + + // Type GET parameter + // + // Type + Type string + + // OwnerID GET parameter + // + // OwnerID + OwnerID uint64 `json:",string"` + + // Partial GET parameter + // + // Show partial templates + Partial bool + + // Deleted GET parameter + // + // Exclude (0, default), include (1) or return only (2) deleted templates + Deleted uint + + // Labels GET parameter + // + // Labels + Labels map[string]string + + // Limit GET parameter + // + // Limit + Limit uint + + // PageCursor GET parameter + // + // Page cursor + PageCursor string + + // Sort GET parameter + // + // Sort items + Sort string + } + + TemplateCreate struct { + // Handle POST parameter + // + // Handle + Handle string + + // Language POST parameter + // + // Language + Language string + + // Type POST parameter + // + // Type + Type string + + // Partial POST parameter + // + // Partial + Partial bool + + // Meta POST parameter + // + // Meta + Meta types.TemplateMeta + + // Template POST parameter + // + // Template + Template string + + // OwnerID POST parameter + // + // OwnerID + OwnerID uint64 `json:",string"` + + // Labels POST parameter + // + // Labels + Labels map[string]string + } + + TemplateRead struct { + // TemplateID PATH parameter + // + // ID + TemplateID uint64 `json:",string"` + } + + TemplateUpdate struct { + // TemplateID PATH parameter + // + // ID + TemplateID uint64 `json:",string"` + + // Handle POST parameter + // + // Handle + Handle string + + // Language POST parameter + // + // Language + Language string + + // Type POST parameter + // + // Type + Type string + + // Partial POST parameter + // + // Partial + Partial bool + + // Meta POST parameter + // + // Meta + Meta types.TemplateMeta + + // Template POST parameter + // + // Template + Template string + + // OwnerID POST parameter + // + // OwnerID + OwnerID uint64 `json:",string"` + + // Labels POST parameter + // + // Labels + Labels map[string]string + } + + TemplateDelete struct { + // TemplateID PATH parameter + // + // ID + TemplateID uint64 `json:",string"` + } + + TemplateUndelete struct { + // TemplateID PATH parameter + // + // Template ID + TemplateID uint64 `json:",string"` + } + + TemplateRender struct { + // TemplateID PATH parameter + // + // Render template to use + TemplateID uint64 `json:",string"` + + // Filename PATH parameter + // + // Filename to use + Filename string + + // Ext PATH parameter + // + // Export format + Ext string + + // Variables POST parameter + // + // Variables defined by import file + Variables json.RawMessage + + // Options POST parameter + // + // Rendering options + Options json.RawMessage + } +) + +// NewTemplateList request +func NewTemplateList() *TemplateList { + return &TemplateList{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) Auditable() map[string]interface{} { + return map[string]interface{}{ + "handle": r.Handle, + "type": r.Type, + "ownerID": r.OwnerID, + "partial": r.Partial, + "deleted": r.Deleted, + "labels": r.Labels, + "limit": r.Limit, + "pageCursor": r.PageCursor, + "sort": r.Sort, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetHandle() string { + return r.Handle +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetType() string { + return r.Type +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetOwnerID() uint64 { + return r.OwnerID +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetPartial() bool { + return r.Partial +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetDeleted() uint { + return r.Deleted +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetLimit() uint { + return r.Limit +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetPageCursor() string { + return r.PageCursor +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateList) GetSort() string { + return r.Sort +} + +// Fill processes request and fills internal variables +func (r *TemplateList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["handle"]; ok && len(val) > 0 { + r.Handle, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["type"]; ok && len(val) > 0 { + r.Type, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["ownerID"]; ok && len(val) > 0 { + r.OwnerID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["partial"]; ok && len(val) > 0 { + r.Partial, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["deleted"]; ok && len(val) > 0 { + r.Deleted, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := tmp["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + if val, ok := tmp["limit"]; ok && len(val) > 0 { + r.Limit, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["pageCursor"]; ok && len(val) > 0 { + r.PageCursor, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["sort"]; ok && len(val) > 0 { + r.Sort, err = val[0], nil + if err != nil { + return err + } + } + } + + return err +} + +// NewTemplateCreate request +func NewTemplateCreate() *TemplateCreate { + return &TemplateCreate{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "handle": r.Handle, + "language": r.Language, + "type": r.Type, + "partial": r.Partial, + "meta": r.Meta, + "template": r.Template, + "ownerID": r.OwnerID, + "labels": r.Labels, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetHandle() string { + return r.Handle +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetLanguage() string { + return r.Language +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetType() string { + return r.Type +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetPartial() bool { + return r.Partial +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetMeta() types.TemplateMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetTemplate() string { + return r.Template +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetOwnerID() uint64 { + return r.OwnerID +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateCreate) GetLabels() map[string]string { + return r.Labels +} + +// Fill processes request and fills internal variables +func (r *TemplateCreate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["handle"]; ok && len(val) > 0 { + r.Handle, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["language"]; ok && len(val) > 0 { + r.Language, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["type"]; ok && len(val) > 0 { + r.Type, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["partial"]; ok && len(val) > 0 { + r.Partial, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseTemplateMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseTemplateMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["template"]; ok && len(val) > 0 { + r.Template, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["ownerID"]; ok && len(val) > 0 { + r.OwnerID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + } + + return err +} + +// NewTemplateRead request +func NewTemplateRead() *TemplateRead { + return &TemplateRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRead) Auditable() map[string]interface{} { + return map[string]interface{}{ + "templateID": r.TemplateID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRead) GetTemplateID() uint64 { + return r.TemplateID +} + +// Fill processes request and fills internal variables +func (r *TemplateRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "templateID") + r.TemplateID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTemplateUpdate request +func NewTemplateUpdate() *TemplateUpdate { + return &TemplateUpdate{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "templateID": r.TemplateID, + "handle": r.Handle, + "language": r.Language, + "type": r.Type, + "partial": r.Partial, + "meta": r.Meta, + "template": r.Template, + "ownerID": r.OwnerID, + "labels": r.Labels, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetTemplateID() uint64 { + return r.TemplateID +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetHandle() string { + return r.Handle +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetLanguage() string { + return r.Language +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetType() string { + return r.Type +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetPartial() bool { + return r.Partial +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetMeta() types.TemplateMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetTemplate() string { + return r.Template +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetOwnerID() uint64 { + return r.OwnerID +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUpdate) GetLabels() map[string]string { + return r.Labels +} + +// Fill processes request and fills internal variables +func (r *TemplateUpdate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["handle"]; ok && len(val) > 0 { + r.Handle, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["language"]; ok && len(val) > 0 { + r.Language, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["type"]; ok && len(val) > 0 { + r.Type, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["partial"]; ok && len(val) > 0 { + r.Partial, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseTemplateMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseTemplateMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["template"]; ok && len(val) > 0 { + r.Template, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["ownerID"]; ok && len(val) > 0 { + r.OwnerID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "templateID") + r.TemplateID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTemplateDelete request +func NewTemplateDelete() *TemplateDelete { + return &TemplateDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "templateID": r.TemplateID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateDelete) GetTemplateID() uint64 { + return r.TemplateID +} + +// Fill processes request and fills internal variables +func (r *TemplateDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "templateID") + r.TemplateID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTemplateUndelete request +func NewTemplateUndelete() *TemplateUndelete { + return &TemplateUndelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUndelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "templateID": r.TemplateID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateUndelete) GetTemplateID() uint64 { + return r.TemplateID +} + +// Fill processes request and fills internal variables +func (r *TemplateUndelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "templateID") + r.TemplateID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTemplateRender request +func NewTemplateRender() *TemplateRender { + return &TemplateRender{} +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) Auditable() map[string]interface{} { + return map[string]interface{}{ + "templateID": r.TemplateID, + "filename": r.Filename, + "ext": r.Ext, + "variables": r.Variables, + "options": r.Options, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) GetTemplateID() uint64 { + return r.TemplateID +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) GetFilename() string { + return r.Filename +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) GetExt() string { + return r.Ext +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) GetVariables() json.RawMessage { + return r.Variables +} + +// Auditable returns all auditable/loggable parameters +func (r TemplateRender) GetOptions() json.RawMessage { + return r.Options +} + +// Fill processes request and fills internal variables +func (r *TemplateRender) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["variables"]; ok && len(val) > 0 { + r.Variables, err = json.RawMessage(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["options"]; ok && len(val) > 0 { + r.Options, err = json.RawMessage(val[0]), nil + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "templateID") + r.TemplateID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "filename") + r.Filename, err = val, nil + if err != nil { + return err + } + + val = chi.URLParam(req, "ext") + r.Ext, err = val, nil + if err != nil { + return err + } + + } + + return err +} diff --git a/system/rest/router.go b/system/rest/router.go index b55b8ff40..9e420e111 100644 --- a/system/rest/router.go +++ b/system/rest/router.go @@ -34,6 +34,7 @@ func MountRoutes(r chi.Router) { handlers.NewRole(Role{}.New()).MountRoutes(r) handlers.NewPermissions(Permissions{}.New()).MountRoutes(r) handlers.NewApplication(Application{}.New()).MountRoutes(r) + handlers.NewTemplate(Template{}.New()).MountRoutes(r) handlers.NewSettings(Settings{}.New()).MountRoutes(r) handlers.NewStats(Stats{}.New()).MountRoutes(r) handlers.NewReminder(Reminder{}.New()).MountRoutes(r) diff --git a/system/rest/template.go b/system/rest/template.go new file mode 100644 index 000000000..648c9eb85 --- /dev/null +++ b/system/rest/template.go @@ -0,0 +1,216 @@ +package rest + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/system/rest/request" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/pkg/errors" +) + +var _ = errors.Wrap + +type ( + Template struct { + renderer service.TemplateService + ac templateAccessController + } + + templateSetPayload struct { + Filter types.TemplateFilter `json:"filter"` + Set []*templatePayload `json:"set"` + } + + templatePayload struct { + *types.Template + + CanGrant bool `json:"canGrant"` + CanUpdateTemplate bool `json:"canUpdateTemplate"` + CanDeleteTemplate bool `json:"canDeleteTemplate"` + } + + templateAccessController interface { + CanGrant(context.Context) bool + + CanAccess(context.Context) bool + CanCreateTemplate(context.Context) bool + CanReadTemplate(context.Context, *types.Template) bool + CanUpdateTemplate(context.Context, *types.Template) bool + CanDeleteTemplate(context.Context, *types.Template) bool + } +) + +func (Template) New() *Template { + return &Template{ + renderer: service.DefaultRenderer, + ac: service.DefaultAccessControl, + } +} + +func (ctrl *Template) Read(ctx context.Context, r *request.TemplateRead) (interface{}, error) { + tpl, err := ctrl.renderer.FindByID(ctx, r.TemplateID) + return ctrl.makePayload(ctx, tpl, err) +} + +func (ctrl *Template) List(ctx context.Context, r *request.TemplateList) (interface{}, error) { + var ( + err error + f = types.TemplateFilter{ + Handle: r.Handle, + Type: r.Type, + OwnerID: r.OwnerID, + Partial: r.Partial, + Deleted: filter.State(r.Deleted), + } + ) + + if f.Paging, err = filter.NewPaging(r.Limit, r.PageCursor); err != nil { + return nil, err + } + + if f.Sorting, err = filter.NewSorting(r.Sort); err != nil { + return nil, err + } + + set, filter, err := ctrl.renderer.Search(ctx, f) + return ctrl.makeFilterPayload(ctx, set, filter, err) +} + +func (ctrl *Template) Create(ctx context.Context, r *request.TemplateCreate) (interface{}, error) { + var ( + err error + app = &types.Template{ + Handle: r.Handle, + Language: r.Language, + Type: types.DocumentType(r.Type), + Partial: r.Partial, + Meta: r.Meta, + Template: r.Template, + OwnerID: r.OwnerID, + } + ) + + app, err = ctrl.renderer.Create(ctx, app) + return ctrl.makePayload(ctx, app, err) +} + +func (ctrl *Template) Update(ctx context.Context, r *request.TemplateUpdate) (interface{}, error) { + var ( + err error + app = &types.Template{ + ID: r.TemplateID, + Handle: r.Handle, + Language: r.Language, + Type: types.DocumentType(r.Type), + Partial: r.Partial, + Meta: r.Meta, + Template: r.Template, + OwnerID: r.OwnerID, + } + ) + + app, err = ctrl.renderer.Update(ctx, app) + return ctrl.makePayload(ctx, app, err) +} + +func (ctrl *Template) Delete(ctx context.Context, r *request.TemplateDelete) (interface{}, error) { + return api.OK(), ctrl.renderer.DeleteByID(ctx, r.TemplateID) +} + +func (ctrl *Template) Undelete(ctx context.Context, r *request.TemplateUndelete) (interface{}, error) { + return api.OK(), ctrl.renderer.UndeleteByID(ctx, r.TemplateID) +} + +func (ctrl *Template) Render(ctx context.Context, r *request.TemplateRender) (interface{}, error) { + vars := make(map[string]interface{}) + err := json.Unmarshal(r.Variables, &vars) + if err != nil { + return nil, err + } + + opts := make(map[string]string) + if r.Options != nil { + err = json.Unmarshal(r.Options, &opts) + if err != nil { + return nil, err + } + } + + ct := ctrl.getDestinationType(r.Ext) + + doc, err := ctrl.renderer.Render(ctx, r.TemplateID, ct, vars, opts) + return ctrl.serve(doc, ct, r, err) +} + +// Utilities + +func (ctrl Template) makeFilterPayload(ctx context.Context, nn types.TemplateSet, f types.TemplateFilter, err error) (*templateSetPayload, error) { + if err != nil { + return nil, err + } + + msp := &templateSetPayload{Filter: f, Set: make([]*templatePayload, len(nn))} + + for i := range nn { + msp.Set[i], _ = ctrl.makePayload(ctx, nn[i], nil) + } + + return msp, nil +} + +func (ctrl Template) makePayload(ctx context.Context, tpl *types.Template, err error) (*templatePayload, error) { + if err != nil || tpl == nil { + return nil, err + } + + pl := &templatePayload{ + Template: tpl, + + CanGrant: ctrl.ac.CanGrant(ctx), + CanUpdateTemplate: ctrl.ac.CanUpdateTemplate(ctx, tpl), + CanDeleteTemplate: ctrl.ac.CanDeleteTemplate(ctx, tpl), + } + + return pl, nil +} + +func (ctrl *Template) serve(doc io.ReadSeeker, ct string, r *request.TemplateRender, err error) (interface{}, error) { + if err != nil { + return nil, err + } + + return func(w http.ResponseWriter, req *http.Request) { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + name := url.QueryEscape(strings.TrimSpace(r.Filename) + "." + strings.TrimSpace(r.Ext)) + w.Header().Add("Content-Disposition", "attachment; filename="+name) + w.Header().Add("Content-Type", ct+"; charset=utf-8") + + http.ServeContent(w, req, name, time.Now(), doc) + }, nil +} + +func (ctrl *Template) getDestinationType(ext string) string { + switch ext { + case "txt": + return "text/plain" + case "html": + return "text/html" + case "pdf": + return "application/pdf" + } + + return "text/plain" +} diff --git a/system/service/access_control.go b/system/service/access_control.go index 9916f479d..053ab7a8c 100644 --- a/system/service/access_control.go +++ b/system/service/access_control.go @@ -43,6 +43,7 @@ func (svc accessControl) Effective(ctx context.Context) (ee rbac.EffectiveSet) { ee.Push(types.SystemRBACResource, "settings.read", svc.CanReadSettings(ctx)) ee.Push(types.SystemRBACResource, "settings.manage", svc.CanManageSettings(ctx)) ee.Push(types.SystemRBACResource, "application.create", svc.CanCreateApplication(ctx)) + ee.Push(types.SystemRBACResource, "template.create", svc.CanCreateTemplate(ctx)) ee.Push(types.SystemRBACResource, "role.create", svc.CanCreateRole(ctx)) return @@ -76,6 +77,10 @@ func (svc accessControl) CanCreateApplication(ctx context.Context) bool { return svc.can(ctx, types.SystemRBACResource, "application.create") } +func (svc accessControl) CanCreateTemplate(ctx context.Context) bool { + return svc.can(ctx, types.SystemRBACResource, "template.create") +} + func (svc accessControl) CanAssignReminder(ctx context.Context) bool { return svc.can(ctx, types.SystemRBACResource, "reminder.assign") } @@ -119,6 +124,22 @@ func (svc accessControl) CanDeleteApplication(ctx context.Context, app *types.Ap return svc.can(ctx, app.RBACResource(), "delete") } +func (svc accessControl) CanReadTemplate(ctx context.Context, tpl *types.Template) bool { + return svc.can(ctx, tpl.RBACResource(), "read", rbac.Allowed) +} + +func (svc accessControl) CanUpdateTemplate(ctx context.Context, tpl *types.Template) bool { + return svc.can(ctx, tpl.RBACResource(), "update") +} + +func (svc accessControl) CanDeleteTemplate(ctx context.Context, tpl *types.Template) bool { + return svc.can(ctx, tpl.RBACResource(), "delete") +} + +func (svc accessControl) CanRenderTemplate(ctx context.Context, tpl *types.Template) bool { + return svc.can(ctx, tpl.RBACResource(), "render", rbac.Allowed) +} + func (svc accessControl) CanReadUser(ctx context.Context, u *types.User) bool { return svc.can(ctx, u.RBACResource(), "read") } @@ -225,6 +246,7 @@ func (svc accessControl) Whitelist() rbac.Whitelist { "role.create", "user.create", "application.create", + "template.create", "reminder.assign", ) @@ -235,6 +257,14 @@ func (svc accessControl) Whitelist() rbac.Whitelist { "delete", ) + wl.Set( + types.TemplateRBACResource, + "read", + "update", + "delete", + "render", + ) + wl.Set( types.UserRBACResource, "read", diff --git a/system/service/auth_notification.go b/system/service/auth_notification.go index 2b7491c57..d45b88476 100644 --- a/system/service/auth_notification.go +++ b/system/service/auth_notification.go @@ -4,8 +4,9 @@ import ( "bytes" "context" "fmt" + htpl "html/template" + "github.com/cortezaproject/corteza-server/system/types" - "html/template" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -30,11 +31,11 @@ type ( EmailAddress string URL string BaseURL string - Logo template.URL + Logo htpl.URL SignatureName string SignatureEmail string - EmailHeaderEn template.HTML - EmailFooterEn template.HTML + EmailHeaderEn htpl.HTML + EmailFooterEn htpl.HTML } ) @@ -84,7 +85,7 @@ func (svc authNotification) send(ctx context.Context, name, lang string, payload ntf = svc.newMail() ) - payload.Logo = template.URL(svc.settings.General.Mail.Logo) + payload.Logo = htpl.URL(svc.settings.General.Mail.Logo) payload.BaseURL = svc.settings.Auth.Frontend.Url.Base payload.SignatureName = svc.settings.Auth.Mail.FromName payload.SignatureEmail = svc.settings.Auth.Mail.FromAddress @@ -93,11 +94,11 @@ func (svc authNotification) send(ctx context.Context, name, lang string, payload if tmp, err = svc.render(svc.settings.General.Mail.Header, payload); err != nil { return fmt.Errorf("failed to render svc.settings.General.Mail.Header: %w", err) } - payload.EmailHeaderEn = template.HTML(tmp) + payload.EmailHeaderEn = htpl.HTML(tmp) if tmp, err = svc.render(svc.settings.General.Mail.Footer, payload); err != nil { return fmt.Errorf("failed to render svc.settings.General.Mail.Footer: %w", err) } - payload.EmailFooterEn = template.HTML(tmp) + payload.EmailFooterEn = htpl.HTML(tmp) ntf.SetAddressHeader("To", payload.EmailAddress, "") // @todo translations @@ -139,11 +140,11 @@ func (svc authNotification) send(ctx context.Context, name, lang string, payload func (svc authNotification) render(source string, payload interface{}) (string, error) { var ( err error - tpl *template.Template + tpl *htpl.Template buf = bytes.Buffer{} ) - tpl, err = template.New("").Parse(source) + tpl, err = htpl.New("").Parse(source) if err != nil { return "", fmt.Errorf("could not parse template: %w", err) } diff --git a/system/service/service.go b/system/service/service.go index a4f91bf23..76d5a51e4 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -3,9 +3,10 @@ package service import ( "context" "errors" - "github.com/cortezaproject/corteza-server/pkg/logger" "time" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/pkg/actionlog" intAuth "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/eventbus" @@ -30,6 +31,7 @@ type ( Config struct { ActionLog options.ActionLogOpt Storage options.ObjectStoreOpt + Template options.TemplateOpt } permitChecker interface { @@ -86,6 +88,7 @@ var ( DefaultApplication *application DefaultReminder ReminderService DefaultAttachment AttachmentService + DefaultRenderer TemplateService DefaultStatistics *statistics @@ -176,6 +179,7 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, c Config) DefaultSink = Sink() DefaultStatistics = Statistics() DefaultAttachment = Attachment(DefaultObjectStore) + DefaultRenderer = Renderer(c.Template) return } @@ -211,3 +215,19 @@ func unwrapGeneric(err error) error { return err } } + +// Data is stale when new date does not match updatedAt or createdAt (before first update) +// +// @todo This is the same as in compose.service; do we want to make an util thing? +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) +} diff --git a/system/service/template.go b/system/service/template.go new file mode 100644 index 000000000..b8e3939ff --- /dev/null +++ b/system/service/template.go @@ -0,0 +1,467 @@ +package service + +import ( + "bytes" + "context" + "io" + "strconv" + + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/handle" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/options" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/renderer" + "github.com/cortezaproject/corteza-server/system/types" +) + +type ( + template struct { + actionlog actionlog.Recorder + store store.Storer + ac templateAccessController + + renderer rendererService + } + + templateAccessController interface { + CanAccess(context.Context) bool + CanCreateTemplate(context.Context) bool + CanReadTemplate(context.Context, *types.Template) bool + CanUpdateTemplate(context.Context, *types.Template) bool + CanDeleteTemplate(context.Context, *types.Template) bool + CanRenderTemplate(context.Context, *types.Template) bool + } + + rendererService interface { + Render(ctx context.Context, p *renderer.RendererPayload) (io.ReadSeeker, error) + } + + TemplateService interface { + FindByID(ctx context.Context, ID uint64) (*types.Template, error) + FindByHandle(ct context.Context, handle string) (*types.Template, error) + FindByAny(ctx context.Context, identifier interface{}) (*types.Template, error) + Search(context.Context, types.TemplateFilter) (types.TemplateSet, types.TemplateFilter, error) + + Create(ctx context.Context, tpl *types.Template) (*types.Template, error) + Update(ctx context.Context, tpl *types.Template) (*types.Template, error) + + DeleteByID(ctx context.Context, ID uint64) error + UndeleteByID(ctx context.Context, ID uint64) error + + Render(ctx context.Context, templateID uint64, dstType string, variables map[string]interface{}, options map[string]string) (io.ReadSeeker, error) + } +) + +func Renderer(cfg options.TemplateOpt) TemplateService { + return (&template{ + actionlog: DefaultActionlog, + store: DefaultStore, + ac: DefaultAccessControl, + + renderer: renderer.Renderer(cfg), + }) +} + +func (svc template) FindByID(ctx context.Context, ID uint64) (tpl *types.Template, err error) { + var ( + tplProps = &templateActionProps{template: &types.Template{ID: ID}} + ) + + err = func() error { + if ID == 0 { + return TemplateErrInvalidID() + } + + if tpl, err = store.LookupTemplateByID(ctx, svc.store, ID); err != nil { + return TemplateErrInvalidID().Wrap(err) + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanReadTemplate(ctx, tpl) { + return TemplateErrNotAllowedToRead() + } + + return nil + }() + + return tpl, svc.recordAction(ctx, tplProps, TemplateActionLookup, err) +} + +func (svc template) FindByHandle(ctx context.Context, h string) (tpl *types.Template, err error) { + var ( + tplProps = &templateActionProps{template: &types.Template{Handle: h}} + ) + + err = func() error { + if h == "" || !handle.IsValid(h) { + return TemplateErrInvalidHandle() + } + + if tpl, err = store.LookupTemplateByHandle(ctx, svc.store, h); err != nil { + return TemplateErrInvalidHandle().Wrap(err) + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanReadTemplate(ctx, tpl) { + return TemplateErrNotAllowedToRead() + } + + return nil + }() + + return tpl, svc.recordAction(ctx, tplProps, TemplateActionLookup, err) +} + +func (svc template) FindByAny(ctx context.Context, identifier interface{}) (tpl *types.Template, err error) { + if ID, ok := identifier.(uint64); ok { + tpl, err = svc.FindByID(ctx, ID) + } else if strIdentifier, ok := identifier.(string); ok { + if ID, _ := strconv.ParseUint(strIdentifier, 10, 64); ID > 0 { + tpl, err = svc.FindByID(ctx, ID) + } else { + tpl, err = svc.FindByHandle(ctx, strIdentifier) + } + } else { + err = TemplateErrInvalidID() + } + + if err != nil { + return + } + + return +} + +func (svc template) Search(ctx context.Context, filter types.TemplateFilter) (set types.TemplateSet, f types.TemplateFilter, err error) { + var ( + aProps = &templateActionProps{filter: &filter} + ) + + // For each fetched item, store backend will check if it is valid or not + filter.Check = func(res *types.Template) (bool, error) { + if !svc.ac.CanReadTemplate(ctx, res) { + return false, nil + } + + return true, nil + } + + err = func() error { + if len(filter.Labels) > 0 { + filter.LabeledIDs, err = label.Search( + ctx, + svc.store, + types.Template{}.LabelResourceKind(), + filter.Labels, + ) + + if err != nil { + return err + } + } + + if set, f, err = store.SearchTemplates(ctx, svc.store, filter); err != nil { + return err + } + + if err = label.Load(ctx, svc.store, toLabeledTemplates(set)...); err != nil { + return err + } + + return nil + }() + + return set, f, svc.recordAction(ctx, aProps, TemplateActionSearch, err) +} + +func (svc template) Create(ctx context.Context, new *types.Template) (tpl *types.Template, err error) { + var ( + tplProps = &templateActionProps{new: new} + ) + + err = func() (err error) { + if !svc.ac.CanCreateTemplate(ctx) { + return TemplateErrNotAllowedToCreate() + } + + // @todo corredor? + + // Set new values after beforeCreate events are emitted + new.ID = nextID() + new.CreatedAt = *now() + + if err = store.CreateTemplate(ctx, svc.store, new); err != nil { + return + } + + if err = label.Create(ctx, svc.store, new); err != nil { + return + } + + tpl = new + + return nil + }() + + return tpl, svc.recordAction(ctx, tplProps, TemplateActionCreate, err) +} + +func (svc template) Update(ctx context.Context, upd *types.Template) (tpl *types.Template, err error) { + var ( + tplProps = &templateActionProps{update: upd} + ) + + err = func() (err error) { + if upd.ID == 0 { + return TemplateErrInvalidID() + } + + if tpl, err = store.LookupTemplateByID(ctx, svc.store, upd.ID); err != nil { + return + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanUpdateTemplate(ctx, tpl) { + return TemplateErrNotAllowedToUpdate() + } + + // @todo corredor? + + tpl.Handle = upd.Handle + tpl.Language = upd.Language + tpl.Type = upd.Type + tpl.Partial = upd.Partial + tpl.Meta = upd.Meta + tpl.Template = upd.Template + tpl.OwnerID = upd.OwnerID + tpl.UpdatedAt = now() + + if err = store.UpdateTemplate(ctx, svc.store, tpl); err != nil { + return err + } + + if label.Changed(tpl.Labels, upd.Labels) { + if err = label.Update(ctx, svc.store, upd); err != nil { + return + } + tpl.Labels = upd.Labels + } + + return nil + }() + + return tpl, svc.recordAction(ctx, tplProps, TemplateActionUpdate, err) +} + +func (svc template) DeleteByID(ctx context.Context, ID uint64) (err error) { + var ( + tplProps = &templateActionProps{} + tpl *types.Template + ) + + err = func() (err error) { + if ID == 0 { + return TemplateErrInvalidID() + } + + if tpl, err = store.LookupTemplateByID(ctx, svc.store, ID); err != nil { + return + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanDeleteTemplate(ctx, tpl) { + return TemplateErrNotAllowedToDelete() + } + + // @todo corredor? + + tpl.DeletedAt = now() + if err = store.UpdateTemplate(ctx, svc.store, tpl); err != nil { + return + } + + return nil + }() + + return svc.recordAction(ctx, tplProps, TemplateActionDelete, err) +} + +func (svc template) UndeleteByID(ctx context.Context, ID uint64) (err error) { + var ( + tplProps = &templateActionProps{} + tpl *types.Template + ) + + err = func() (err error) { + if ID == 0 { + return TemplateErrInvalidID() + } + + if tpl, err = store.LookupTemplateByID(ctx, svc.store, ID); err != nil { + return + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanDeleteTemplate(ctx, tpl) { + return TemplateErrNotAllowedToUndelete() + } + + // @todo corredor? + tpl.DeletedAt = nil + if err = store.UpdateTemplate(ctx, svc.store, tpl); err != nil { + return + } + + return nil + }() + + return svc.recordAction(ctx, tplProps, TemplateActionUndelete, err) +} + +func (svc template) Render(ctx context.Context, templateID uint64, dstType string, variables map[string]interface{}, options map[string]string) (document io.ReadSeeker, err error) { + var ( + tplProps = &templateActionProps{} + tpl *types.Template + ) + + err = func() (err error) { + tpl, err = svc.FindByID(ctx, templateID) + if err != nil { + return err + } + if tpl == nil { + return TemplateErrNotFound() + } + if tpl.Partial { + return TemplateErrCannotRenderPartial() + } + + tplProps.setTemplate(tpl) + + if !svc.ac.CanRenderTemplate(ctx, tpl) { + return TemplateErrNotAllowedToRender() + } + + // Prepare partials + // + // @todo Make this more sophisticated by inspecting the template or + // by requiring users to "import" (specify) what partials to use. + pp, err := svc.getPartials(ctx, tpl) + if err != nil { + return err + } + + att, err := svc.getAttachments(ctx, tpl) + if err != nil { + return err + } + + // Prepare payload + p := &renderer.RendererPayload{ + Template: svc.getSource(tpl), + TemplateType: tpl.Type, + TargetType: types.DocumentType(dstType), + Variables: variables, + Options: options, + Partials: pp, + Attachments: att, + } + + // Render the doc + document, err = svc.renderer.Render(ctx, p) + if err != nil { + return err + } + return nil + }() + + return document, svc.recordAction(ctx, tplProps, TemplateActionRender, err) +} + +// Util things + +func (svc template) getSource(tpl *types.Template) io.Reader { + return bytes.NewBuffer([]byte(tpl.Template)) +} + +func (svc template) getPartials(ctx context.Context, tpl *types.Template) ([]*renderer.TemplatePartial, error) { + pp := make([]*renderer.TemplatePartial, 0, 20) + + set, _, err := svc.Search(ctx, types.TemplateFilter{ + Partial: true, + }) + if err != nil { + return nil, err + } + + // @todo inspect original template to filter partials + // @todo do some filtering based on partial type and main template type + + for _, t := range set { + pp = append(pp, &renderer.TemplatePartial{ + Handle: t.Handle, + Template: bytes.NewBuffer([]byte(t.Template)), + TemplateType: t.Type, + }) + } + + return pp, nil +} + +// @todo... +func (svc template) getAttachments(ctx context.Context, tpl *types.Template) (renderer.AttachmentIndex, error) { + return make(renderer.AttachmentIndex), nil + // fpath := "..." + + // att := make(renderer.AttachmentIndex) + // return att, filepath.Walk(fpath, func(fpath string, info os.FileInfo, err error) error { + // if err != nil { + // return err + // } + + // if info.IsDir() { + // return nil + // } + + // f, err := os.Open(fpath) + // if err != nil { + // return err + // } + // defer f.Close() + + // bb := make([]byte, info.Size()) + // _, err = f.Read(bb) + // if err != nil { + // return err + // } + + // att[info.Name()] = &renderer.Attachment{ + // Source: bytes.NewBuffer(bb), + // // @todo proper implementation!!! + // Mime: "image/png", + // Name: info.Name(), + // } + + // return nil + // }) +} + +// toLabeledTemplates converts to []label.LabeledResource +func toLabeledTemplates(set []*types.Template) []label.LabeledResource { + if len(set) == 0 { + return nil + } + + ll := make([]label.LabeledResource, len(set)) + for i := range set { + ll[i] = set[i] + } + + return ll +} diff --git a/system/service/template_actions.gen.go b/system/service/template_actions.gen.go new file mode 100644 index 000000000..c73fcb0d4 --- /dev/null +++ b/system/service/template_actions.gen.go @@ -0,0 +1,826 @@ +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: +// system/service/template_actions.yaml + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/system/types" + "strings" + "time" +) + +type ( + templateActionProps struct { + template *types.Template + new *types.Template + update *types.Template + filter *types.TemplateFilter + } + + templateAction struct { + timestamp time.Time + resource string + action string + log string + severity actionlog.Severity + + // prefix for error when action fails + errorMessage string + + props *templateActionProps + } + + templateLogMetaKey struct{} + templatePropsMetaKey struct{} +) + +var ( + // just a placeholder to cover template cases w/o fmt package use + _ = fmt.Println +) + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Props methods +// setTemplate updates templateActionProps's template +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *templateActionProps) setTemplate(template *types.Template) *templateActionProps { + p.template = template + return p +} + +// setNew updates templateActionProps's new +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *templateActionProps) setNew(new *types.Template) *templateActionProps { + p.new = new + return p +} + +// setUpdate updates templateActionProps's update +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *templateActionProps) setUpdate(update *types.Template) *templateActionProps { + p.update = update + return p +} + +// setFilter updates templateActionProps's filter +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *templateActionProps) setFilter(filter *types.TemplateFilter) *templateActionProps { + p.filter = filter + return p +} + +// Serialize converts templateActionProps to actionlog.Meta +// +// This function is auto-generated. +// +func (p templateActionProps) Serialize() actionlog.Meta { + var ( + m = make(actionlog.Meta) + ) + + if p.template != nil { + m.Set("template.handle", p.template.Handle, true) + m.Set("template.type", p.template.Type, true) + m.Set("template.ID", p.template.ID, true) + } + if p.new != nil { + m.Set("new.handle", p.new.Handle, true) + m.Set("new.type", p.new.Type, true) + m.Set("new.ID", p.new.ID, true) + } + if p.update != nil { + m.Set("update.handle", p.update.Handle, true) + m.Set("update.type", p.update.Type, true) + m.Set("update.ID", p.update.ID, true) + } + if p.filter != nil { + m.Set("filter.templateID", p.filter.TemplateID, true) + m.Set("filter.handle", p.filter.Handle, true) + m.Set("filter.type", p.filter.Type, true) + m.Set("filter.ownerID", p.filter.OwnerID, true) + m.Set("filter.deleted", p.filter.Deleted, true) + m.Set("filter.sort", p.filter.Sort, true) + } + + return m +} + +// tr translates string and replaces meta value placeholder with values +// +// This function is auto-generated. +// +func (p templateActionProps) 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.template != nil { + // replacement for "{template}" (in order how fields are defined) + pairs = append( + pairs, + "{template}", + fns( + p.template.Handle, + p.template.Type, + p.template.ID, + ), + ) + pairs = append(pairs, "{template.handle}", fns(p.template.Handle)) + pairs = append(pairs, "{template.type}", fns(p.template.Type)) + pairs = append(pairs, "{template.ID}", fns(p.template.ID)) + } + + if p.new != nil { + // replacement for "{new}" (in order how fields are defined) + pairs = append( + pairs, + "{new}", + fns( + p.new.Handle, + p.new.Type, + p.new.ID, + ), + ) + pairs = append(pairs, "{new.handle}", fns(p.new.Handle)) + pairs = append(pairs, "{new.type}", fns(p.new.Type)) + pairs = append(pairs, "{new.ID}", fns(p.new.ID)) + } + + if p.update != nil { + // replacement for "{update}" (in order how fields are defined) + pairs = append( + pairs, + "{update}", + fns( + p.update.Handle, + p.update.Type, + p.update.ID, + ), + ) + pairs = append(pairs, "{update.handle}", fns(p.update.Handle)) + pairs = append(pairs, "{update.type}", fns(p.update.Type)) + pairs = append(pairs, "{update.ID}", fns(p.update.ID)) + } + + if p.filter != nil { + // replacement for "{filter}" (in order how fields are defined) + pairs = append( + pairs, + "{filter}", + fns( + p.filter.TemplateID, + p.filter.Handle, + p.filter.Type, + p.filter.OwnerID, + p.filter.Deleted, + p.filter.Sort, + ), + ) + pairs = append(pairs, "{filter.templateID}", fns(p.filter.TemplateID)) + pairs = append(pairs, "{filter.handle}", fns(p.filter.Handle)) + pairs = append(pairs, "{filter.type}", fns(p.filter.Type)) + pairs = append(pairs, "{filter.ownerID}", fns(p.filter.OwnerID)) + pairs = append(pairs, "{filter.deleted}", fns(p.filter.Deleted)) + pairs = append(pairs, "{filter.sort}", fns(p.filter.Sort)) + } + return strings.NewReplacer(pairs...).Replace(in) +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action methods + +// String returns loggable description as string +// +// This function is auto-generated. +// +func (a *templateAction) String() string { + var props = &templateActionProps{} + + if a.props != nil { + props = a.props + } + + return props.Format(a.log, nil) +} + +func (e *templateAction) ToAction() *actionlog.Action { + return &actionlog.Action{ + Resource: e.resource, + Action: e.action, + Severity: e.severity, + Description: e.String(), + Meta: e.props.Serialize(), + } +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action constructors + +// TemplateActionSearch returns "system:template.search" action +// +// This function is auto-generated. +// +func TemplateActionSearch(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "search", + log: "searched for templates", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionLookup returns "system:template.lookup" action +// +// This function is auto-generated. +// +func TemplateActionLookup(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "lookup", + log: "looked-up for a {template}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionCreate returns "system:template.create" action +// +// This function is auto-generated. +// +func TemplateActionCreate(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "create", + log: "created {template}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionUpdate returns "system:template.update" action +// +// This function is auto-generated. +// +func TemplateActionUpdate(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "update", + log: "updated {template}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionDelete returns "system:template.delete" action +// +// This function is auto-generated. +// +func TemplateActionDelete(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "delete", + log: "deleted {template}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionUndelete returns "system:template.undelete" action +// +// This function is auto-generated. +// +func TemplateActionUndelete(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "undelete", + log: "undeleted {template}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TemplateActionRender returns "system:template.render" action +// +// This function is auto-generated. +// +func TemplateActionRender(props ...*templateActionProps) *templateAction { + a := &templateAction{ + timestamp: time.Now(), + resource: "system:template", + action: "render", + log: "rendered {template}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Error constructors + +// TemplateErrGeneric returns "system:template.generic" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrGeneric(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + 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", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "{err}"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotFound returns "system:template.notFound" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotFound(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("template not found", nil), + + errors.Meta("type", "notFound"), + errors.Meta("resource", "system:template"), + + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrInvalidID returns "system:template.invalidID" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrInvalidID(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + 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", "system:template"), + + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrInvalidHandle returns "system:template.invalidHandle" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrInvalidHandle(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + 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", "system:template"), + + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrCannotRenderPartial returns "system:template.cannotRenderPartial" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrCannotRenderPartial(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("cannot render partial templates", nil), + + errors.Meta("type", "cannotRenderPartial"), + errors.Meta("resource", "system:template"), + + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToRead returns "system:template.notAllowedToRead" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToRead(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to read this template", nil), + + errors.Meta("type", "notAllowedToRead"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to read {template.handle}; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToListTemplates returns "system:template.notAllowedToListTemplates" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToListTemplates(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to list templates", nil), + + errors.Meta("type", "notAllowedToListTemplates"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to list template; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToCreate returns "system:template.notAllowedToCreate" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToCreate(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to create templates", nil), + + errors.Meta("type", "notAllowedToCreate"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to create template; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToUpdate returns "system:template.notAllowedToUpdate" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToUpdate(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to update this template", nil), + + errors.Meta("type", "notAllowedToUpdate"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to update {template.handle}; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToDelete returns "system:template.notAllowedToDelete" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToDelete(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to delete this template", nil), + + errors.Meta("type", "notAllowedToDelete"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to delete {template.handle}; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToUndelete returns "system:template.notAllowedToUndelete" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToUndelete(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to undelete this template", nil), + + errors.Meta("type", "notAllowedToUndelete"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to undelete {template.handle}; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TemplateErrNotAllowedToRender returns "system:template.notAllowedToRender" as *errors.Error +// +// +// This function is auto-generated. +// +func TemplateErrNotAllowedToRender(mm ...*templateActionProps) *errors.Error { + var p = &templateActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to render this template", nil), + + errors.Meta("type", "notAllowedToRender"), + errors.Meta("resource", "system:template"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(templateLogMetaKey{}, "failed to render {template.handle}; insufficient permissions"), + errors.Meta(templatePropsMetaKey{}, p), + + 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 template) recordAction(ctx context.Context, props *templateActionProps, actionFn func(...*templateActionProps) *templateAction, 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(templateLogMetaKey{}), err) + + if p, has := m[templatePropsMetaKey{}]; has { + a.Meta = p.(*templateActionProps).Serialize() + } + + svc.actionlog.Record(ctx, a) + default: + svc.actionlog.Record(ctx, a) + } + + // Original error is passed on + return err +} diff --git a/system/service/template_actions.yaml b/system/service/template_actions.yaml new file mode 100644 index 000000000..193c07346 --- /dev/null +++ b/system/service/template_actions.yaml @@ -0,0 +1,95 @@ +# List of loggable service actions + +resource: system:template +service: template + +# Default sensitivity for actions +defaultActionSeverity: notice + +# default severity for errors +defaultErrorSeverity: error + +import: + - github.com/cortezaproject/corteza-server/system/types + +props: + - name: template + type: "*types.Template" + fields: [ handle, type, ID ] + - name: new + type: "*types.Template" + fields: [ handle, type, ID ] + - name: update + type: "*types.Template" + fields: [ handle, type, ID ] + - name: filter + type: "*types.TemplateFilter" + fields: [ templateID, handle, type, ownerID, deleted, sort ] + +actions: + - action: search + log: "searched for templates" + severity: info + + - action: lookup + log: "looked-up for a {template}" + severity: info + + - action: create + log: "created {template}" + + - action: update + log: "updated {template}" + + - action: delete + log: "deleted {template}" + + - action: undelete + log: "undeleted {template}" + + - action: render + log: "rendered {template}" + +errors: + - error: notFound + message: "template not found" + severity: warning + + - error: invalidID + message: "invalid ID" + severity: warning + + - error: invalidHandle + message: "invalid handle" + severity: warning + + - error: cannotRenderPartial + message: "cannot render partial templates" + + - error: notAllowedToRead + message: "not allowed to read this template" + log: "failed to read {template.handle}; insufficient permissions" + + - error: notAllowedToListTemplates + message: "not allowed to list templates" + log: "failed to list template; insufficient permissions" + + - error: notAllowedToCreate + message: "not allowed to create templates" + log: "failed to create template; insufficient permissions" + + - error: notAllowedToUpdate + message: "not allowed to update this template" + log: "failed to update {template.handle}; insufficient permissions" + + - error: notAllowedToDelete + message: "not allowed to delete this template" + log: "failed to delete {template.handle}; insufficient permissions" + + - error: notAllowedToUndelete + message: "not allowed to undelete this template" + log: "failed to undelete {template.handle}; insufficient permissions" + + - error: notAllowedToRender + message: "not allowed to render this template" + log: "failed to render {template.handle}; insufficient permissions" diff --git a/system/types/parsers.go b/system/types/parsers.go new file mode 100644 index 000000000..7f2e4c81a --- /dev/null +++ b/system/types/parsers.go @@ -0,0 +1,16 @@ +package types + +import "encoding/json" + +func ParseTemplateMeta(ss []string) (p TemplateMeta, err error) { + p = TemplateMeta{} + return p, parseStringsInput(ss, p) +} + +func parseStringsInput(ss []string, p interface{}) (err error) { + if len(ss) == 0 { + return + } + + return json.Unmarshal([]byte(ss[0]), &p) +} diff --git a/system/types/permission_resources.go b/system/types/permission_resources.go index 929b6c3cd..eb4277843 100644 --- a/system/types/permission_resources.go +++ b/system/types/permission_resources.go @@ -6,6 +6,7 @@ import ( const SystemRBACResource = rbac.Resource("system") const ApplicationRBACResource = rbac.Resource("system:application:") +const TemplateRBACResource = rbac.Resource("system:template:") const OrganisationRBACResource = rbac.Resource("system:organisation:") const UserRBACResource = rbac.Resource("system:user:") const RoleRBACResource = rbac.Resource("system:role:") diff --git a/system/types/template.go b/system/types/template.go new file mode 100644 index 000000000..e832bbc6f --- /dev/null +++ b/system/types/template.go @@ -0,0 +1,103 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/pkg/errors" +) + +type ( + DocumentType string + + Template struct { + ID uint64 `json:"templateID,string"` + Handle string `json:"handle"` + // Language specifies the language the template is written for; leave empty for default + Language string `json:"language"` + + Type DocumentType `json:"type"` + // Partial templates can be used to construct larger templates; for example headers and footers + Partial bool `json:"partial"` + // use int so JS can handle it normally + // + // @todo We'll handle this at a later point + // Revision int `json:"revision,string"` + Meta TemplateMeta `json:"meta"` + + Template string `json:"template"` + + Labels map[string]string `json:"labels,omitempty"` + + OwnerID uint64 `json:"ownerID,string"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + } + + TemplateMeta struct { + Short string `json:"short"` + Description string `json:"description,omitempty"` + } + + TemplateFilter struct { + TemplateID []uint64 `json:"templateID"` + Handle string `json:"handle"` + Type string `json:"type"` + OwnerID uint64 `json:"ownerID,string"` + Partial bool `json:"partial"` + + LabeledIDs []uint64 `json:"-"` + Labels map[string]string `json:"labels,omitempty"` + + // Check fn is called by store backend for each resource found function can + // modify the resource and return false if store should not return it + // + // Store then loads additional resources to satisfy the paging parameters + Check func(*Template) (bool, error) `json:"-"` + + Deleted filter.State `json:"deleted"` + + // Standard helpers for paging and sorting + filter.Sorting + filter.Paging + } +) + +const ( + DocumentTypePlain DocumentType = "text/plain" + DocumentTypeHTML = "text/html" + DocumentTypePDF = "application/pdf" +) + +func (t *TemplateMeta) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *t = TemplateMeta{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, t); err != nil { + return errors.Wrapf(err, "Can not scan '%v' into TemplateMeta", string(b)) + } + } + + return nil +} + +func (t TemplateMeta) Value() (driver.Value, error) { + return json.Marshal(t) +} + +func (t Template) Clone() *Template { + c := &t + return c +} + +func (r Template) RBACResource() rbac.Resource { + return TemplateRBACResource.AppendID(r.ID) +} diff --git a/system/types/type_labels.gen.go b/system/types/type_labels.gen.go index ab1ca52b6..db9c5166e 100644 --- a/system/types/type_labels.gen.go +++ b/system/types/type_labels.gen.go @@ -56,6 +56,30 @@ func (m Role) LabelResourceID() uint64 { return m.ID } +// SetLabel adds new label to label map +func (m *Template) SetLabel(key string, value string) { + if m.Labels == nil { + m.Labels = make(map[string]string) + } + + m.Labels[key] = value +} + +// GetLabels adds new label to label map +func (m Template) GetLabels() map[string]string { + return m.Labels +} + +// GetLabels adds new label to label map +func (Template) LabelResourceKind() string { + return "template" +} + +// GetLabels adds new label to label map +func (m Template) LabelResourceID() uint64 { + return m.ID +} + // SetLabel adds new label to label map func (m *User) SetLabel(key string, value string) { if m.Labels == nil { diff --git a/system/types/type_set.gen.go b/system/types/type_set.gen.go index 6159c0f84..7a90e07ce 100644 --- a/system/types/type_set.gen.go +++ b/system/types/type_set.gen.go @@ -45,6 +45,11 @@ type ( // This type is auto-generated. SettingValueSet []*SettingValue + // TemplateSet slice of Template + // + // This type is auto-generated. + TemplateSet []*Template + // UserSet slice of User // // This type is auto-generated. @@ -391,6 +396,62 @@ func (set SettingValueSet) Filter(f func(*SettingValue) (bool, error)) (out Sett return } +// Walk iterates through every slice item and calls w(Template) err +// +// This function is auto-generated. +func (set TemplateSet) Walk(w func(*Template) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Template) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set TemplateSet) Filter(f func(*Template) (bool, error)) (out TemplateSet, err error) { + var ok bool + out = TemplateSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set TemplateSet) FindByID(ID uint64) *Template { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set TemplateSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} + // Walk iterates through every slice item and calls w(User) err // // This function is auto-generated. diff --git a/system/types/type_set.gen_test.go b/system/types/type_set.gen_test.go index 5468eb50d..6115adadd 100644 --- a/system/types/type_set.gen_test.go +++ b/system/types/type_set.gen_test.go @@ -576,6 +576,96 @@ func TestSettingValueSetFilter(t *testing.T) { } } +func TestTemplateSetWalk(t *testing.T) { + var ( + value = make(TemplateSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*Template) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*Template) error { return fmt.Errorf("walk error") })) +} + +func TestTemplateSetFilter(t *testing.T) { + var ( + value = make(TemplateSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*Template) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*Template) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*Template) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestTemplateSetIDs(t *testing.T) { + var ( + value = make(TemplateSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(Template) + value[1] = new(Template) + value[2] = new(Template) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} + func TestUserSetWalk(t *testing.T) { var ( value = make(UserSet, 3) diff --git a/system/types/types.yaml b/system/types/types.yaml index fdf2cb378..56a771a92 100644 --- a/system/types/types.yaml +++ b/system/types/types.yaml @@ -12,3 +12,5 @@ types: Attachment: {} SettingValue: noIdField: true + Template: + labelResourceType: template diff --git a/tests/helpers/assert.go b/tests/helpers/assert.go index e7de4988c..cc7129972 100644 --- a/tests/helpers/assert.go +++ b/tests/helpers/assert.go @@ -3,9 +3,10 @@ package helpers import ( "encoding/json" "fmt" + "io/ioutil" "net/http" + "strings" - "github.com/davecgh/go-spew/spew" "github.com/pkg/errors" "github.com/cortezaproject/corteza-server/compose/types" @@ -108,13 +109,10 @@ func AssertRecordValueError(exp ...*types.RecordValueError) assertFn { // Dump can be put into Assert() func Dump(rsp *http.Response, _ *http.Request) (err error) { - spew.Dump(rsp.Status) - spew.Dump(rsp.Header) var payload interface{} if err = DecodeBody(rsp, &payload); err != nil { return err } - spew.Dump(payload) return nil } @@ -137,3 +135,19 @@ func AssertError(expectedError string) assertFn { return nil } } + +// AssertBody compares the raw body to the provided string +func AssertBody(expected string) assertFn { + return func(rsp *http.Response, _ *http.Request) (err error) { + bb, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return err + } + + got := strings.Trim(string(bb), " \n") + if expected != got { + return errors.Errorf("Expecting: %v, got: %v", expected, got) + } + return nil + } +} diff --git a/tests/system/main_test.go b/tests/system/main_test.go index baba1c4c8..d1a42c044 100644 --- a/tests/system/main_test.go +++ b/tests/system/main_test.go @@ -3,6 +3,9 @@ package system import ( "context" "errors" + "os" + "testing" + "github.com/cortezaproject/corteza-server/app" "github.com/cortezaproject/corteza-server/pkg/api/server" "github.com/cortezaproject/corteza-server/pkg/auth" @@ -27,8 +30,6 @@ import ( "github.com/steinfletcher/apitest" "github.com/stretchr/testify/require" "go.uber.org/zap" - "os" - "testing" ) type ( @@ -177,3 +178,7 @@ func (h helper) setLabel(res label.LabeledResource, name, value string) { Value: value, })) } + +func (h helper) clearTemplates() { + h.noError(store.TruncateTemplates(context.Background(), service.DefaultStore)) +} diff --git a/tests/system/template_test.go b/tests/system/template_test.go new file mode 100644 index 000000000..4856c25f3 --- /dev/null +++ b/tests/system/template_test.go @@ -0,0 +1,266 @@ +package system + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/cortezaproject/corteza-server/tests/helpers" + jsonpath "github.com/steinfletcher/apitest-jsonpath" +) + +func (h helper) repoMakeTemplate(ss ...string) *types.Template { + var res = &types.Template{ + ID: id.Next(), + CreatedAt: time.Now(), + } + + if len(ss) > 0 { + res.Handle = ss[0] + } else { + res.Handle = "n_" + rs() + } + if len(ss) > 1 { + res.Template = ss[1] + } + if len(ss) > 2 { + res.Type = types.DocumentType(ss[2]) + } + + h.a.NoError(store.CreateTemplate(context.Background(), service.DefaultStore, res)) + + return res +} + +func (h helper) lookupTemplateByID(ID uint64) *types.Template { + res, err := store.LookupTemplateByID(context.Background(), service.DefaultStore, ID) + h.noError(err) + return res +} + +func TestTemplateRead(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + + u := h.repoMakeTemplate() + + h.apiInit(). + Get(fmt.Sprintf("/template/%d", u.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Equal(`$.response.handle`, u.Handle)). + Assert(jsonpath.Equal(`$.response.templateID`, fmt.Sprintf("%d", u.ID))). + End() +} + +func TestTemplateList(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + + h.repoMakeTemplate(rs()) + h.repoMakeTemplate(rs()) + + h.apiInit(). + Get("/template/"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len(`$.response.set`, 2)). + End() +} + +func TestTemplateList_filterForbidden(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + + // @todo this can be a problematic test because it leaves + // behind templates that are not denied this context + // db purge might be needed + + h.repoMakeTemplate("template") + f := h.repoMakeTemplate() + + h.deny(types.TemplateRBACResource.AppendID(f.ID), "read") + + h.apiInit(). + Get("/template/"). + Query("handle", f.Handle). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.NotPresent(fmt.Sprintf(`$.response.set[? @.handle=="%s"]`, f.Handle))). + End() +} + +func TestTemplateCreateForbidden(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + + h.apiInit(). + Post("/template/"). + Header("Accept", "application/json"). + FormData("handle", rs()). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to create templates")). + End() +} + +func TestTemplateCreate(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.allow(types.SystemRBACResource, "template.create") + + h.apiInit(). + Post("/template/"). + FormData("handle", rs()). + FormData("handle", "handle_"+rs()). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() +} + +func TestTemplateUpdateForbidden(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + u := h.repoMakeTemplate() + + h.apiInit(). + Put(fmt.Sprintf("/template/%d", u.ID)). + Header("Accept", "application/json"). + FormData("handle", rs()). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to update this template")). + End() +} + +func TestTemplateUpdate(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + res := h.repoMakeTemplate() + h.allow(types.TemplateRBACResource.AppendWildcard(), "update") + + newHandle := "updated-" + rs() + + h.apiInit(). + Put(fmt.Sprintf("/template/%d", res.ID)). + Header("Accept", "application/json"). + FormData("handle", newHandle). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res = h.lookupTemplateByID(res.ID) + h.a.NotNil(res) + h.a.Equal(newHandle, res.Handle) +} + +func TestTemplateDeleteForbidden(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + u := h.repoMakeTemplate() + + h.apiInit(). + Delete(fmt.Sprintf("/template/%d", u.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to delete this template")). + End() +} + +func TestTemplateDelete(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.allow(types.TemplateRBACResource.AppendWildcard(), "delete") + + res := h.repoMakeTemplate() + + h.apiInit(). + Delete(fmt.Sprintf("/template/%d", res.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res = h.lookupTemplateByID(res.ID) + h.a.NotNil(res) + h.a.NotNil(res.DeletedAt) +} + +func TestTemplateRenderForbiden(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.deny(types.TemplateRBACResource.AppendWildcard(), "render") + + res := h.repoMakeTemplate("rendering", "Hello, {{.interpolate}}", "text/plain") + + h.apiInit(). + Post(fmt.Sprintf("/template/%d/render/testing.txt", res.ID)). + JSON(`{"variables": {"interpolate": "world!"}}`). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to render this template")). + End() +} + +func TestTemplateRenderDriverUndefined(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.allow(types.TemplateRBACResource.AppendWildcard(), "render") + + res := h.repoMakeTemplate("rendering", "Hello, {{.interpolate}}", "text/notexisting") + + h.apiInit(). + Post(fmt.Sprintf("/template/%d/render/testing.txt", res.ID)). + JSON(`{"variables": {"interpolate": "world!"}}`). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("rendering failed: driver not found")). + End() +} + +func TestTemplateRenderPlain(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.allow(types.TemplateRBACResource.AppendWildcard(), "render") + + res := h.repoMakeTemplate("rendering", "Hello, {{.interpolate}}", "text/plain") + + h.apiInit(). + Post(fmt.Sprintf("/template/%d/render/testing.txt", res.ID)). + JSON(`{"variables": {"interpolate": "world!"}}`). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertBody("Hello, world!")). + End() +} + +func TestTemplateRenderHTML(t *testing.T) { + h := newHelper(t) + h.clearTemplates() + h.allow(types.TemplateRBACResource.AppendWildcard(), "render") + + res := h.repoMakeTemplate("rendering", "