Basic support for Mosaico templates.

TODO:
- Allow choosing a mosaico template in a mosaico-based template
- Integrate the custom mosaico templates with templates (endpoint for retrieving a mosaico template, replacement of URL_BASE and PLACEHOLDER tags
- Implement support for MJML-based Mosaico templates
- Implement support for MJML-based templates
- Implement support for GrapeJS-based templates
This commit is contained in:
Tomas Bures 2018-04-02 19:05:22 +02:00
parent 7b5642e911
commit 6706d93bc1
21 changed files with 2192 additions and 26 deletions

View file

@ -45,6 +45,7 @@ const sharesRest = require('./routes/rest/shares');
const segmentsRest = require('./routes/rest/segments'); const segmentsRest = require('./routes/rest/segments');
const subscriptionsRest = require('./routes/rest/subscriptions'); const subscriptionsRest = require('./routes/rest/subscriptions');
const templatesRest = require('./routes/rest/templates'); const templatesRest = require('./routes/rest/templates');
const mosaicoTemplatesRest = require('./routes/rest/mosaico-templates');
const blacklistRest = require('./routes/rest/blacklist'); const blacklistRest = require('./routes/rest/blacklist');
const editorsRest = require('./routes/rest/editors'); const editorsRest = require('./routes/rest/editors');
const filesRest = require('./routes/rest/files'); const filesRest = require('./routes/rest/files');
@ -279,6 +280,7 @@ function createApp(trusted) {
app.use('/rest', segmentsRest); app.use('/rest', segmentsRest);
app.use('/rest', subscriptionsRest); app.use('/rest', subscriptionsRest);
app.use('/rest', templatesRest); app.use('/rest', templatesRest);
app.use('/rest', mosaicoTemplatesRest);
app.use('/rest', blacklistRest); app.use('/rest', blacklistRest);
app.use('/rest', editorsRest); app.use('/rest', editorsRest);
app.use('/rest', filesRest); app.use('/rest', filesRest);

View file

@ -61,7 +61,7 @@ function getMenus(t) {
title: t('Create'), title: t('Create'),
panelRender: props => <ReportsCUD action="create" /> panelRender: props => <ReportsCUD action="create" />
}, },
'templates': { templates: {
title: t('Templates'), title: t('Templates'),
link: '/reports/templates', link: '/reports/templates',
panelComponent: ReportTemplatesList, panelComponent: ReportTemplatesList,

View file

@ -29,13 +29,22 @@ export default class List extends Component {
createTemplate: { createTemplate: {
entityTypeId: 'namespace', entityTypeId: 'namespace',
requiredOperations: ['createTemplate'] requiredOperations: ['createTemplate']
},
createMosaicoTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createMosaicoTemplate']
},
viewMosaicoTemplate: {
entityTypeId: 'mosaicoTemplate',
requiredOperations: ['view']
} }
}; };
const result = await axios.post('/rest/permissions-check', request); const result = await axios.post('/rest/permissions-check', request);
this.setState({ this.setState({
createPermitted: result.data.createTemplate createPermitted: result.data.createTemplate,
mosaicoTemplatesPermitted: result.data.createMosaicoTemplate || result.data.viewMosaicoTemplate
}); });
} }
@ -85,14 +94,16 @@ export default class List extends Component {
return ( return (
<div> <div>
{this.state.createPermitted && <Toolbar>
<Toolbar> {this.state.createPermitted &&
<NavButton linkTo="/templates/create" className="btn-primary" icon="plus" label={t('Create Template')}/> <NavButton linkTo="/templates/create" className="btn-primary" icon="plus" label={t('Create Template')}/>
}
{this.state.mosaicoTemplatesPermitted &&
<NavButton linkTo="/templates/mosaico" className="btn-primary" label={t('Mosaico Templates')}/> <NavButton linkTo="/templates/mosaico" className="btn-primary" label={t('Mosaico Templates')}/>
</Toolbar> }
} </Toolbar>
<Title>{t(' Templates')}</Title> <Title>{t('Templates')}</Title>
<Table withHeader dataUrl="/rest/templates-table" columns={columns} /> <Table withHeader dataUrl="/rest/templates-table" columns={columns} />
</div> </div>

View file

@ -0,0 +1,192 @@
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ButtonRow, Button } from '../../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals";
import { versafix } from "../../../../shared/mosaico-templates";
import { getTemplateTypes } from "./helpers";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.state = {};
this.initForm();
}
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/mosaico-templates/${this.props.entity.id}`, data => {
this.templateTypes[data.type].afterLoad(data);
});
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
this.templateTypes[data.type].afterLoad(data);
});
} else {
const wizard = this.props.wizard;
if (wizard === 'versafix') {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
type: 'html',
html: versafix
});
} else {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
type: 'html',
html: ''
});
}
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['type', 'value'])) {
state.setIn(['type', 'error'], t('Type must be selected'));
} else {
state.setIn(['type', 'error'], null);
}
validateNamespace(t, state);
}
async submitAndStay() {
await this.formHandleChangedError(async () => await this.doSubmit(true));
}
async submitAndLeave() {
await this.formHandleChangedError(async () => await this.doSubmit(false));
}
async doSubmit(stay) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `/rest/mosaico-templates/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/mosaico-templates'
}
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
this.templateTypes[data.type].beforeSave(data);
});
if (submitSuccessful) {
if (stay) {
await this.loadFormValues();
this.enableForm();
this.setFormStatusMessage('success', t('Mosaico template saved'));
} else {
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template saved'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const typeKey = this.getFormValue('type');
let form = null;
if (typeKey) {
form = this.templateTypes[typeKey].getForm(this);
}
const typeOptions = [];
for (const type of ['html', 'mjml']) {
typeOptions.push({
key: type,
label: this.templateTypes.typeName
});
}
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/rest/templates/mosaico/${this.props.entity.id}`}
cudUrl={`/templates/mosaico/${this.props.entity.id}/edit`}
listUrl="/templates/mosaico"
deletingMsg={t('Deleting mosaico template ...')}
deletedMsg={t('Mosaico template deleted')}/>
}
<Title>{isEdit ? t('Edit Mosaico Template') : t('Create Mosaico Template')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<Dropdown id="type" label={t('Type')} options={typeOptions}/>
<NamespaceSelect/>
{form}
{isEdit ?
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
{canDelete &&
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/templates/mosaico/${this.props.entity.id}/delete`}/>
}
</ButtonRow>
:
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
</ButtonRow>
}
</Form>
</div>
);
}
}

View file

@ -0,0 +1,97 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import {DropdownMenu, Icon} from '../../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { Table } from '../../lib/table';
import axios from '../../lib/axios';
import moment from 'moment';
import { getTemplateTypes } from './helpers';
@translate()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createMosaicoTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createMosaicoTemplate']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createMosaicoTemplate
});
}
componentDidMount() {
this.fetchPermissions();
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() },
{ data: 5, title: t('Namespace') },
{
actions: data => {
const actions = [];
const perms = data[6];
if (perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/templates/mosaico/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share-alt" title={t('Share')}/>,
link: `/templates/mosaico/${data[0]}/share`
});
}
return actions;
}
}
];
return (
<div>
{this.state.createPermitted &&
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Mosaico Template')}>
<DropdownLink to="/templates/mosaico/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/templates/mosaico/create/versafix">{t('Versafix One')}</DropdownLink>
</DropdownMenu>
</Toolbar>
}
<Title>{t('Mosaico Templates')}</Title>
<Table withHeader dataUrl="/rest/mosaico-templates-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,48 @@
'use strict';
import React from "react";
import {ACEEditor} from "../../lib/form";
import 'brace/mode/html'
import 'brace/mode/xml'
export function getTemplateTypes(t) {
const templateTypes = {};
function clearBeforeSend(data) {
delete data.html;
delete data.mjml;
}
templateTypes.html = {
typeName: t('HTML'),
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('Template content')}/>,
afterLoad: data => {
console.log(data);
data.html = data.data.html;
},
beforeSave: (data) => {
data.data = {
html: data.html
};
clearBeforeSend(data);
},
};
templateTypes.mjml = {
typeName: t('MJML'),
getForm: owner => <ACEEditor id="html" height="700px" mode="xml" label={t('Template content')}/>,
afterLoad: data => {
data.mjml = data.data.mjml;
},
beforeSave: (data) => {
data.data = {
mjml: data.mjml
};
clearBeforeSend(data);
},
};
return templateTypes;
}

View file

@ -6,6 +6,8 @@ import TemplatesCUD from './CUD';
import TemplatesList from './List'; import TemplatesList from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import Files from "../lib/files"; import Files from "../lib/files";
import MosaicoCUD from './mosaico/CUD';
import MosaicoList from './mosaico/List';
function getMenus(t) { function getMenus(t) {
@ -45,6 +47,45 @@ function getMenus(t) {
create: { create: {
title: t('Create'), title: t('Create'),
panelRender: props => <TemplatesCUD action="create" /> panelRender: props => <TemplatesCUD action="create" />
},
mosaico: {
title: t('Mosaico Templates'),
link: '/templates/mosaico',
panelComponent: MosaicoList,
children: {
':mosaiceTemplateId([0-9]+)': {
title: resolved => t('Mosaico Template "{{name}}"', {name: resolved.mosaicoTemplate.name}),
resolve: {
mosaicoTemplate: params => `/rest/mosaico-templates/${params.mosaiceTemplateId}`
},
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`,
visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'),
panelRender: props => <MosaicoCUD action={props.match.params.action} entity={props.resolved.mosaicoTemplate} />
},
files: {
title: t('Files'),
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/files`,
visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'),
panelRender: props => <Files title={t('Files')} entity={props.resolved.mosaicoTemplate} entityTypeId="mosaicoTemplate" />
},
share: {
title: t('Share'),
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/share`,
visible: resolved => resolved.mosaicoTemplate.permissions.includes('share'),
panelRender: props => <Share title={t('Share')} entity={props.resolved.mosaicoTemplate} entityTypeId="mosaicoTemplate" />
}
}
},
create: {
title: t('Create'),
extraParams: [':wizard?'],
panelRender: props => <MosaicoCUD action="create" wizard={props.match.params.wizard} />
}
}
} }
} }
} }

View file

@ -204,7 +204,7 @@ rootNamespaceRole="master"
[roles.namespace.master] [roles.namespace.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"] permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "manageUsers"]
[roles.namespace.master.children] [roles.namespace.master.children]
list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"]
@ -213,7 +213,8 @@ campaign=["view", "edit", "delete", "share", "manageFiles"]
template=["view", "edit", "delete", "share", "manageFiles"] template=["view", "edit", "delete", "share", "manageFiles"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
reportTemplate=["view", "edit", "delete", "share", "execute"] reportTemplate=["view", "edit", "delete", "share", "execute"]
namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"] mosaicoTemplate=["view", "edit", "delete", "share", "manageFiles"]
namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "manageUsers"]
[roles.list.master] [roles.list.master]
name="Master" name="Master"
@ -245,6 +246,10 @@ name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "execute"] permissions=["view", "edit", "delete", "share", "execute"]
[roles.mosaicoTemplate.master]
name="Master"
description="All permissions"
permissions=["view", "edit", "delete", "share", "manageFiles"]
[roles.global.editor] [roles.global.editor]
@ -297,3 +302,7 @@ name="Editor"
description="XXX" description="XXX"
permissions=[] permissions=[]
[roles.mosaicoTemplate.editor]
name="Editor"
description="All permissions"
permissions=[]

View file

@ -19,12 +19,14 @@ const entityTypes = {
campaign: { campaign: {
entitiesTable: 'campaigns', entitiesTable: 'campaigns',
sharesTable: 'shares_campaign', sharesTable: 'shares_campaign',
permissionsTable: 'permissions_campaign' permissionsTable: 'permissions_campaign',
filesTable: 'files_campaign'
}, },
template: { template: {
entitiesTable: 'templates', entitiesTable: 'templates',
sharesTable: 'shares_template', sharesTable: 'shares_template',
permissionsTable: 'permissions_template' permissionsTable: 'permissions_template',
filesTable: 'files_template'
}, },
report: { report: {
entitiesTable: 'reports', entitiesTable: 'reports',
@ -35,6 +37,12 @@ const entityTypes = {
entitiesTable: 'report_templates', entitiesTable: 'report_templates',
sharesTable: 'shares_report_template', sharesTable: 'shares_report_template',
permissionsTable: 'permissions_report_template' permissionsTable: 'permissions_report_template'
},
mosaicoTemplate: {
entitiesTable: 'mosaico_templates',
sharesTable: 'shares_mosaico_template',
permissionsTable: 'permissions_mosaico_template',
filesTable: 'files_mosaico_template'
} }
}; };

View file

@ -7,21 +7,26 @@ const shares = require('./shares');
const fs = require('fs-extra-promise'); const fs = require('fs-extra-promise');
const path = require('path'); const path = require('path');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const permissions = require('../lib/permissions');
const entityTypes = permissions.getEntityTypes();
const filesDir = path.join(__dirname, '..', 'files'); const filesDir = path.join(__dirname, '..', 'files');
const permittedTypes = new Set(['template']); function enforceTypePermitted(type) {
enforce(type in entityTypes && entityTypes[type].filesTable);
}
function getFilePath(type, entityId, filename) { function getFilePath(type, entityId, filename) {
return path.join(path.join(filesDir, type, entityId.toString()), filename); return path.join(path.join(filesDir, type, entityId.toString()), filename);
} }
function getFilesTable(type) { function getFilesTable(type) {
return 'files_' + type; return entityTypes[type].filesTable;
} }
async function listDTAjax(context, type, entityId, params) { async function listDTAjax(context, type, entityId, params) {
enforce(permittedTypes.has(type)); enforceTypePermitted(type);
await shares.enforceEntityPermission(context, type, entityId, 'manageFiles'); await shares.enforceEntityPermission(context, type, entityId, 'manageFiles');
return await dtHelpers.ajaxList( return await dtHelpers.ajaxList(
params, params,
@ -38,7 +43,7 @@ async function list(context, type, entityId) {
} }
async function getFileById(context, type, id) { async function getFileById(context, type, id) {
enforce(permittedTypes.has(type)); enforceTypePermitted(type);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).first(); const file = await tx(getFilesTable(type)).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles'); await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
@ -57,7 +62,7 @@ async function getFileById(context, type, id) {
} }
async function getFileByFilename(context, type, entityId, name) { async function getFileByFilename(context, type, entityId, name) {
enforce(permittedTypes.has(type)); enforceTypePermitted(type);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view'); await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view');
const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first(); const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first();
@ -76,7 +81,7 @@ async function getFileByFilename(context, type, entityId, name) {
} }
async function createFiles(context, type, entityId, files, dontReplace = false) { async function createFiles(context, type, entityId, files, dontReplace = false) {
enforce(permittedTypes.has(type)); enforceTypePermitted(type);
if (files.length == 0) { if (files.length == 0) {
// No files uploaded // No files uploaded
return {uploaded: 0}; return {uploaded: 0};

110
models/mosaico-templates.js Normal file
View file

@ -0,0 +1,110 @@
'use strict';
const knex = require('../lib/knex');
const hasher = require('node-object-hash')();
const { enforce, filterObject } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const namespaceHelpers = require('../lib/namespace-helpers');
const shares = require('./shares');
const allowedKeys = new Set(['name', 'description', 'type', 'data', 'namespace']);
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(context, id) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', id, 'view');
const entity = await tx('mosaico_templates').where('id', id).first();
entity.data = JSON.parse(entity.data);
entity.permissions = await shares.getPermissionsTx(tx, context, 'mosaicoTemplate', id);
return entity;
});
}
async function listDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'mosaicoTemplate', requiredOperations: ['view'] }],
params,
builder => builder.from('mosaico_templates').innerJoin('namespaces', 'namespaces.id', 'mosaico_templates.namespace'),
[ 'mosaico_templates.id', 'mosaico_templates.name', 'mosaico_templates.description', 'mosaico_templates.type', 'mosaico_templates.created', 'namespaces.name' ]
);
}
async function _validateAndPreprocess(tx, entity) {
entity.data = JSON.stringify(entity.data);
}
async function create(context, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createMosaicoTemplate');
await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
const ids = await tx('mosaico_templates').insert(filterObject(entity, allowedKeys));
const id = ids[0];
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'mosaicoTemplate', entityId: id });
return id;
});
}
async function updateWithConsistencyCheck(context, entity) {
await knex.transaction(async tx => {
await shares.enforceGlobalPermission(context, 'createJavascriptWithROAccess');
await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', entity.id, 'edit');
const existing = await tx('mosaico_templates').where('id', entity.id).first();
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
existing.data = JSON.parse(existing.data);
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'mosaicoTemplate', 'createMosaicoTemplate', 'delete');
await tx('mosaico_templates').where('id', entity.id).update(filterObject(entity, allowedKeys));
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'mosaicoTemplate', entityId: entity.id });
});
}
async function remove(context, id) {
await knex.transaction(async tx => {
const rows = await tx('templates').where('type', 'mosaico').select(['data']);
for (const row of rows) {
const data = JSON.parse(row.data);
if (data.template === id) {
throw new interoperableErrors.DependencyPresentError();
}
}
await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', id, 'delete');
await tx('mosaico_templates').where('id', id).del();
});
}
module.exports = {
hash,
getById,
listDTAjax,
create,
updateWithConsistencyCheck,
remove
};

View file

@ -36,7 +36,7 @@ async function listDTAjax(context, params) {
); );
} }
async function _validateAndPreprocess(tx, entity, isCreate) { async function _validateAndPreprocess(tx, entity) {
entity.data = JSON.stringify(entity.data); entity.data = JSON.stringify(entity.data);
} }
@ -44,7 +44,7 @@ async function create(context, entity) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
await _validateAndPreprocess(tx, entity, true); await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateEntity(tx, entity);
@ -73,7 +73,7 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }
await _validateAndPreprocess(tx, entity, false); await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete'); await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');

View file

@ -123,6 +123,7 @@ router.getAsync('/img/:type/:fileId', passport.loggedIn, async (req, res) => {
} else { } else {
width = sanitizeSize(width, 1, 2048, 600, false); width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true); height = sanitizeSize(height, 1, 2048, 300, true);
// TODO - validate that one has the rights to read this ???
image = await resizedImage(req.query.src, method, width, height); image = await resizedImage(req.query.src, method, width, height);
} }

View file

@ -0,0 +1,36 @@
'use strict';
const passport = require('../../lib/passport');
const mosaicoTemplates = require('../../models/mosaico-templates');
const router = require('../../lib/router-async').create();
router.getAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, async (req, res) => {
const mosaicoTemplate = await mosaicoTemplates.getById(req.context, req.params.mosaicoTemplateId);
mosaicoTemplate.hash = mosaicoTemplates.hash(mosaicoTemplate);
return res.json(mosaicoTemplate);
});
router.postAsync('/mosaico-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
return res.json(await mosaicoTemplates.create(req.context, req.body));
});
router.putAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const mosaicoTemplate = req.body;
mosaicoTemplate.id = parseInt(req.params.mosaicoTemplateId);
await mosaicoTemplates.updateWithConsistencyCheck(req.context, mosaicoTemplate);
return res.json();
});
router.deleteAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await mosaicoTemplates.remove(req.context, req.params.mosaicoTemplateId);
return res.json();
});
router.postAsync('/mosaico-templates-table', passport.loggedIn, async (req, res) => {
return res.json(await mosaicoTemplates.listDTAjax(req.context, req.body));
});
module.exports = router;

View file

@ -2,7 +2,7 @@
exports.up = (knex, Promise) => (async() => { exports.up = (knex, Promise) => (async() => {
await knex.schema.table('custom_fields', table => { await knex.schema.table('custom_fields', table => {
table.json('settings'); table.text('settings');
}); });
await knex.schema.table('custom_fields', table => { await knex.schema.table('custom_fields', table => {

View file

@ -2,7 +2,7 @@
exports.up = (knex, Promise) => (async() => { exports.up = (knex, Promise) => (async() => {
await knex.schema.table('segments', table => { await knex.schema.table('segments', table => {
table.json('settings'); table.text('settings');
}); });
await knex.schema.table('segments', table => { await knex.schema.table('segments', table => {

View file

@ -5,7 +5,7 @@ exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable(`files_${entityType}`, table => { await knex.schema.createTable(`files_${entityType}`, table => {
table.increments('id').primary(); table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('templates.id'); table.integer('entity').unsigned().notNullable().references(`${entityType}s.id`);
table.string('filename'); table.string('filename');
table.string('originalname'); table.string('originalname');
table.string('mimetype'); table.string('mimetype');

View file

@ -1,6 +1,6 @@
exports.up = (knex, Promise) => (async() => { exports.up = (knex, Promise) => (async() => {
await knex.schema.table('templates', table => { await knex.schema.table('templates', table => {
table.json('data'); table.text('data', 'longtext');
table.string('type'); table.string('type');
}); });

View file

@ -0,0 +1,61 @@
const mosaicoTemplates = require('../../../shared/mosaico-templates');
exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable('mosaico_templates', table => {
table.increments('id').primary();
table.string('name');
table.text('description');
table.string('type');
table.text('data', 'longtext');
table.timestamp('created').defaultTo(knex.fn.now());
table.integer('namespace').unsigned().references('namespaces.id');
});
await knex.schema.createTable(`shares_mosaico_template`, table => {
table.integer('entity').unsigned().notNullable().references(`mosaico_templates.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('role', 128).notNullable();
table.boolean('auto').defaultTo(false);
table.primary(['entity', 'user']);
});
await knex.schema.createTable(`permissions_mosaico_template`, table => {
table.integer('entity').unsigned().notNullable().references(`mosaico_templates.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('operation', 128).notNullable();
table.primary(['entity', 'user', 'operation']);
});
await knex.schema.createTable(`files_mosaico_template`, table => {
table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('mosaico_templates.id');
table.string('filename');
table.string('originalname');
table.string('mimetype');
table.string('encoding');
table.integer('size');
table.timestamp('created').defaultTo(knex.fn.now());
table.index(['entity', 'originalname'])
});
const versafix = {
name: 'Versafix One',
description: 'Default Mosaico Template',
type: 'html',
namespace: 1,
data: JSON.stringify({
html: mosaicoTemplates.versafix
})
};
await knex('mosaico_templates').insert(versafix);
})();
exports.down = (knex, Promise) => (async() => {
await knex.schema
.dropTable('shares_mosaico_template')
.dropTable('permissions_mosaico_template')
.dropTable('files_mosaico_template')
.dropTable('mosaico_templates')
;
})();

View file

@ -106,6 +106,13 @@ class InvalidConfirmationForUnsubscriptionError extends InteroperableError {
} }
} }
class DependencyPresentError extends InteroperableError {
constructor(msg, data) {
super('DependencyPresentError', msg, data);
}
}
const errorTypes = { const errorTypes = {
InteroperableError, InteroperableError,
NotLoggedInError, NotLoggedInError,
@ -123,7 +130,8 @@ const errorTypes = {
PermissionDeniedError, PermissionDeniedError,
InvalidConfirmationForSubscriptionError, InvalidConfirmationForSubscriptionError,
InvalidConfirmationForAddressChangeError, InvalidConfirmationForAddressChangeError,
InvalidConfirmationForUnsubscriptionError InvalidConfirmationForUnsubscriptionError,
DependencyPresentError
}; };
function deserialize(errorObj) { function deserialize(errorObj) {

1537
shared/mosaico-templates.js Normal file

File diff suppressed because it is too large Load diff