diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js index 82e4a82f..6a8e79ca 100644 --- a/client/src/campaigns/CUD.js +++ b/client/src/campaigns/CUD.js @@ -34,7 +34,6 @@ import { import {DeleteModalDialog} from "../lib/modals"; import mailtrainConfig from 'mailtrainConfig'; import { - getEditForm, getTemplateTypes, getTypeForm } from '../templates/helpers'; @@ -43,11 +42,13 @@ import styles from "../lib/styles.scss"; import {getUrl} from "../lib/urls"; import { CampaignSource, + CampaignStatus, CampaignType } from "../../../shared/campaigns"; import moment from 'moment'; import {getMailerTypes} from "../send-configurations/helpers"; import {ResourceType} from "../lib/mosaico"; +import {getCampaignTypeLabels} from "./helpers"; const overridables = ['from_name', 'from_email', 'reply_to', 'subject']; @@ -65,6 +66,8 @@ export default class CUD extends Component { this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN); this.mailerTypes = getMailerTypes(props.t); + this.campaignTypes = getCampaignTypeLabels(t); + this.createTitles = { [CampaignType.REGULAR]: t('Create Regular Campaign'), [CampaignType.RSS]: t('Create RSS Campaign'), @@ -79,7 +82,8 @@ export default class CUD extends Component { this.sourceLabels = { [CampaignSource.TEMPLATE]: t('Template'), - [CampaignSource.CUSTOM_FROM_TEMPLATE]: t('Custom content'), + [CampaignSource.CUSTOM_FROM_TEMPLATE]: t('Custom content cloned from template'), + [CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('Custom content cloned from another campaign'), [CampaignSource.CUSTOM]: t('Custom content'), [CampaignSource.URL]: t('URL') }; @@ -95,8 +99,6 @@ export default class CUD extends Component { } this.state = { - showMergeTagReference: false, - elementInFullscreen: false, sendConfiguration: null }; @@ -112,7 +114,6 @@ export default class CUD extends Component { static propTypes = { action: PropTypes.string.isRequired, - wizard: PropTypes.string, entity: PropTypes.object, type: PropTypes.number } @@ -144,37 +145,17 @@ export default class CUD extends Component { componentDidMount() { if (this.props.entity) { this.getFormValuesFromEntity(this.props.entity, data => { - if (data.source === CampaignSource.TEMPLATE || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { + // The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources + if (data.source === CampaignSource.TEMPLATE) { data.data_sourceTemplate = data.data.sourceTemplate; - } else { - data.data_sourceTemplate = null; - } - - if (data.source === CampaignSource.CUSTOM || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { - data.data_sourceCustom_type = data.data.sourceCustom.type; - data.data_sourceCustom_data = data.data.sourceCustom.data; - data.data_sourceCustom_html = data.data.sourceCustom.html; - data.data_sourceCustom_text = data.data.sourceCustom.text; - - this.templateTypes[data.data.sourceCustom.type].afterLoad(data); - - } else { - data.data_sourceCustom_type = null; - data.data_sourceCustom_data = {}; - data.data_sourceCustom_html = ''; - data.data_sourceCustom_text = ''; } if (data.source === CampaignSource.URL) { data.data_sourceUrl = data.data.sourceUrl; - } else { - data.data_sourceUrl = null; } if (data.type === CampaignType.RSS) { data.data_feedUrl = data.data.feedUrl; - } else { - data.data_feedUrl = ''; } data.useSegmentation = !!data.segment; @@ -211,11 +192,14 @@ export default class CUD extends Component { source: CampaignSource.TEMPLATE, - // This is for CampaignSource.TEMPLATE + // This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE data_sourceTemplate: null, + // This is for CampaignSource.CUSTOM_FROM_CAMPAIGN + data_sourceCampaign: null, + // This is for CampaignSource.CUSTOM - data_sourceCustom_type: null, + data_sourceCustom_type: mailtrainConfig.editors[0], data_sourceCustom_data: {}, data_sourceCustom_html: '', data_sourceCustom_text: '', @@ -252,6 +236,12 @@ export default class CUD extends Component { state.setIn(['segment', 'error'], null); } + if (!state.getIn(['send_configuration', 'value'])) { + state.setIn(['send_configuration', 'error'], t('Send configuration must be selected')); + } else { + state.setIn(['send_configuration', 'error'], null); + } + if (state.getIn(['from_email_overriden', 'value']) && !state.getIn(['from_email_override', 'value'])) { state.setIn(['from_email_override', 'error'], t('"From" email must not be empty')); } else { @@ -261,22 +251,29 @@ export default class CUD extends Component { const campaignTypeKey = state.getIn(['type', 'value']); - const sourceTypeKey = state.getIn(['source', 'value']); + const sourceTypeKey = Number.parseInt(state.getIn(['source', 'value'])); + + for (const key of state.keys()) { + if (key.startsWith('data_')) { + state.setIn([key, 'error'], null); + } + } if (sourceTypeKey === CampaignSource.TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) { if (!state.getIn(['data_sourceTemplate', 'value'])) { state.setIn(['data_sourceTemplate', 'error'], t('Template must be selected')); - } else { - state.setIn(['data_sourceTemplate', 'error'], null); + } + + } else if (sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) { + if (!state.getIn(['data_sourceCampaign', 'value'])) { + state.setIn(['data_sourceCampaign', 'error'], t('Campaign must be selected')); } } else if (sourceTypeKey === CampaignSource.CUSTOM) { - // The type is used only in create form. In case of CUSTOM_FROM_TEMPLATE, it is determined by the source template, so no need to check it here + // The type is used only in create form. In case of CUSTOM_FROM_TEMPLATE or CUSTOM_FROM_CAMPAIGN, it is determined by the source template, so no need to check it here const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']); if (!customTemplateTypeKey) { state.setIn(['data_sourceCustom_type', 'error'], t('Type must be selected')); - } else { - state.setIn(['data_sourceCustom_type', 'error'], null); } if (customTemplateTypeKey) { @@ -286,16 +283,12 @@ export default class CUD extends Component { } else if (sourceTypeKey === CampaignSource.URL) { if (!state.getIn(['data_sourceUrl', 'value'])) { state.setIn(['data_sourceUrl', 'error'], t('URL must not be empty')); - } else { - state.setIn(['data_sourceUrl', 'error'], null); } } if (campaignTypeKey === CampaignType.RSS) { if (!state.getIn(['data_feedUrl', 'value'])) { state.setIn(['data_feedUrl', 'error'], t('RSS feed URL must be given')); - } else { - state.setIn(['data_feedUrl', 'error'], null); } } @@ -306,14 +299,6 @@ export default class CUD extends Component { async submitHandler() { const t = this.props.t; - if (this.props.entity) { - const sourceTypeKey = this.getFormValue('source'); - if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) { - const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); - await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this); - } - } - let sendMethod, url; if (this.props.entity) { sendMethod = FormSendMethod.PUT; @@ -337,7 +322,11 @@ export default class CUD extends Component { data.data.sourceTemplate = data.data_sourceTemplate; } - if (data.source === CampaignSource.CUSTOM || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { + if (data.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) { + data.data.sourceCampaign = data.data_sourceCampaign; + } + + if (data.source === CampaignSource.CUSTOM) { this.templateTypes[data.data_sourceCustom_type].beforeSave(data); data.data.sourceCustom = { @@ -382,116 +371,18 @@ export default class CUD extends Component { } } - async extractPlainText() { - const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); - await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this); - - const html = this.getFormValue('data_sourceCustom_html'); - if (!html) { - return; - } - - if (this.isFormDisabled()) { - return; - } - - this.disableForm(); - - console.log(html); - - const response = await axios.post(getUrl('rest/html-to-text', { html })); - - this.updateFormValue('data_sourceCustom_text', response.data.text); - - this.enableForm(); - } - - async toggleMergeTagReference() { - this.setState({ - showMergeTagReference: !this.state.showMergeTagReference - }); - } - - async setElementInFullscreen(elementInFullscreen) { - this.setState({ - elementInFullscreen - }); - } - render() { const t = this.props.t; const isEdit = !!this.props.entity; const canDelete = isEdit && this.props.entity.permissions.includes('delete'); - let templateEdit = null; let extraSettings = null; - const sourceTypeKey = this.getFormValue('source'); + const sourceTypeKey = Number.parseInt(this.getFormValue('source')); const campaignTypeKey = this.getFormValue('type'); - - let sourceEdit; - if (isEdit) { - sourceEdit = {this.sourceLabels[sourceTypeKey]}; - } else { - sourceEdit = - } - - - if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) { - const templatesColumns = [ - { 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') }, - ]; - - let help = null; - if (sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) { - help = t('Selecting a template creates a campaign specific copy from it.'); - } - - templateEdit = ; - - } else if (sourceTypeKey === CampaignSource.CUSTOM || (isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) { - // TODO: Toggle HTML preview - - const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); - - let customTemplateEditForm = null; - let customTemplateTypeForm = null; - - if (customTemplateTypeKey) { - customTemplateTypeForm = getTypeForm(this, customTemplateTypeKey, isEdit); - - if (isEdit) { - customTemplateEditForm = getEditForm(this, customTemplateTypeKey); - } - } - - templateEdit =
- {isEdit - ? - - {customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName} - - : - - } - - {customTemplateTypeForm} - - {customTemplateEditForm} -
; - - } else if (sourceTypeKey === CampaignSource.URL) { - templateEdit = - } - if (campaignTypeKey === CampaignType.RSS) { extraSettings = - } const listsColumns = [ @@ -545,8 +436,64 @@ export default class CUD extends Component { } + let sourceEdit = null; + if (isEdit) { + if (!(sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN)) { + sourceEdit = {this.sourceLabels[sourceTypeKey]}; + } + } else { + sourceEdit = + } + + let templateEdit = null; + if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) { + const templatesColumns = [ + { 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') }, + ]; + + let help = null; + if (sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) { + help = t('Selecting a template creates a campaign specific copy from it.'); + } + + templateEdit = ; + + } else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) { + const campaignsColumns = [ + { data: 1, title: t('Name') }, + { data: 2, title: t('Description') }, + { data: 3, title: t('Type'), render: data => this.campaignTypes[data] }, + { data: 7, title: t('Created'), render: data => moment(data).fromNow() }, + { data: 8, title: t('Namespace') } + ]; + + templateEdit = ; + + } else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM) { + const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); + + let customTemplateTypeForm = null; + + if (customTemplateTypeKey) { + customTemplateTypeForm = getTypeForm(this, customTemplateTypeKey, isEdit); + } + + templateEdit =
+ + {customTemplateTypeForm} +
; + + } else if (sourceTypeKey === CampaignSource.URL) { + templateEdit = + } + + return ( -
+
{canDelete && -
+ {sourceEdit &&
} {sourceEdit} diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js new file mode 100644 index 00000000..135fdf4f --- /dev/null +++ b/client/src/campaigns/Content.js @@ -0,0 +1,195 @@ +'use strict'; + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {translate} from 'react-i18next'; +import { + requiresAuthenticatedUser, + Title, + withPageHelpers +} from '../lib/page' +import { + Button, + ButtonRow, + Form, + FormSendMethod, + StaticField, + withForm +} from '../lib/form'; +import {withErrorHandling} from '../lib/error-handling'; +import mailtrainConfig from 'mailtrainConfig'; +import { + getEditForm, + getTemplateTypes, + getTypeForm +} from '../templates/helpers'; +import axios from '../lib/axios'; +import styles from "../lib/styles.scss"; +import {getUrl} from "../lib/urls"; +import {ResourceType} from "../lib/mosaico"; + +const overridables = ['from_name', 'from_email', 'reply_to', 'subject']; + +@translate() +@withForm +@withPageHelpers +@withErrorHandling +@requiresAuthenticatedUser +export default class CustomContent extends Component { + constructor(props) { + super(props); + + const t = props.t; + + console.log(props); + this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN); + + this.customTemplateTypeOptions = []; + for (const key of mailtrainConfig.editors) { + this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName}); + } + + this.state = { + showMergeTagReference: false, + elementInFullscreen: false + }; + + this.initForm(); + } + + static propTypes = { + entity: PropTypes.object + } + + componentDidMount() { + this.getFormValuesFromEntity(this.props.entity, data => { + data.data_sourceCustom_type = data.data.sourceCustom.type; + data.data_sourceCustom_data = data.data.sourceCustom.data; + data.data_sourceCustom_html = data.data.sourceCustom.html; + data.data_sourceCustom_text = data.data.sourceCustom.text; + + this.templateTypes[data.data.sourceCustom.type].afterLoad(data); + }); + } + + localValidateFormValues(state) { + const t = this.props.t; + + const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']); + + if (customTemplateTypeKey) { + this.templateTypes[customTemplateTypeKey].validate(state); + } + } + + async submitHandler() { + const t = this.props.t; + + const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); + await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this); + + let sendMethod, url; + if (this.props.entity) { + sendMethod = FormSendMethod.PUT; + url = `rest/campaigns/${this.props.entity.id}` + } else { + sendMethod = FormSendMethod.POST; + url = 'rest/campaigns' + } + + this.disableForm(); + this.setFormStatusMessage('info', t('Saving ...')); + + const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { + this.templateTypes[data.data_sourceCustom_type].beforeSave(data); + + data.data.sourceCustom = { + type: data.data_sourceCustom_type, + data: data.data_sourceCustom_data, + html: data.data_sourceCustom_html, + text: data.data_sourceCustom_text + }; + + for (const key in data) { + if (key.startsWith('data_')) { + delete data[key]; + } + } + }); + + if (submitResponse) { + if (this.props.entity) { + this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign saved')); + } else { + this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/edit`, 'success', t('Campaign saved')); + } + } else { + this.enableForm(); + this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); + } + } + + async extractPlainText() { + const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); + await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this); + + const html = this.getFormValue('data_sourceCustom_html'); + if (!html) { + return; + } + + if (this.isFormDisabled()) { + return; + } + + this.disableForm(); + + const response = await axios.post(getUrl('rest/html-to-text', { html })); + + this.updateFormValue('data_sourceCustom_text', response.data.text); + + this.enableForm(); + } + + async toggleMergeTagReference() { + this.setState({ + showMergeTagReference: !this.state.showMergeTagReference + }); + } + + async setElementInFullscreen(elementInFullscreen) { + this.setState({ + elementInFullscreen + }); + } + + render() { + const t = this.props.t; + + // TODO: Toggle HTML preview + + const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); + + // FIXME - data_sourceCustom_type is initialized only after first render + + return ( +
+ {t('Edit Custom Content')} + +
+ + {customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName} + + + {customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)} + + {customTemplateTypeKey && getEditForm(this, customTemplateTypeKey)} + + +
+ ); + } +} diff --git a/client/src/campaigns/List.js b/client/src/campaigns/List.js index 5518fbdb..245811d1 100644 --- a/client/src/campaigns/List.js +++ b/client/src/campaigns/List.js @@ -25,6 +25,7 @@ import { CampaignType } from "../../../shared/campaigns"; import {checkPermissions} from "../lib/permissions"; +import {getCampaignTypeLabels} from "./helpers"; @translate() @withPageHelpers @@ -44,11 +45,7 @@ export default class List extends Component { [CampaignStatus.ACTIVE]: t('Active') }; - this.campaignTypes = { - [CampaignType.REGULAR]: t('Regular'), - [CampaignType.TRIGGERED]: t('Triggered'), - [CampaignType.RSS]: t('RSS') - }; + this.campaignTypes = getCampaignTypeLabels(t); this.state = {}; } @@ -109,14 +106,26 @@ export default class List extends Component { }); } - if (perms.includes('manageFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE)) { + if (perms.includes('edit') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) { + actions.push({ + label: , + link: `/campaigns/${data[0]}/content` + }); + } + + if (perms.includes('viewFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) { actions.push({ label: , link: `/campaigns/${data[0]}/files` }); } - // FIXME: add attachments + if (perms.includes('viewAttachments')) { + actions.push({ + label: , + link: `/campaigns/${data[0]}/attachments` + }); + } if (perms.includes('share')) { actions.push({ diff --git a/client/src/campaigns/helpers.js b/client/src/campaigns/helpers.js new file mode 100644 index 00000000..c539da46 --- /dev/null +++ b/client/src/campaigns/helpers.js @@ -0,0 +1,14 @@ +'use strict'; + +import {CampaignType} from "../../../shared/campaigns"; + +export function getCampaignTypeLabels(t) { + + const campaignTypeLabels = { + [CampaignType.REGULAR]: t('Regular'), + [CampaignType.TRIGGERED]: t('Triggered'), + [CampaignType.RSS]: t('RSS') + }; + + return campaignTypeLabels; +} diff --git a/client/src/campaigns/root.js b/client/src/campaigns/root.js index 90d3839f..9c84361a 100644 --- a/client/src/campaigns/root.js +++ b/client/src/campaigns/root.js @@ -3,6 +3,7 @@ import React from 'react'; import CampaignsCUD from './CUD'; +import Content from './Content'; import CampaignsList from './List'; import Share from '../shares/Share'; import Files from "../lib/files"; @@ -29,13 +30,24 @@ function getMenus(t) { visible: resolved => resolved.campaign.permissions.includes('edit'), panelRender: props => }, + content: { + title: t('Content'), + link: params => `/campaigns/${params.campaignId}/content`, + visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN), + panelRender: props => + }, files: { title: t('Files'), link: params => `/campaigns/${params.campaignId}/files`, - visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE), - panelRender: props => + visible: resolved => resolved.campaign.permissions.includes('viewFiles') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN), + panelRender: props => + }, + attachments: { + title: t('Attachments'), + link: params => `/campaigns/${params.campaignId}/attachments`, + visible: resolved => resolved.campaign.permissions.includes('viewAttachments'), + panelRender: props => }, - // FIXME: add attachments share: { title: t('Share'), link: params => `/campaigns/${params.campaignId}/share`, diff --git a/client/src/lib/files.js b/client/src/lib/files.js index 93ed0722..714060bf 100644 --- a/client/src/lib/files.js +++ b/client/src/lib/files.js @@ -32,8 +32,10 @@ export default class Files extends Component { static propTypes = { title: PropTypes.string, - entity: PropTypes.object, - entityTypeId: PropTypes.string, + entity: PropTypes.object.isRequired, + entityTypeId: PropTypes.string.isRequired, + entitySubTypeId: PropTypes.string.isRequired, + managePermission: PropTypes.string.isRequired, usePublicDownloadUrls: PropTypes.bool } @@ -65,7 +67,7 @@ export default class Files extends Component { for (const file of files) { data.append('files[]', file) } - axios.post(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entity.id}`), data) + axios.post(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}`), data) .then(res => { this.filesTable.refresh(); const message = this.getFilesUploadedMessage(res); @@ -93,7 +95,7 @@ export default class Files extends Component { try { this.setFlashMessage('info', t('Deleting file ...')); - await axios.delete(getUrl(`rest/files/${this.props.entityTypeId}/${fileToDeleteId}`)); + await axios.delete(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${fileToDeleteId}`)); this.filesTable.refresh(); this.setFlashMessage('info', t('File deleted')); } catch (err) { @@ -110,24 +112,26 @@ export default class Files extends Component { { data: 3, title: "Size" }, { actions: data => { + const actions = []; let downloadUrl; if (this.props.usePublicDownloadUrls) { - downloadUrl =`/files/${this.props.entityTypeId}/${this.props.entity.id}/${data[2]}`; + downloadUrl =`/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}/${data[2]}`; } else { - downloadUrl =`rest/files/${this.props.entityTypeId}/${data[0]}`; + downloadUrl =`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${data[0]}`; } - const actions = [ - { - label: , - href: downloadUrl - }, - { + actions.push({ + label: , + href: downloadUrl + }); + + if (this.props.entity.permissions.includes(this.props.managePermission)) { + actions.push({ label: , action: () => this.deleteFile(data[0], data[1]) - } - ]; + }); + } return actions; } @@ -146,10 +150,15 @@ export default class Files extends Component { ]}> {t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})} - - {state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')} - - this.filesTable = node} dataUrl={`rest/files-table/${this.props.entityTypeId}/${this.props.entity.id}`} columns={columns} /> + + { + this.props.entity.permissions.includes(this.props.managePermission) && + + {state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')} + + } + +
this.filesTable = node} dataUrl={`rest/files-table/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}`} columns={columns} /> ); } diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss index 07427dac..160e7afb 100644 --- a/client/src/lib/styles.scss +++ b/client/src/lib/styles.scss @@ -26,6 +26,10 @@ text-align: right; } +.buttonRow { + margin-top: 15px; +} + .buttonRow > * { margin-right: 15px; } diff --git a/client/src/templates/List.js b/client/src/templates/List.js index 77635baf..495c964f 100644 --- a/client/src/templates/List.js +++ b/client/src/templates/List.js @@ -80,7 +80,7 @@ export default class List extends Component { }); } - if (perms.includes('manageFiles')) { + if (perms.includes('viewFiles')) { actions.push({ label: , link: `/templates/${data[0]}/files` diff --git a/client/src/templates/helpers.js b/client/src/templates/helpers.js index 063c3d06..3b1da84b 100644 --- a/client/src/templates/helpers.js +++ b/client/src/templates/helpers.js @@ -323,8 +323,6 @@ export function getEditForm(owner, typeKey, prefix = '') { } export function getTypeForm(owner, typeKey, isEdit) { - return
- {owner.templateTypes[typeKey].getTypeForm(owner, isEdit)} -
; + return owner.templateTypes[typeKey].getTypeForm(owner, isEdit); } diff --git a/client/src/templates/mosaico/List.js b/client/src/templates/mosaico/List.js index 2760b83c..19fd397d 100644 --- a/client/src/templates/mosaico/List.js +++ b/client/src/templates/mosaico/List.js @@ -64,6 +64,20 @@ export default class List extends Component { }); } + if (perms.includes('viewFiles')) { + actions.push({ + label: , + link: `/templates/mosaico/${data[0]}/files` + }); + } + + if (perms.includes('viewFiles')) { + actions.push({ + label: , + link: `/templates/mosaico/${data[0]}/blocks` + }); + } + if (perms.includes('share')) { actions.push({ label: , diff --git a/client/src/templates/root.js b/client/src/templates/root.js index 04a16eaf..70f86abe 100644 --- a/client/src/templates/root.js +++ b/client/src/templates/root.js @@ -33,8 +33,8 @@ function getMenus(t) { files: { title: t('Files'), link: params => `/templates/${params.templateId}/files`, - visible: resolved => resolved.template.permissions.includes('edit'), - panelRender: props => + visible: resolved => resolved.template.permissions.includes('viewFiles'), + panelRender: props => }, share: { title: t('Share'), @@ -69,8 +69,14 @@ function getMenus(t) { files: { title: t('Files'), link: params => `/templates/mosaico/${params.mosaiceTemplateId}/files`, - visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'), - panelRender: props => + visible: resolved => resolved.mosaicoTemplate.permissions.includes('viewFiles'), + panelRender: props => + }, + blocks: { + title: t('Block thumbnails'), + link: params => `/templates/mosaico/${params.mosaiceTemplateId}/blocks`, + visible: resolved => resolved.mosaicoTemplate.permissions.includes('viewFiles'), + panelRender: props => }, share: { title: t('Share'), diff --git a/lib/file-helpers.js b/lib/file-helpers.js index 156c0c67..48b4c3c3 100644 --- a/lib/file-helpers.js +++ b/lib/file-helpers.js @@ -10,9 +10,9 @@ const multer = require('multer')({ dest: uploadedFilesDir }); -function installUploadHandler(router, url, getUrl = null, dontReplace = false) { +function installUploadHandler(router, url, replacementBehavior, type = null, subType = null) { router.postAsync(url, passport.loggedIn, multer.array('files[]'), async (req, res) => { - return res.json(await files.createFiles(req.context, req.params.type, req.params.entityId, req.files, getUrl, dontReplace)); + return res.json(await files.createFiles(req.context, type || req.params.type, subType || req.params.subType, req.params.entityId, req.files, replacementBehavior)); }); } diff --git a/lib/permissions.js b/lib/permissions.js index 28af538b..0c00209b 100644 --- a/lib/permissions.js +++ b/lib/permissions.js @@ -20,13 +20,36 @@ const entityTypes = { entitiesTable: 'campaigns', sharesTable: 'shares_campaign', permissionsTable: 'permissions_campaign', - filesTable: 'files_campaign' + files: { + file: { + table: 'files_campaign_file', + permissions: { + view: 'viewFiles', + manage: 'manageFiles' + } + }, + attachment: { + table: 'files_campaign_attachment', + permissions: { + view: 'viewAttachments', + manage: 'manageAttachments' + } + } + } }, template: { entitiesTable: 'templates', sharesTable: 'shares_template', permissionsTable: 'permissions_template', - filesTable: 'files_template' + files: { + file: { + table: 'files_template_file', + permissions: { + view: 'viewFiles', + manage: 'manageFiles' + } + } + } }, sendConfiguration: { entitiesTable: 'send_configurations', @@ -47,7 +70,22 @@ const entityTypes = { entitiesTable: 'mosaico_templates', sharesTable: 'shares_mosaico_template', permissionsTable: 'permissions_mosaico_template', - filesTable: 'files_mosaico_template' + files: { + file: { + table: 'files_mosaico_template_file', + permissions: { + view: 'viewFiles', + manage: 'manageFiles' + } + }, + block: { + table: 'files_mosaico_template_block', + permissions: { + view: 'viewFiles', + manage: 'manageFiles' + } + } + } } }; diff --git a/lib/report-processor.js b/lib/report-processor.js index 0bee35c8..67bda8f2 100644 --- a/lib/report-processor.js +++ b/lib/report-processor.js @@ -104,7 +104,7 @@ module.exports.start = async (reportId) => { if (!workers[reportId]) { log.info('ReportProcessor', 'Scheduling report id: %s', reportId); await reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, last_run: null}); - tryStartWorkers(); + await tryStartWorkers(); } else { log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId); } @@ -125,7 +125,7 @@ module.exports.stop = async reportId => { module.exports.init = async () => { try { await reports.bulkChangeState(reports.ReportState.PROCESSING, reports.ReportState.SCHEDULED); - tryStartWorkers(); + await tryStartWorkers(); } catch (err) { log.error('ReportProcessor', err); } diff --git a/models/campaigns.js b/models/campaigns.js index 7567235e..563a38fd 100644 --- a/models/campaigns.js +++ b/models/campaigns.js @@ -9,6 +9,7 @@ const { enforce, filterObject } = require('../lib/helpers'); const shares = require('./shares'); const namespaceHelpers = require('../lib/namespace-helpers'); const files = require('./files'); +const templates = require('./templates'); const { CampaignSource, CampaignType} = require('../shared/campaigns'); const segments = require('./segments'); @@ -33,16 +34,22 @@ async function listDTAjax(context, params) { ); } -async function getById(context, id) { - return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'view'); - const entity = await tx('campaigns').where('id', id).first(); +async function getByIdTx(tx, context, id, withPermissions = true) { + await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'view'); + const entity = await tx('campaigns').where('id', id).first(); + if (withPermissions) { entity.permissions = await shares.getPermissionsTx(tx, context, 'campaign', id); + } - entity.data = JSON.parse(entity.data); + entity.data = JSON.parse(entity.data); - return entity; + return entity; +} + +async function getById(context, id, withPermissions = true) { + return await knex.transaction(async tx => { + return await getByIdTx(tx, context, id, withPermissions); }); } @@ -50,7 +57,7 @@ async function _validateAndPreprocess(tx, context, entity, isCreate) { await namespaceHelpers.validateEntity(tx, entity); if (isCreate) { - enforce(entity.type === CampaignType.REGULAR && entity.type === CampaignType.RSS && entity.type === CampaignType.TRIGGERED, 'Unknown campaign type'); + enforce(entity.type === CampaignType.REGULAR || entity.type === CampaignType.RSS || entity.type === CampaignType.TRIGGERED, 'Unknown campaign type'); if (entity.source === CampaignSource.TEMPLATE || entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { await shares.enforceEntityPermissionTx(tx, context, 'template', entity.data.sourceTemplate, 'view'); @@ -66,6 +73,8 @@ async function _validateAndPreprocess(tx, context, entity, isCreate) { await segments.getByIdTx(tx, context, entity.list, entity.segment); } + await shares.enforceEntityPermissionTx(tx, context, 'send_configuration', entity.send_configuration, 'viewPublic'); + entity.data = JSON.stringify(entity.data); } @@ -73,9 +82,30 @@ async function create(context, entity) { return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign'); - let copyFilesFromTemplateId; + let copyFilesFrom = null; if (entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { - copyFilesFromTemplateId = entity.data.sourceTemplate; + copyFilesFrom = { + entityType: 'template', + entityId: entity.data.sourceTemplate + }; + + const template = await templates.getByIdTx(tx, context, entity.data.sourceTemplate, false); + + entity.data.sourceCustom = { + type: template.type, + data: template.data, + html: template.html, + text: template.text + }; + } else if (entity.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) { + copyFilesFrom = { + entityType: 'campaign', + entityId: entity.data.sourceCampaign + }; + + const sourceCampaign = await getByIdTx(tx, context, entity.data.sourceCampaign, false); + + entity.data.sourceCustom = sourceCampaign.data.sourceCustom; } await _validateAndPreprocess(tx, context, entity, true); @@ -119,8 +149,8 @@ async function create(context, entity) { await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id }); - if (copyFilesFromTemplateId) { - files.copyAllTx(tx, context, 'template', copyFilesFromTemplateId, 'campaign', id); + if (copyFilesFrom) { + await files.copyAllTx(tx, context, copyFilesFrom.entityType, copyFilesFrom.entityId, 'campaign', id); } return id; @@ -168,6 +198,7 @@ async function remove(context, id) { module.exports = { hash, listDTAjax, + getByIdTx, getById, create, updateWithConsistencyCheck, diff --git a/models/files.js b/models/files.js index 0e6c80e8..1978ebc1 100644 --- a/models/files.js +++ b/models/files.js @@ -10,49 +10,63 @@ const interoperableErrors = require('../shared/interoperable-errors'); const permissions = require('../lib/permissions'); const {getTrustedUrl} = require('../lib/urls'); +const crypto = require('crypto'); +const bluebird = require('bluebird'); +const cryptoPseudoRandomBytes = bluebird.promisify(crypto.pseudoRandomBytes); + const entityTypes = permissions.getEntityTypes(); const filesDir = path.join(__dirname, '..', 'files'); -function enforceTypePermitted(type) { - enforce(type in entityTypes && entityTypes[type].filesTable); +const ReplacementBehavior = { + NONE: 0, + REPLACE: 1, + RENAME: 2 +}; + +function enforceTypePermitted(type, subType) { + enforce(type in entityTypes && entityTypes[type].files && entityTypes[type].files[subType]); } -function getFilePath(type, entityId, filename) { - return path.join(path.join(filesDir, type, entityId.toString()), filename); +function getFilePath(type, subType, entityId, filename) { + return path.join(path.join(filesDir, type, subType, entityId.toString()), filename); } -function getFileUrl(context, type, entityId, filename) { - return getTrustedUrl(`files/${type}/${entityId}/${filename}`, context) +function getFileUrl(context, type, subType, entityId, filename) { + return getTrustedUrl(`files/${type}/${subType}/${entityId}/${filename}`, context) } -function getFilesTable(type) { - return entityTypes[type].filesTable; +function getFilesTable(type, subType) { + return entityTypes[type].files[subType].table; } -async function listDTAjax(context, type, entityId, params) { - enforceTypePermitted(type); - await shares.enforceEntityPermission(context, type, entityId, 'viewFiles'); +function getFilesPermission(type, subType, operation) { + return entityTypes[type].files[subType].permissions[operation]; +} + +async function listDTAjax(context, type, subType, entityId, params) { + enforceTypePermitted(type, subType); + await shares.enforceEntityPermission(context, type, entityId, getFilesPermission(type, subType, 'view')); return await dtHelpers.ajaxList( params, - builder => builder.from(getFilesTable(type)).where({entity: entityId}), + builder => builder.from(getFilesTable(type, subType)).where({entity: entityId}), ['id', 'originalname', 'filename', 'size', 'created'] ); } -async function list(context, type, entityId) { - enforceTypePermitted(type); +async function list(context, type, subType, entityId) { + enforceTypePermitted(type, subType); return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'viewFiles'); - return await tx(getFilesTable(type)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc'); + await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view')); + return await tx(getFilesTable(type, subType)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc'); }); } -async function getFileById(context, type, id) { - enforceTypePermitted(type); +async function getFileById(context, type, subType, id) { + enforceTypePermitted(type, subType); const file = await knex.transaction(async tx => { - const file = await tx(getFilesTable(type)).where('id', id).first(); - await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'viewFiles'); + const file = await tx(getFilesTable(type, subType)).where('id', id).first(); + await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'view')); return file; }); @@ -63,15 +77,15 @@ async function getFileById(context, type, id) { return { mimetype: file.mimetype, name: file.originalname, - path: getFilePath(type, file.entity, file.filename) + path: getFilePath(type, subType, file.entity, file.filename) }; } -async function _getFileBy(context, type, entityId, key, value) { - enforceTypePermitted(type); +async function _getFileBy(context, type, subType, entityId, key, value) { + enforceTypePermitted(type, subType); const file = await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'viewFiles'); - const file = await tx(getFilesTable(type)).where({entity: entityId, [key]: value}).first(); + await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view')); + const file = await tx(getFilesTable(type, subType)).where({entity: entityId, [key]: value}).first(); return file; }); @@ -82,30 +96,31 @@ async function _getFileBy(context, type, entityId, key, value) { return { mimetype: file.mimetype, name: file.originalname, - path: getFilePath(type, file.entity, file.filename) + path: getFilePath(type, subType, file.entity, file.filename) }; } -async function getFileByOriginalName(context, type, entityId, name) { - return await _getFileBy(context, type, entityId, 'originalname', name) +async function getFileByOriginalName(context, type, subType, entityId, name) { + return await _getFileBy(context, type, subType, entityId, 'originalname', name) } -async function getFileByFilename(context, type, entityId, name) { - return await _getFileBy(context, type, entityId, 'filename', name) +async function getFileByFilename(context, type, subType, entityId, name) { + return await _getFileBy(context, type, subType, entityId, 'filename', name) } -async function getFileByUrl(context, type, entityId, url) { - const urlPrefix = getTrustedUrl(`files/${type}/${entityId}/`, context); +async function getFileByUrl(context, type, subType, entityId, url) { + const urlPrefix = getTrustedUrl(`files/${type}/${subType}/${entityId}/`, context); if (url.startsWith(urlPrefix)) { const name = url.substring(urlPrefix.length); - return await getFileByFilename(context, type, entityId, name); + return await getFileByFilename(context, type, subType, entityId, name); } else { throw new interoperableErrors.NotFoundError(); } } -async function createFiles(context, type, entityId, files, getUrl = null, dontReplace = false) { - enforceTypePermitted(type); +// Adds files to an entity. The source data can be either a file (then it's path is contained in file.path) or in-memory data (then it's content is in file.data). +async function createFiles(context, type, subType, entityId, files, replacementBehavior) { + enforceTypePermitted(type, subType); if (files.length == 0) { // No files uploaded return {uploaded: 0}; @@ -118,31 +133,39 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe const filesRet = []; await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'manageFiles'); + await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'manage')); - const existingNamesRows = await tx(getFilesTable(type)).where('entity', entityId).select(['filename', 'originalname']); - const existingNameMap = new Map(); + const existingNamesRows = await tx(getFilesTable(type, subType)).where('entity', entityId).select(['id', 'filename', 'originalname']); + + const existingNameSet = new Set(); for (const row of existingNamesRows) { - existingNameMap.set(row.originalname, row); + existingNameSet.add(row.originalname); } - const originalNameSet = new Set(); + // The processedNameSet holds originalnames of entries which have been already processed in the upload batch. It prevents uploading two files with the same originalname + const processedNameSet = new Set(); + // Create entities for files for (const file of files) { const parsedOriginalName = path.parse(file.originalname); let originalName = parsedOriginalName.base; - if (dontReplace) { + if (!file.filename) { + // This is taken from multer/storage/disk.js and adapted for async/await + file.filename = (await cryptoPseudoRandomBytes(16)).toString('hex'); + } + + if (replacementBehavior === ReplacementBehavior.RENAME) { let suffix = 1; - while (existingNameMap.has(originalName) || originalNameSet.has(originalName)) { + while (existingNameSet.has(originalName) || processedNameSet.has(originalName)) { originalName = parsedOriginalName.name + '-' + suffix + parsedOriginalName.ext; suffix++; } } - if (originalNameSet.has(originalName)) { - // The file has an original name same as another file + if (replacementBehavior === ReplacementBehavior.NONE && (existingNameSet.has(originalName) || processedNameSet.has(originalName))) { + // The file has an original name same as another file in the same upload batch or it has an original name same as another already existing file ignoredFiles.push(file); } else { @@ -161,46 +184,61 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe name: file.filename, originalName: originalName, size: file.size, - type: file.mimetype, + type: file.mimetype }; - filesRetEntry.url = getFileUrl(context, type, entityId, file.filename); - filesRetEntry.thumbnailUrl = getFileUrl(context, type, entityId, file.filename); // TODO - use smaller thumbnails + filesRetEntry.url = getFileUrl(context, type, subType, entityId, file.filename); + + if (file.mimetype.startsWith('image/')) { + filesRetEntry.thumbnailUrl = getFileUrl(context, type, subType, entityId, file.filename); // TODO - use smaller thumbnails, + } filesRet.push(filesRetEntry); + } - if (existingNameMap.has(originalName)) { - removedFiles.push(existingNameMap.get(originalName)); + processedNameSet.add(originalName); + } + + if (replacementBehavior === ReplacementBehavior.REPLACE) { + for (const row of existingNamesRows) { + const idsToRemove = []; + if (processedNameSet.has(row.originalname)) { + removedFiles.push(row); + idsToRemove.push(row.id); } } - originalNameSet.add(originalName); + await tx(getFilesTable(type, subType)).where('entity', entityId).whereIn('id', idsToRemove).del(); } - const originalNameArray = Array.from(originalNameSet); - await tx(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del(); - if (fileEntities) { - await tx(getFilesTable(type)).insert(fileEntities); + await tx(getFilesTable(type, subType)).insert(fileEntities); } }); // Move new files from upload directory to files directory for (const file of filesToMove) { - const filePath = getFilePath(type, entityId, file.filename); - // The names should be unique, so overwrite is disabled - // The directory is created if it does not exist - // Empty options argument is passed, otherwise fails - await fs.moveAsync(file.path, filePath, {}); + const filePath = getFilePath(type, subType, entityId, file.filename); + + if (file.path) { + // The names should be unique, so overwrite is disabled + // The directory is created if it does not exist + // Empty options argument is passed, otherwise fails + await fs.moveAsync(file.path, filePath, {}); + } else if (file.data) { + await fs.outputFile(filePath, file.data); + } } // Remove replaced files from files directory for (const file of removedFiles) { - const filePath = getFilePath(type, entityId, file.filename); + const filePath = getFilePath(type, subType, entityId, file.filename); await fs.removeAsync(filePath); } // Remove ignored files from upload directory for (const file of ignoredFiles) { - await fs.removeAsync(file.path); + if (file.path) { + await fs.removeAsync(file.path); + } } return { @@ -212,38 +250,38 @@ async function createFiles(context, type, entityId, files, getUrl = null, dontRe }; } -async function removeFile(context, type, id) { - enforceTypePermitted(type); +async function removeFile(context, type, subType, id) { + enforceTypePermitted(type, subType); const file = await knex.transaction(async tx => { - const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first(); - await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles'); - await tx(getFilesTable(type)).where('id', id).del(); + const file = await tx(getFilesTable(type, subType)).where('id', id).select('entity', 'filename').first(); + await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage')); + await tx(getFilesTable(type, subType)).where('id', id).del(); return {filename: file.filename, entity: file.entity}; }); - const filePath = getFilePath(type, file.entity, file.filename); + const filePath = getFilePath(type, subType, file.entity, file.filename); await fs.removeAsync(filePath); } -async function copyAllTx(tx, context, fromType, fromEntityId, toType, toEntityId) { - enforceTypePermitted(fromType); - await shares.enforceEntityPermissionTx(tx, context, fromType, fromEntityId, 'viewFiles'); +async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toType, toSubType, toEntityId) { + enforceTypePermitted(fromType, fromSubType); + await shares.enforceEntityPermissionTx(tx, context, fromType, fromEntityId, getFilesPermission(fromType, fromSubType, 'view')); - enforceTypePermitted(toType); - await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, 'manageFiles'); + enforceTypePermitted(toType, toSubType); + await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, getFilesPermission(toType, toSubType, 'manage')); - const rows = await tx(getFilesTable(fromType)).where({entity: fromEntityId}); + const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId}); for (const row of rows) { - const fromFilePath = getFilePath(fromType, fromEntityId, row.filename); - const toFilePath = getFilePath(toType, toEntityId, row.filename); + const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename); + const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename); await fs.copyAsync(fromFilePath, toFilePath, {}); delete row.id; row.entity = toEntityId; } - await tx(getFilesTable(toType)).insert(rows); + await tx(getFilesTable(toType, toSubType)).insert(rows); } @@ -259,5 +297,6 @@ module.exports = { removeFile, getFileUrl, getFilePath, - copyAllTx + copyAllTx, + ReplacementBehavior }; \ No newline at end of file diff --git a/models/templates.js b/models/templates.js index 07d4fb2c..667a6c51 100644 --- a/models/templates.js +++ b/models/templates.js @@ -15,17 +15,21 @@ function hash(entity) { return hasher.hash(filterObject(entity, allowedKeys)); } +async function getByIdTx(tx, context, id, withPermissions = true) { + await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view'); + const entity = await tx('templates').where('id', id).first(); + entity.data = JSON.parse(entity.data); + + if (withPermissions) { + entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id); + } + + return entity; +} + async function getById(context, id, withPermissions = true) { return await knex.transaction(async tx => { - await shares.enforceEntityPermissionTx(tx, context, 'template', id, 'view'); - const entity = await tx('templates').where('id', id).first(); - entity.data = JSON.parse(entity.data); - - if (withPermissions) { - entity.permissions = await shares.getPermissionsTx(tx, context, 'template', id); - } - - return entity; + return await getByIdTx(tx, context, id, withPermissions); }); } @@ -100,6 +104,7 @@ async function remove(context, id) { module.exports = { hash, + getByIdTx, getById, listDTAjax, create, diff --git a/package-lock.json b/package-lock.json index 0c871af6..a3f0afe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1308,6 +1308,11 @@ } } }, + "crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" + }, "csrf": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.0.6.tgz", diff --git a/package.json b/package.json index fd8d4eab..9e54b160 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "connect-redis": "^3.3.0", "cookie-parser": "^1.4.3", "cors": "^2.8.4", + "crypto": "^1.0.1", "csurf": "^1.9.0", "csv-parse": "^1.2.3", "device": "^0.3.8", diff --git a/routes/mosaico.js b/routes/mosaico.js index 403f7f99..32ae59bb 100644 --- a/routes/mosaico.js +++ b/routes/mosaico.js @@ -135,10 +135,9 @@ function getRouter(trusted) { }); // Mosaico looks for block thumbnails in edres folder relative to index.html of the template. We respond to such requests here. - // We use the following naming convention in Mosaico templates for the block thumbnails: edres/xxx -> edres-xxx router.getAsync('/templates/:mosaicoTemplateId/edres/:fileName', async (req, res, next) => { try { - const file = await files.getFileByOriginalName(contextHelpers.getAdminContext(), 'mosaicoTemplate', req.params.mosaicoTemplateId, req.params.fileName); + const file = await files.getFileByOriginalName(contextHelpers.getAdminContext(), 'mosaicoTemplate', 'block', req.params.mosaicoTemplateId, req.params.fileName); res.type(file.mimetype); return res.download(file.path, file.name); } catch (err) { @@ -154,18 +153,18 @@ function getRouter(trusted) { router.use('/templates/:mosaicoTemplateId/edres', express.static(path.join(__dirname, '..', 'client', 'public', 'mosaico', 'templates', 'versafix-1', 'edres'))); - fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', getSandboxUrl, true); + fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', files.ReplacementBehavior.RENAME, null, 'file'); router.getAsync('/upload/:type/:entityId', passport.loggedIn, async (req, res) => { - const entries = await files.list(req.context, req.params.type, req.params.entityId); + const entries = await files.list(req.context, req.params.type, 'file', req.params.entityId); const filesOut = []; for (const entry of entries) { filesOut.push({ name: entry.originalname, - url: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename), + url: files.getFileUrl(req.context, req.params.type, 'file', req.params.entityId, entry.filename), size: entry.size, - thumbnailUrl: files.getFileUrl(req.context, req.params.type, req.params.entityId, entry.filename) // TODO - use smaller thumbnails + thumbnailUrl: files.getFileUrl(req.context, req.params.type, 'file', req.params.entityId, entry.filename) // TODO - use smaller thumbnails }) } @@ -175,9 +174,6 @@ function getRouter(trusted) { }); router.getAsync('/editor', passport.csrfProtection, async (req, res) => { - const resourceType = req.query.type; - const resourceId = req.query.id; - const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, trusted); let languageStrings = null; @@ -229,7 +225,7 @@ function getRouter(trusted) { if (url.startsWith(mosaicoLegacyUrlPrefix)) { filePath = path.join(__dirname, '..', 'client', 'public' , 'mosaico', 'uploads', url.substring(mosaicoLegacyUrlPrefix.length)); } else { - const file = await files.getFileByUrl(contextHelpers.getAdminContext(), req.params.type, req.params.entityId, url); + const file = await files.getFileByUrl(contextHelpers.getAdminContext(), req.params.type, 'file', req.params.entityId, url); filePath = file.path; } diff --git a/routes/rest/files.js b/routes/rest/files.js index 35919eaa..f3b9216f 100644 --- a/routes/rest/files.js +++ b/routes/rest/files.js @@ -5,21 +5,21 @@ const files = require('../../models/files'); const router = require('../../lib/router-async').create(); const fileHelpers = require('../../lib/file-helpers'); -router.postAsync('/files-table/:type/:entityId', passport.loggedIn, async (req, res) => { - return res.json(await files.listDTAjax(req.context, req.params.type, req.params.entityId, req.body)); +router.postAsync('/files-table/:type/:subType/:entityId', passport.loggedIn, async (req, res) => { + return res.json(await files.listDTAjax(req.context, req.params.type, req.params.subType, req.params.entityId, req.body)); }); -router.getAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => { - const file = await files.getFileById(req.context, req.params.type, req.params.fileId); +router.getAsync('/files/:type/:subType/:fileId', passport.loggedIn, async (req, res) => { + const file = await files.getFileById(req.context, req.params.type, req.params.subType, req.params.fileId); res.type(file.mimetype); return res.download(file.path, file.name); }); -router.deleteAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => { - await files.removeFile(req.context, req.params.type, req.params.fileId); +router.deleteAsync('/files/:type/:subType/:fileId', passport.loggedIn, async (req, res) => { + await files.removeFile(req.context, req.params.type, req.params.subType, req.params.fileId); return res.json(); }); -fileHelpers.installUploadHandler(router, '/files/:type/:entityId'); +fileHelpers.installUploadHandler(router, '/files/:type/:subType/:entityId', files.ReplacementBehavior.REPLACE); module.exports = router; \ No newline at end of file diff --git a/routes/rest/shares.js b/routes/rest/shares.js index 122337f3..21e13eed 100644 --- a/routes/rest/shares.js +++ b/routes/rest/shares.js @@ -69,7 +69,7 @@ router.postAsync('/permissions-check', passport.loggedIn, async (req, res) => { router.postAsync('/permissions-rebuild', passport.loggedIn, async (req, res) => { shares.enforceGlobalPermission(req.context, 'rebuildPermissions'); - shares.rebuildPermissions(); + await shares.rebuildPermissions(); return res.json(result); }); diff --git a/routes/subscription.js b/routes/subscription.js index fbd7d16e..3ae130d0 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -505,7 +505,7 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req, const autoUnsubscribe = req.query.auto === 'yes'; if (autoUnsubscribe) { - handleUnsubscribe(list, req.params.ucid, autoUnsubscribe, req.query.c, req.ip, res, next); + await handleUnsubscribe(list, req.params.ucid, autoUnsubscribe, req.query.c, req.ip, res, next); } else if (req.query.formTest || list.unsubscription_mode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM || diff --git a/setup/knex/migrations/20170506102634_base.js b/setup/knex/migrations/20170506102634_base.js index 52d39e37..308eaa77 100644 --- a/setup/knex/migrations/20170506102634_base.js +++ b/setup/knex/migrations/20170506102634_base.js @@ -120,5 +120,4 @@ exports.up = (knex, Promise) => (async() => { })(); exports.down = (knex, Promise) => (async() => { - // return knex.schema.dropTable('users'); })(); \ No newline at end of file diff --git a/setup/knex/migrations/20170507083345_create_namespaces.js b/setup/knex/migrations/20170507083345_create_namespaces.js index 4535392a..cbb5b443 100644 --- a/setup/knex/migrations/20170507083345_create_namespaces.js +++ b/setup/knex/migrations/20170507083345_create_namespaces.js @@ -31,5 +31,4 @@ exports.up = (knex, Promise) => (async() => { })(); exports.down = (knex, Promise) => (async() => { - await knex.schema.dropTable('namespaces'); })(); \ No newline at end of file diff --git a/setup/knex/migrations/20170507084114_create_permissions.js b/setup/knex/migrations/20170507084114_create_permissions.js index b14ee841..8f7c0c38 100644 --- a/setup/knex/migrations/20170507084114_create_permissions.js +++ b/setup/knex/migrations/20170507084114_create_permissions.js @@ -32,9 +32,4 @@ exports.up = (knex, Promise) => (async() => { })(); exports.down = (knex, Promise) => (async() => { - for (const entityType of shareableEntityTypes) { - await knex.schema - .dropTable(`shares_${entityType}`) - .dropTable(`permissions_${entityType}`); - } })(); diff --git a/setup/knex/migrations/20180110120444_add_files.js b/setup/knex/migrations/20180110120444_add_files.js index 3c5bebc5..a6aa6d41 100644 --- a/setup/knex/migrations/20180110120444_add_files.js +++ b/setup/knex/migrations/20180110120444_add_files.js @@ -1,25 +1,39 @@ -const entityTypesWithFiles = ['template', 'campaign']; +const entityTypesWithFiles = { + campaign: { + file: 'files_campaign_file', + attachment: 'files_campaign_attachment', + }, + template: { + file: 'files_template_file' + }, + mosaicoTemplate: { + file: 'files_mosaico_template_file', + block: 'files_mosaico_template_block' + } +}; + exports.up = (knex, Promise) => (async() => { - for (const entityType of entityTypesWithFiles) { + for (const type in entityTypesWithFiles) { + const typeEntry = entityTypesWithFiles[type]; - await knex.schema.createTable(`files_${entityType}`, table => { - table.increments('id').primary(); - table.integer('entity').unsigned().notNullable().references(`${entityType}s.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']) - }) + for (const subType in typeEntry) { + const subTypeEntry = typeEntry[subType]; + await knex.schema.createTable(subTypeEntry, table => { + table.increments('id').primary(); + table.integer('entity').unsigned().notNullable().references(`${type}s.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']) + }); + } } })(); exports.down = (knex, Promise) => (async() => { - for (const entityType of entityTypesWithFiles) { - await knex.schema.dropTable(`files_${entityType}`); - } })(); diff --git a/setup/knex/migrations/20180401120444_create_mosaico_templates.js b/setup/knex/migrations/20180401120444_create_mosaico_templates.js index b3fe5b4d..a0c62005 100644 --- a/setup/knex/migrations/20180401120444_create_mosaico_templates.js +++ b/setup/knex/migrations/20180401120444_create_mosaico_templates.js @@ -52,10 +52,4 @@ exports.up = (knex, Promise) => (async() => { })(); exports.down = (knex, Promise) => (async() => { - await knex.schema - .dropTable('shares_mosaico_template') - .dropTable('permissions_mosaico_template') - .dropTable('files_mosaico_template') - .dropTable('mosaico_templates') - ; })(); diff --git a/setup/knex/migrations/20180414120444_transform_settings.js b/setup/knex/migrations/20180414120444_transform_settings.js index 30f8a1e7..a5ff6d88 100644 --- a/setup/knex/migrations/20180414120444_transform_settings.js +++ b/setup/knex/migrations/20180414120444_transform_settings.js @@ -121,9 +121,4 @@ exports.up = (knex, Promise) => (async() => { exports.down = (knex, Promise) => (async() => { - await knex.schema - .dropTable('shares_send_configuration') - .dropTable('permissions_send_configuration') - .dropTable('send_configurations') - ; })(); diff --git a/setup/knex/migrations/20180718220444_upgrade_campaigns.js b/setup/knex/migrations/20180718220444_upgrade_campaigns.js index 957076d2..e9f3e2e2 100644 --- a/setup/knex/migrations/20180718220444_upgrade_campaigns.js +++ b/setup/knex/migrations/20180718220444_upgrade_campaigns.js @@ -56,6 +56,8 @@ scheduled - used only for campaign type NORMAL const { getSystemSendConfigurationId } = require('../../../shared/send-configurations'); const { CampaignSource, CampaignType} = require('../../../shared/campaigns'); +const files = require('../../../models/files'); +const contextHelpers = require('../../../lib/context-helpers'); exports.up = (knex, Promise) => (async() => { @@ -77,11 +79,11 @@ exports.up = (knex, Promise) => (async() => { let editorType = campaign.editor_name; const editorData = JSON.parse(campaign.editor_data || '{}'); - if (editorType == 'summernote') { + if (editorType === 'summernote') { editorType = 'ckeditor'; } - if (editorType == 'mosaico') { + if (editorType === 'mosaico') { editorType = 'mosaicoWithFsTemplate'; editorData.mosaicoFsTemplate = editorData.template; delete editorData.template; @@ -115,6 +117,20 @@ exports.up = (knex, Promise) => (async() => { campaign.data = JSON.stringify(data); await knex('campaigns').where('id', campaign.id).update(campaign); + + const attachments = await knex('attachments').where('campaign', campaign.id); + const attachmentFiles = []; + for (const attachment of attachments) { + attachmentFiles.push({ + originalname: attachment.filename, + mimetype: attachment.content_type, + // encoding: file.encoding, + size: attachment.size, + created: attachment.created, + data: attachment.content + }); + } + await files.createFiles(contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id, attachmentFiles, files.ReplacementBehavior.NONE); } await knex.schema.table('campaigns', table => { @@ -138,6 +154,7 @@ exports.up = (knex, Promise) => (async() => { await knex.schema.dropTableIfExists('campaign'); await knex.schema.dropTableIfExists('campaign_tracker'); + await knex.schema.dropTableIfExists('attachments'); })(); exports.down = (knex, Promise) => (async() => { diff --git a/setup/knex/migrations/20180718220445_drop_template_table.js b/setup/knex/migrations/20180718220445_drop_subscription_table.js similarity index 100% rename from setup/knex/migrations/20180718220445_drop_template_table.js rename to setup/knex/migrations/20180718220445_drop_subscription_table.js diff --git a/shared/campaigns.js b/shared/campaigns.js index 966df8d9..e09dceb0 100644 --- a/shared/campaigns.js +++ b/shared/campaigns.js @@ -6,8 +6,9 @@ const CampaignSource = { TEMPLATE: 1, CUSTOM: 2, CUSTOM_FROM_TEMPLATE: 3, - URL: 4, - RSS: 5, + CUSTOM_FROM_CAMPAIGN: 4, + URL: 5, + RSS: 6, MAX: 6 };