diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js index 6a8e79ca..d6f469ea 100644 --- a/client/src/campaigns/CUD.js +++ b/client/src/campaigns/CUD.js @@ -217,6 +217,7 @@ export default class CUD extends Component { localValidateFormValues(state) { const t = this.props.t; + const isEdit = !!this.props.entity; if (!state.getIn(['name', 'value'])) { state.setIn(['name', 'error'], t('Name must not be empty')); @@ -259,17 +260,17 @@ export default class CUD extends Component { } } - if (sourceTypeKey === CampaignSource.TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) { + if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) { if (!state.getIn(['data_sourceTemplate', 'value'])) { state.setIn(['data_sourceTemplate', 'error'], t('Template must be selected')); } - } else if (sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) { + } else if (!isEdit && 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) { + } else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM) { // 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) { @@ -302,7 +303,7 @@ export default class CUD extends Component { let sendMethod, url; if (this.props.entity) { sendMethod = FormSendMethod.PUT; - url = `rest/campaigns/${this.props.entity.id}` + url = `rest/campaigns-settings/${this.props.entity.id}`; } else { sendMethod = FormSendMethod.POST; url = 'rest/campaigns' @@ -312,6 +313,8 @@ export default class CUD extends Component { this.setFormStatusMessage('info', t('Saving ...')); const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { + data.source = Number.parseInt(data.source); + if (!data.useSegmentation) { data.segment = null; } @@ -460,18 +463,20 @@ export default class CUD extends Component { help = t('Selecting a template creates a campaign specific copy from it.'); } - templateEdit = ; + // The "key" property here and in the TableSelect below is to tell React that these tables are different and should be rendered by different instances. Otherwise, React will use + // only one instance, which fails because Table does not handle updates in "columns" property + 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') } + { data: 4, title: t('Created'), render: data => moment(data).fromNow() }, + { data: 5, title: t('Namespace') } ]; - templateEdit = ; + templateEdit = ; } else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM) { const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js index 135fdf4f..3623b1ea 100644 --- a/client/src/campaigns/Content.js +++ b/client/src/campaigns/Content.js @@ -88,14 +88,8 @@ export default class CustomContent extends Component { 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' - } + const sendMethod = FormSendMethod.PUT; + const url = `rest/campaigns-content/${this.props.entity.id}`; this.disableForm(); this.setFormStatusMessage('info', t('Saving ...')); diff --git a/client/src/campaigns/root.js b/client/src/campaigns/root.js index 9c84361a..c77a41e8 100644 --- a/client/src/campaigns/root.js +++ b/client/src/campaigns/root.js @@ -20,7 +20,7 @@ function getMenus(t) { ':campaignId([0-9]+)': { title: resolved => t('Campaign "{{name}}"', {name: resolved.campaign.name}), resolve: { - campaign: params => `rest/campaigns/${params.campaignId}` + campaign: params => `rest/campaigns-settings/${params.campaignId}` }, link: params => `/campaigns/${params.campaignId}/edit`, navs: { @@ -33,20 +33,23 @@ function getMenus(t) { content: { title: t('Content'), link: params => `/campaigns/${params.campaignId}/content`, + resolve: { + campaignContent: params => `rest/campaigns-content/${params.campaignId}` + }, 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 => + panelRender: props => }, files: { title: t('Files'), link: params => `/campaigns/${params.campaignId}/files`, 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 => + panelRender: props => }, attachments: { title: t('Attachments'), link: params => `/campaigns/${params.campaignId}/attachments`, visible: resolved => resolved.campaign.permissions.includes('viewAttachments'), - panelRender: props => + panelRender: props => }, share: { title: t('Share'), diff --git a/client/src/lib/files.js b/client/src/lib/files.js index 714060bf..3f2a9841 100644 --- a/client/src/lib/files.js +++ b/client/src/lib/files.js @@ -3,7 +3,10 @@ import React, {Component} from "react"; import PropTypes from "prop-types"; import {translate} from "react-i18next"; -import {requiresAuthenticatedUser} from "./page"; +import { + requiresAuthenticatedUser, + Title +} from "./page"; import {withErrorHandling} from "./error-handling"; import {Table} from "./table"; import Dropzone from "react-dropzone"; @@ -32,6 +35,7 @@ export default class Files extends Component { static propTypes = { title: PropTypes.string, + help: PropTypes.string, entity: PropTypes.object.isRequired, entityTypeId: PropTypes.string.isRequired, entitySubTypeId: PropTypes.string.isRequired, @@ -151,6 +155,10 @@ export default class Files extends Component { {t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})} + {this.props.title && {this.props.title}} + + {this.props.help &&

{this.props.help}

} + { this.props.entity.permissions.includes(this.props.managePermission) && diff --git a/client/src/lib/mosaico.js b/client/src/lib/mosaico.js index 11b48a75..fada9847 100644 --- a/client/src/lib/mosaico.js +++ b/client/src/lib/mosaico.js @@ -147,8 +147,8 @@ export class MosaicoSandbox extends Component { }); const config = { - imgProcessorBackend: getTrustedUrl(`mosaico/img/${this.props.entityTypeId}/${this.props.entityId}`), - emailProcessorBackend: getSandboxUrl('mosaico/dl/'), + imgProcessorBackend: getTrustedUrl('mosaico/img'), + emailProcessorBackend: getSandboxUrl('mosaico/dl'), fileuploadConfig: { url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`) }, diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss index 160e7afb..68e8ba55 100644 --- a/client/src/lib/styles.scss +++ b/client/src/lib/styles.scss @@ -20,6 +20,11 @@ :global .ace_editor { border: 1px solid #ccc; } + + .buttonRow:last-child { + // This is to move Save/Delete buttons a bit down + margin-top: 15px; + } } .dayPickerWrapper { @@ -27,7 +32,6 @@ } .buttonRow { - margin-top: 15px; } .buttonRow > * { diff --git a/client/src/lists/List.js b/client/src/lists/List.js index 86042267..8d5d606e 100644 --- a/client/src/lists/List.js +++ b/client/src/lists/List.js @@ -78,13 +78,20 @@ export default class List extends Component { }); } - if (perms.includes('manageFields')) { + if (perms.includes('viewFields')) { actions.push({ - label: , + label: , link: `/lists/${data[0]}/fields` }); } + if (perms.includes('viewSegments')) { + actions.push({ + label: , + link: `/lists/${data[0]}/segments` + }); + } + if (perms.includes('share')) { actions.push({ label: , diff --git a/client/src/lists/fields/List.js b/client/src/lists/fields/List.js index adade6d0..b3877fdb 100644 --- a/client/src/lists/fields/List.js +++ b/client/src/lists/fields/List.js @@ -40,18 +40,28 @@ export default class List extends Component { { data: 2, title: t('Type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false }, { data: 3, title: t('Merge Tag') }, { - actions: data => [{ - label: , - link: `/lists/${this.props.list.id}/fields/${data[0]}/edit` - }] + actions: data => { + const actions = []; + + if (this.props.list.permissions.includes('manageFields')) { + actions.push({ + label: , + link: `/lists/${this.props.list.id}/fields/${data[0]}/edit` + }); + } + + return actions; + } } ]; return (
- - - + {this.props.list.permissions.includes('manageFields') && + + + + } {t('Fields')} diff --git a/client/src/lists/root.js b/client/src/lists/root.js index 0b6adc6b..64d01dab 100644 --- a/client/src/lists/root.js +++ b/client/src/lists/root.js @@ -70,7 +70,7 @@ function getMenus(t) { fields: { title: t('Fields'), link: params => `/lists/${params.listId}/fields/`, - visible: resolved => resolved.list.permissions.includes('manageFields'), + visible: resolved => resolved.list.permissions.includes('viewFields'), panelRender: props => , children: { ':fieldId([0-9]+)': { @@ -100,7 +100,7 @@ function getMenus(t) { segments: { title: t('Segments'), link: params => `/lists/${params.listId}/segments`, - visible: resolved => resolved.list.permissions.includes('manageSegments'), + visible: resolved => resolved.list.permissions.includes('viewSegments'), panelRender: props => , children: { ':segmentId([0-9]+)': { diff --git a/client/src/lists/segments/List.js b/client/src/lists/segments/List.js index db668cc1..b904591c 100644 --- a/client/src/lists/segments/List.js +++ b/client/src/lists/segments/List.js @@ -32,18 +32,28 @@ export default class List extends Component { const columns = [ { data: 1, title: t('Name') }, { - actions: data => [{ - label: , - link: `/lists/${this.props.list.id}/segments/${data[0]}/edit` - }] + actions: data => { + const actions = []; + + if (this.props.list.permissions.includes('manageSegments')) { + actions.push({ + label: , + link: `/lists/${this.props.list.id}/segments/${data[0]}/edit` + }); + } + + return actions; + } } ]; return (
- - - + {this.props.list.permissions.includes('manageSegments') && + + + + } {t('Segment')} diff --git a/client/src/templates/helpers.js b/client/src/templates/helpers.js index 3b1da84b..737ba017 100644 --- a/client/src/templates/helpers.js +++ b/client/src/templates/helpers.js @@ -37,8 +37,8 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM const initVals = templateTypes[templateType].initData(); for (const key in initVals) { - if (!mutState.hasIn([prefix + key])) { - mutState.setIn([prefix + key, 'value'], initVals[key]); + if (!mutState.hasIn([key])) { + mutState.setIn([key, 'value'], initVals[key]); } } } @@ -97,6 +97,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM }; }, beforeSave: data => { + console.log(data); data[prefix + 'data'] = { mosaicoTemplate: data[prefix + 'mosaicoTemplate'], metadata: data[prefix + 'mosaicoData'].metadata, @@ -150,8 +151,8 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM }); }, initData: () => ({ - mosaicoFsTemplate: mailtrainConfig.mosaico.fsTemplates[0][0], - mosaicoData: {} + [prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0][0], + [prefix + 'mosaicoData']: {} }), afterLoad: data => { data[prefix + 'mosaicoFsTemplate'] = data[prefix + 'data'].mosaicoFsTemplate; @@ -169,7 +170,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM clearBeforeSave(data); }, afterTypeChange: mutState => { - initFieldsIfMissing(mutState, 'mosaico'); + initFieldsIfMissing(mutState, 'mosaicoWithFsTemplate'); }, validate: state => {} }; diff --git a/client/src/templates/root.js b/client/src/templates/root.js index 70f86abe..e59be4be 100644 --- a/client/src/templates/root.js +++ b/client/src/templates/root.js @@ -34,7 +34,7 @@ function getMenus(t) { title: t('Files'), link: params => `/templates/${params.templateId}/files`, visible: resolved => resolved.template.permissions.includes('viewFiles'), - panelRender: props => + panelRender: props => }, share: { title: t('Share'), @@ -70,13 +70,13 @@ function getMenus(t) { title: t('Files'), link: params => `/templates/mosaico/${params.mosaiceTemplateId}/files`, visible: resolved => resolved.mosaicoTemplate.permissions.includes('viewFiles'), - panelRender: props => + panelRender: props => }, blocks: { title: t('Block thumbnails'), link: params => `/templates/mosaico/${params.mosaiceTemplateId}/blocks`, visible: resolved => resolved.mosaicoTemplate.permissions.includes('viewFiles'), - panelRender: props => + panelRender: props => }, share: { title: t('Share'), diff --git a/lib/client-helpers.js b/lib/client-helpers.js index bc28fc2b..4f8689bb 100644 --- a/lib/client-helpers.js +++ b/lib/client-helpers.js @@ -2,7 +2,6 @@ const passport = require('./passport'); const config = require('config'); -const permissions = require('./permissions'); const forms = require('../models/forms'); const shares = require('../models/shares'); const urls = require('./urls'); diff --git a/lib/dt-helpers.js b/lib/dt-helpers.js index eb376c11..8b6a9bce 100644 --- a/lib/dt-helpers.js +++ b/lib/dt-helpers.js @@ -1,7 +1,7 @@ 'use strict'; const knex = require('../lib/knex'); -const permissions = require('../lib/permissions'); +const entitySettings = require('./entity-settings'); async function ajaxListTx(tx, params, queryFun, columns, options) { options = options || {}; @@ -109,7 +109,7 @@ async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryF const permCols = []; for (const fetchSpec of fetchSpecs) { - const entityType = permissions.getEntityType(fetchSpec.entityTypeId); + const entityType = entitySettings.getEntityType(fetchSpec.entityTypeId); permCols.push({ name: `permissions_${fetchSpec.entityTypeId}`, query: builder => builder @@ -128,7 +128,7 @@ async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryF let query = queryFun(builder); for (const fetchSpec of fetchSpecs) { - const entityType = permissions.getEntityType(fetchSpec.entityTypeId); + const entityType = entitySettings.getEntityType(fetchSpec.entityTypeId); if (fetchSpec.requiredOperations) { query = query.innerJoin( diff --git a/lib/permissions.js b/lib/entity-settings.js similarity index 80% rename from lib/permissions.js rename to lib/entity-settings.js index 0c00209b..e9036e6c 100644 --- a/lib/permissions.js +++ b/lib/entity-settings.js @@ -1,5 +1,11 @@ 'use strict'; +const ReplacementBehavior = { + NONE: 1, + REPLACE: 2, + RENAME: 3 +}; + const entityTypes = { namespace: { entitiesTable: 'namespaces', @@ -26,14 +32,16 @@ const entityTypes = { permissions: { view: 'viewFiles', manage: 'manageFiles' - } + }, + defaultReplacementBehavior: ReplacementBehavior.REPLACE }, attachment: { table: 'files_campaign_attachment', permissions: { view: 'viewAttachments', manage: 'manageAttachments' - } + }, + defaultReplacementBehavior: ReplacementBehavior.NONE } } }, @@ -47,7 +55,8 @@ const entityTypes = { permissions: { view: 'viewFiles', manage: 'manageFiles' - } + }, + defaultReplacementBehavior: ReplacementBehavior.REPLACE } } }, @@ -76,14 +85,16 @@ const entityTypes = { permissions: { view: 'viewFiles', manage: 'manageFiles' - } + }, + defaultReplacementBehavior: ReplacementBehavior.REPLACE }, block: { table: 'files_mosaico_template_block', permissions: { view: 'viewFiles', manage: 'manageFiles' - } + }, + defaultReplacementBehavior: ReplacementBehavior.REPLACE } } } @@ -105,5 +116,6 @@ function getEntityType(entityTypeId) { module.exports = { getEntityTypes, - getEntityType + getEntityType, + ReplacementBehavior } \ No newline at end of file diff --git a/lib/file-helpers.js b/lib/file-helpers.js index 48b4c3c3..8d088185 100644 --- a/lib/file-helpers.js +++ b/lib/file-helpers.js @@ -10,7 +10,7 @@ const multer = require('multer')({ dest: uploadedFilesDir }); -function installUploadHandler(router, url, replacementBehavior, type = null, subType = null) { +function installUploadHandler(router, url, replacementBehavior, type, subType) { router.postAsync(url, passport.loggedIn, multer.array('files[]'), async (req, res) => { 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/namespace-helpers.js b/lib/namespace-helpers.js index 7036a91f..e47b8c85 100644 --- a/lib/namespace-helpers.js +++ b/lib/namespace-helpers.js @@ -1,7 +1,7 @@ 'use strict'; -const knex = require('./knex'); const { enforce } = require('./helpers'); +const shares = require('../models/shares'); const interoperableErrors = require('../shared/interoperable-errors'); async function validateEntity(tx, entity) { diff --git a/models/campaigns.js b/models/campaigns.js index 563a38fd..228a3fc6 100644 --- a/models/campaigns.js +++ b/models/campaigns.js @@ -19,8 +19,32 @@ const allowedKeysCommon = ['name', 'description', 'list', 'segment', 'namespace' const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]); const allowedKeysUpdate = new Set([...allowedKeysCommon]); -function hash(entity) { - return hasher.hash(filterObject(entity, allowedKeysUpdate)); +const Content = { + ALL: 0, + WITHOUT_SOURCE_CUSTOM: 1, + ONLY_SOURCE_CUSTOM: 2 +}; + +function hash(entity, content) { + let filteredEntity; + + if (content === Content.ALL) { + filteredEntity = filterObject(entity, allowedKeysUpdate); + + } else if (content === Content.WITHOUT_SOURCE_CUSTOM) { + filteredEntity = filterObject(entity, allowedKeysUpdate); + filteredEntity.data = {...filteredEntity.data}; + delete filteredEntity.data.sourceCustom; + + } else if (content === Content.ONLY_SOURCE_CUSTOM) { + filteredEntity = { + data: { + sourceCustom: entity.data.sourceCustom + } + }; + } + + return hasher.hash(filteredEntity); } async function listDTAjax(context, params) { @@ -34,48 +58,103 @@ async function listDTAjax(context, params) { ); } -async function getByIdTx(tx, context, id, withPermissions = true) { +async function listWithContentDTAjax(context, params) { + return await dtHelpers.ajaxListWithPermissions( + context, + [{ entityTypeId: 'campaign', requiredOperations: ['view'] }], + params, + builder => builder.from('campaigns') + .innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace') + .whereIn('campaigns.source', [CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.CUSTOM_FROM_CAMPAIGN]), + ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.type', 'campaigns.created', 'namespaces.name'] + ); +} + +async function getByIdTx(tx, context, id, withPermissions = true, content = Content.ALL) { await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'view'); - const entity = await tx('campaigns').where('id', id).first(); + let entity = await tx('campaigns').where('id', id).first(); + + entity.data = JSON.parse(entity.data); + + if (content === Content.WITHOUT_SOURCE_CUSTOM) { + delete entity.data.sourceCustom; + + } else if (content === Content.ONLY_SOURCE_CUSTOM) { + entity = { + id: entity.id, + + data: { + sourceCustom: entity.data.sourceCustom + } + }; + } if (withPermissions) { entity.permissions = await shares.getPermissionsTx(tx, context, 'campaign', id); } - entity.data = JSON.parse(entity.data); - return entity; } -async function getById(context, id, withPermissions = true) { +async function getById(context, id, withPermissions = true, content = Content.ALL) { return await knex.transaction(async tx => { - return await getByIdTx(tx, context, id, withPermissions); + return await getByIdTx(tx, context, id, withPermissions, content); }); } -async function _validateAndPreprocess(tx, context, entity, isCreate) { - await namespaceHelpers.validateEntity(tx, entity); +async function _validateAndPreprocess(tx, context, entity, isCreate, content) { + if (content === Content.ALL || content === Content.WITHOUT_SOURCE_CUSTOM) { + await namespaceHelpers.validateEntity(tx, entity); - if (isCreate) { - enforce(entity.type === CampaignType.REGULAR || entity.type === CampaignType.RSS || entity.type === CampaignType.TRIGGERED, 'Unknown campaign type'); + if (isCreate) { + 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'); + if (entity.source === CampaignSource.TEMPLATE || entity.source === CampaignSource.CUSTOM_FROM_TEMPLATE) { + await shares.enforceEntityPermissionTx(tx, context, 'template', entity.data.sourceTemplate, 'view'); + } + + enforce(Number.isInteger(entity.source)); + enforce(entity.source >= CampaignSource.MIN && entity.source <= CampaignSource.MAX, 'Unknown campaign source'); } + + await shares.enforceEntityPermissionTx(tx, context, 'list', entity.list, 'view'); + + if (entity.segment) { + // Check that the segment under the list exists + await segments.getByIdTx(tx, context, entity.list, entity.segment); + } + + await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.send_configuration, 'viewPublic'); + } +} + +function convertFileURLs(sourceCustom, fromEntityType, fromEntityId, toEntityType, toEntityId) { + + function convertText(text) { + if (text) { + const fromUrl = `/files/${fromEntityType}/file/${fromEntityId}`; + const toUrl = `/files/${toEntityType}/file/${toEntityId}`; + + const encodedFromUrl = encodeURIComponent(fromUrl); + const encodedToUrl = encodeURIComponent(toUrl); + + text = text.split('[URL_BASE]' + fromUrl).join('[URL_BASE]' + toUrl); + text = text.split('[SANDBOX_URL_BASE]' + fromUrl).join('[SANDBOX_URL_BASE]' + toUrl); + text = text.split('[ENCODED_URL_BASE]' + encodedFromUrl).join('[ENCODED_URL_BASE]' + encodedToUrl); + text = text.split('[ENCODED_SANDBOX_URL_BASE]' + encodedFromUrl).join('[ENCODED_SANDBOX_URL_BASE]' + encodedToUrl); + } + + return text; } - enforce(entity.source >= CampaignSource.MIN && entity.source <= CampaignSource.MAX, 'Unknown campaign source'); + sourceCustom.html = convertText(sourceCustom.html); + sourceCustom.text = convertText(sourceCustom.text); - await shares.enforceEntityPermissionTx(tx, context, 'list', entity.list, 'view'); - - if (entity.segment) { - // Check that the segment under the list exists - await segments.getByIdTx(tx, context, entity.list, entity.segment); + if (sourceCustom.type === 'mosaico' || sourceCustom.type === 'mosaicoWithFsTemplate') { + sourceCustom.data.model = convertText(sourceCustom.data.model); + sourceCustom.data.model = convertText(sourceCustom.data.model); + sourceCustom.data.metadata = convertText(sourceCustom.data.metadata); } - - await shares.enforceEntityPermissionTx(tx, context, 'send_configuration', entity.send_configuration, 'viewPublic'); - - entity.data = JSON.stringify(entity.data); } async function create(context, entity) { @@ -97,6 +176,7 @@ async function create(context, entity) { html: template.html, text: template.text }; + } else if (entity.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) { copyFilesFrom = { entityType: 'campaign', @@ -104,15 +184,19 @@ async function create(context, entity) { }; const sourceCampaign = await getByIdTx(tx, context, entity.data.sourceCampaign, false); + enforce(sourceCampaign.source === CampaignSource.CUSTOM || sourceCampaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceCampaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN, 'Incorrect source type of the source campaign.'); entity.data.sourceCustom = sourceCampaign.data.sourceCustom; } - await _validateAndPreprocess(tx, context, entity, true); + await _validateAndPreprocess(tx, context, entity, true, Content.ALL); const filteredEntity = filterObject(entity, allowedKeysCreate); filteredEntity.cid = shortid.generate(); + const data = filteredEntity.data; + + filteredEntity.data = JSON.stringify(filteredEntity.data); const ids = await tx('campaigns').insert(filteredEntity); const id = ids[0]; @@ -150,14 +234,20 @@ async function create(context, entity) { await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id }); if (copyFilesFrom) { - await files.copyAllTx(tx, context, copyFilesFrom.entityType, copyFilesFrom.entityId, 'campaign', id); + await files.copyAllTx(tx, context, copyFilesFrom.entityType, 'file', copyFilesFrom.entityId, 'campaign', 'file', id); + + convertFileURLs(data.sourceCustom, copyFilesFrom.entityType, copyFilesFrom.entityId, 'campaign', id); + await tx('campaigns') + .update({ + data: JSON.stringify(data) + }).where('id', id); } return id; }); } -async function updateWithConsistencyCheck(context, entity) { +async function updateWithConsistencyCheck(context, entity, content) { await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.id, 'edit'); @@ -167,16 +257,31 @@ async function updateWithConsistencyCheck(context, entity) { } existing.data = JSON.parse(existing.data); - const existingHash = hash(existing); + const existingHash = hash(existing, content); if (existingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } - await _validateAndPreprocess(tx, context, entity, false); + await _validateAndPreprocess(tx, context, entity, false, content); - await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete'); + let filteredEntity = filterObject(entity, allowedKeysUpdate); + if (content === Content.ALL) { + await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete'); - await tx('campaigns').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate)); + } else if (content === Content.WITHOUT_SOURCE_CUSTOM) { + filteredEntity.data.sourceCustom = existing.data.sourceCustom; + await namespaceHelpers.validateMove(context, filteredEntity, existing, 'campaign', 'createCampaign', 'delete'); + + } else if (content === Content.ONLY_SOURCE_CUSTOM) { + const data = existing.data; + data.sourceCustom = filteredEntity.data.sourceCustom; + filteredEntity = { + data + }; + } + + filteredEntity.data = JSON.stringify(filteredEntity.data); + await tx('campaigns').where('id', entity.id).update(filteredEntity); await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: entity.id }); }); @@ -196,8 +301,10 @@ async function remove(context, id) { module.exports = { + Content, hash, listDTAjax, + listWithContentDTAjax, getByIdTx, getById, create, diff --git a/models/files.js b/models/files.js index 50d5511b..f9eddeb6 100644 --- a/models/files.js +++ b/models/files.js @@ -7,22 +7,18 @@ const shares = require('./shares'); const fs = require('fs-extra-promise'); const path = require('path'); const interoperableErrors = require('../shared/interoperable-errors'); -const permissions = require('../lib/permissions'); +const entitySettings = require('../lib/entity-settings'); const {getTrustedUrl} = require('../lib/urls'); const crypto = require('crypto'); const bluebird = require('bluebird'); const cryptoPseudoRandomBytes = bluebird.promisify(crypto.pseudoRandomBytes); -const entityTypes = permissions.getEntityTypes(); +const entityTypes = entitySettings.getEntityTypes(); const filesDir = path.join(__dirname, '..', 'files'); -const ReplacementBehavior = { - NONE: 0, - REPLACE: 1, - RENAME: 2 -}; +const ReplacementBehavior = entitySettings.ReplacementBehavior; function enforceTypePermitted(type, subType) { enforce(type in entityTypes && entityTypes[type].files && entityTypes[type].files[subType]); @@ -108,10 +104,26 @@ async function getFileByFilename(context, type, subType, entityId, name) { return await _getFileBy(context, type, subType, entityId, 'filename', name) } -async function getFileByUrl(context, type, subType, entityId, url) { - const urlPrefix = getTrustedUrl(`files/${type}/${subType}/${entityId}/`, context); +async function getFileByUrl(context, url) { + const urlPrefix = getTrustedUrl('files/', context); if (url.startsWith(urlPrefix)) { - const name = url.substring(urlPrefix.length); + const path = url.substring(urlPrefix.length); + const pathElem = path.split('/'); + + if (pathElem.length !== 4) { + throw new interoperableErrors.NotFoundError(); + } + + const type = pathElem[0]; + const subType = pathElem[1]; + const entityId = Number.parseInt(pathElem[2]); + + if (Number.isNaN(entityId)) { + throw new interoperableErrors.NotFoundError(); + } + + const name = pathElem[3]; + return await getFileByFilename(context, type, subType, entityId, name); } else { throw new interoperableErrors.NotFoundError(); @@ -126,6 +138,10 @@ async function createFiles(context, type, subType, entityId, files, replacementB return {uploaded: 0}; } + if (!replacementBehavior) { + replacementBehavior = entityTypes[type].files[subType].defaultReplacementBehavior; + } + const fileEntities = []; const filesToMove = []; const ignoredFiles = []; @@ -280,7 +296,9 @@ async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toTyp row.entity = toEntityId; } - await tx(getFilesTable(toType, toSubType)).insert(rows); + if (rows.length > 0) { + await tx(getFilesTable(toType, toSubType)).insert(rows); + } } diff --git a/models/namespaces.js b/models/namespaces.js index f68ed2ba..7aec08ce 100644 --- a/models/namespaces.js +++ b/models/namespaces.js @@ -5,7 +5,7 @@ const hasher = require('node-object-hash')(); const { enforce, filterObject } = require('../lib/helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); -const permissions = require('../lib/permissions'); +const entitySettings = require('../lib/entity-settings'); const namespaceHelpers = require('../lib/namespace-helpers'); @@ -14,7 +14,7 @@ const allowedKeys = new Set(['name', 'description', 'namespace']); async function listTree(context) { // FIXME - process permissions - const entityType = permissions.getEntityType('namespace'); + const entityType = entitySettings.getEntityType('namespace'); // This builds a forest of namespaces that contains only those namespace that the user has access to // This goes in three steps: 1) tree with all namespaces is built with parent-children links, 2) the namespaces that are not accessible diff --git a/models/shares.js b/models/shares.js index faeaef3e..dfed207c 100644 --- a/models/shares.js +++ b/models/shares.js @@ -4,7 +4,7 @@ const knex = require('../lib/knex'); const config = require('config'); const { enforce } = require('../lib/helpers'); const dtHelpers = require('../lib/dt-helpers'); -const permissions = require('../lib/permissions'); +const entitySettings = require('../lib/entity-settings'); const interoperableErrors = require('../shared/interoperable-errors'); const log = require('npmlog'); const {getGlobalNamespaceId} = require('../shared/namespaces'); @@ -15,7 +15,7 @@ const {getGlobalNamespaceId} = require('../shared/namespaces'); async function listByEntityDTAjax(context, entityTypeId, entityId, params) { return await knex.transaction(async (tx) => { - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); return await dtHelpers.ajaxListTx( @@ -41,7 +41,7 @@ async function listByUserDTAjax(context, entityTypeId, userId, params) { await enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers'); - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); return await dtHelpers.ajaxListWithPermissionsTx( tx, @@ -61,7 +61,7 @@ async function listByUserDTAjax(context, entityTypeId, userId, params) { async function listUnassignedUsersDTAjax(context, entityTypeId, entityId, params) { return await knex.transaction(async (tx) => { - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); @@ -93,7 +93,7 @@ async function listRolesDTAjax(entityTypeId, params) { } async function assign(context, entityTypeId, entityId, userId, role) { - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); await knex.transaction(async tx => { await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); @@ -129,17 +129,17 @@ async function assign(context, entityTypeId, entityId, userId, role) { async function rebuildPermissionsTx(tx, restriction) { restriction = restriction || {}; - const namespaceEntityType = permissions.getEntityType('namespace'); + const namespaceEntityType = entitySettings.getEntityType('namespace'); // Collect entity types we care about let restrictedEntityTypes; if (restriction.entityTypeId) { - const entityType = permissions.getEntityType(restriction.entityTypeId); + const entityType = entitySettings.getEntityType(restriction.entityTypeId); restrictedEntityTypes = { [restriction.entityTypeId]: entityType }; } else { - restrictedEntityTypes = permissions.getEntityTypes(); + restrictedEntityTypes = entitySettings.getEntityTypes(); } @@ -374,7 +374,7 @@ async function regenerateRoleNamesTable() { await knex.transaction(async tx => { await tx('generated_role_names').del(); - const entityTypeIds = ['global', ...Object.keys(permissions.getEntityTypes())]; + const entityTypeIds = ['global', ...Object.keys(entitySettings.getEntityTypes())]; for (const entityTypeId of entityTypeIds) { const roles = config.roles[entityTypeId]; @@ -397,7 +397,7 @@ function throwPermissionDenied() { } async function removeDefaultShares(tx, user) { - const namespaceEntityType = permissions.getEntityType('namespace'); + const namespaceEntityType = entitySettings.getEntityType('namespace'); const roleConf = config.roles.global[user.role]; @@ -467,7 +467,7 @@ async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredO return false; } - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); if (typeof requiredOperations === 'string') { requiredOperations = [ requiredOperations ]; @@ -603,7 +603,7 @@ async function getPermissionsTx(tx, context, entityTypeId, entityId) { enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); - const entityType = permissions.getEntityType(entityTypeId); + const entityType = entitySettings.getEntityType(entityTypeId); const rows = await tx(entityType.permissionsTable) .select('operation') diff --git a/routes/mosaico.js b/routes/mosaico.js index 32ae59bb..07bc4980 100644 --- a/routes/mosaico.js +++ b/routes/mosaico.js @@ -201,7 +201,7 @@ function getRouter(trusted) { }); } else { - router.getAsync('/img/:type/:entityId', async (req, res) => { + router.getAsync('/img', async (req, res) => { const method = req.query.method; const params = req.query.params; let [width, height] = params.split(','); @@ -225,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, 'file', req.params.entityId, url); + const file = await files.getFileByUrl(contextHelpers.getAdminContext(), url); filePath = file.path; } diff --git a/routes/rest/campaigns.js b/routes/rest/campaigns.js index 9c9df754..e3f4304d 100644 --- a/routes/rest/campaigns.js +++ b/routes/rest/campaigns.js @@ -10,9 +10,19 @@ router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => { return res.json(await campaigns.listDTAjax(req.context, req.body)); }); -router.getAsync('/campaigns/:campaignId', passport.loggedIn, async (req, res) => { - const campaign = await campaigns.getById(req.context, req.params.campaignId); - campaign.hash = campaigns.hash(campaign); +router.postAsync('/campaigns-with-content-table', passport.loggedIn, async (req, res) => { + return res.json(await campaigns.listWithContentDTAjax(req.context, req.body)); +}); + +router.getAsync('/campaigns-settings/:campaignId', passport.loggedIn, async (req, res) => { + const campaign = await campaigns.getById(req.context, req.params.campaignId, true, campaigns.Content.WITHOUT_SOURCE_CUSTOM); + campaign.hash = campaigns.hash(campaign, campaigns.Content.WITHOUT_SOURCE_CUSTOM); + return res.json(campaign); +}); + +router.getAsync('/campaigns-content/:campaignId', passport.loggedIn, async (req, res) => { + const campaign = await campaigns.getById(req.context, req.params.campaignId, true, campaigns.Content.ONLY_SOURCE_CUSTOM); + campaign.hash = campaigns.hash(campaign, campaigns.Content.ONLY_SOURCE_CUSTOM); return res.json(campaign); }); @@ -20,11 +30,19 @@ router.postAsync('/campaigns', passport.loggedIn, passport.csrfProtection, async return res.json(await campaigns.create(req.context, req.body)); }); -router.putAsync('/campaigns/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => { +router.putAsync('/campaigns-settings/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => { const entity = req.body; entity.id = parseInt(req.params.campaignId); - await campaigns.updateWithConsistencyCheck(req.context, entity); + await campaigns.updateWithConsistencyCheck(req.context, entity, campaigns.Content.WITHOUT_SOURCE_CUSTOM); + return res.json(); +}); + +router.putAsync('/campaigns-content/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => { + const entity = req.body; + entity.id = parseInt(req.params.campaignId); + + await campaigns.updateWithConsistencyCheck(req.context, entity, campaigns.Content.ONLY_SOURCE_CUSTOM); return res.json(); }); diff --git a/routes/rest/files.js b/routes/rest/files.js index f3b9216f..6b99fa23 100644 --- a/routes/rest/files.js +++ b/routes/rest/files.js @@ -20,6 +20,6 @@ router.deleteAsync('/files/:type/:subType/:fileId', passport.loggedIn, async (re return res.json(); }); -fileHelpers.installUploadHandler(router, '/files/:type/:subType/:entityId', files.ReplacementBehavior.REPLACE); +fileHelpers.installUploadHandler(router, '/files/:type/:subType/:entityId'); module.exports = router; \ No newline at end of file diff --git a/routes/rest/shares.js b/routes/rest/shares.js index 21e13eed..967d89c3 100644 --- a/routes/rest/shares.js +++ b/routes/rest/shares.js @@ -3,7 +3,6 @@ const passport = require('../../lib/passport'); const _ = require('../../lib/translate')._; const shares = require('../../models/shares'); -const permissions = require('../../lib/permissions'); const router = require('../../lib/router-async').create(); diff --git a/setup/knex/migrations/20170506102634_v1_to_v2.js b/setup/knex/migrations/20170506102634_v1_to_v2.js index 407a348b..e87a454a 100644 --- a/setup/knex/migrations/20170506102634_v1_to_v2.js +++ b/setup/knex/migrations/20170506102634_v1_to_v2.js @@ -940,7 +940,7 @@ async function migrateAttachments(knex) { data: attachment.content }); } - await files.createFiles(contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id, attachmentFiles, files.ReplacementBehavior.NONE); + await files.createFiles(contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id, attachmentFiles); } await knex.schema.dropTableIfExists('attachments'); diff --git a/shared/templates.js b/shared/templates.js index 488cb688..069d60fe 100644 --- a/shared/templates.js +++ b/shared/templates.js @@ -11,7 +11,12 @@ function base(text, trustedBaseUrl, sandboxBaseUrl) { sandboxBaseUrl = sandboxBaseUrl.substring(0, sandboxBaseUrl.length - 1); } - return text.split('[URL_BASE]').join(trustedBaseUrl).split('[SANDBOX_URL_BASE]').join(sandboxBaseUrl); + text = text.split('[URL_BASE]').join(trustedBaseUrl); + text = text.split('[SANDBOX_URL_BASE]').join(sandboxBaseUrl); + text = text.split('[ENCODED_URL_BASE]').join(encodeURIComponent(trustedBaseUrl)); + text = text.split('[ENCODED_SANDBOX_URL_BASE]').join(encodeURIComponent(sandboxBaseUrl)); + + return text; } function unbase(text, trustedBaseUrl, sandboxBaseUrl, treatSandboxAsTrusted = false) { @@ -23,7 +28,12 @@ function unbase(text, trustedBaseUrl, sandboxBaseUrl, treatSandboxAsTrusted = fa sandboxBaseUrl = sandboxBaseUrl.substring(0, sandboxBaseUrl.length - 1); } - return text.split(trustedBaseUrl).join('[URL_BASE]').split(sandboxBaseUrl).join(treatSandboxAsTrusted ? '[URL_BASE]' : '[SANDBOX_URL_BASE]'); + text = text.split(trustedBaseUrl).join('[URL_BASE]'); + text = text.split(sandboxBaseUrl).join(treatSandboxAsTrusted ? '[URL_BASE]' : '[SANDBOX_URL_BASE]'); + text = text.split(encodeURIComponent(trustedBaseUrl)).join('[ENCODED_URL_BASE]'); + text = text.split(encodeURIComponent(sandboxBaseUrl)).join(treatSandboxAsTrusted ? '[ENCODED_URL_BASE]' : '[ENCODED_SANDBOX_URL_BASE]'); + + return text; } module.exports = {