Beginning of work on templates.

This commit is contained in:
Tomas Bures 2018-02-13 23:50:13 +01:00
parent 47b8d80c22
commit 508d6b3b2f
40 changed files with 1685 additions and 1031 deletions

20
app.js
View file

@ -27,15 +27,10 @@ const routes = require('./routes/index');
const lists = require('./routes/lists-legacy'); const lists = require('./routes/lists-legacy');
//const settings = require('./routes/settings'); //const settings = require('./routes/settings');
const getSettings = nodeifyFunction(require('./models/settings').get); const getSettings = nodeifyFunction(require('./models/settings').get);
const templates = require('./routes/templates');
const campaigns = require('./routes/campaigns'); const campaigns = require('./routes/campaigns');
const links = require('./routes/links'); const links = require('./routes/links');
const fields = require('./routes/fields');
const forms = require('./routes/forms-legacy');
const segments = require('./routes/segments');
const triggers = require('./routes/triggers'); const triggers = require('./routes/triggers');
const webhooks = require('./routes/webhooks'); const webhooks = require('./routes/webhooks');
const subscription = require('./routes/subscription');
const archive = require('./routes/archive'); const archive = require('./routes/archive');
const api = require('./routes/api'); const api = require('./routes/api');
const editorapi = require('./routes/editorapi'); const editorapi = require('./routes/editorapi');
@ -44,6 +39,7 @@ const mosaico = require('./routes/mosaico');
// These are routes for the new React-based client // These are routes for the new React-based client
const reports = require('./routes/reports'); const reports = require('./routes/reports');
const subscription = require('./routes/subscription');
const namespacesRest = require('./routes/rest/namespaces'); const namespacesRest = require('./routes/rest/namespaces');
const usersRest = require('./routes/rest/users'); const usersRest = require('./routes/rest/users');
@ -57,13 +53,16 @@ const fieldsRest = require('./routes/rest/fields');
const sharesRest = require('./routes/rest/shares'); const sharesRest = require('./routes/rest/shares');
const segmentsRest = require('./routes/rest/segments'); const segmentsRest = require('./routes/rest/segments');
const subscriptionsRest = require('./routes/rest/subscriptions'); const subscriptionsRest = require('./routes/rest/subscriptions');
const templatesRest = require('./routes/rest/templates');
const blacklistRest = require('./routes/rest/blacklist'); const blacklistRest = require('./routes/rest/blacklist');
const editorsRest = require('./routes/rest/editors');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration'); const usersLegacyIntegration = require('./routes/users-legacy-integration');
const accountLegacyIntegration = require('./routes/account-legacy-integration'); const accountLegacyIntegration = require('./routes/account-legacy-integration');
const reportsLegacyIntegration = require('./routes/reports-legacy-integration'); const reportsLegacyIntegration = require('./routes/reports-legacy-integration');
const listsLegacyIntegration = require('./routes/lists-legacy-integration'); const listsLegacyIntegration = require('./routes/lists-legacy-integration');
const templatesLegacyIntegration = require('./routes/templates-legacy-integration');
const blacklistLegacyIntegration = require('./routes/blacklist-legacy-integration'); const blacklistLegacyIntegration = require('./routes/blacklist-legacy-integration');
const interoperableErrors = require('./shared/interoperable-errors'); const interoperableErrors = require('./shared/interoperable-errors');
@ -254,22 +253,20 @@ app.use((req, res, next) => {
// Regular endpoints // Regular endpoints
app.use('/', routes); app.use('/', routes);
app.use('/lists', lists); app.use('/lists', lists);
app.use('/templates', templates);
app.use('/campaigns', campaigns); app.use('/campaigns', campaigns);
//app.use('/settings', settings); //app.use('/settings', settings);
app.use('/links', links); app.use('/links', links);
app.use('/fields', fields);
app.use('/forms', forms);
app.use('/segments', segments);
app.use('/triggers', triggers); app.use('/triggers', triggers);
app.use('/webhooks', webhooks); app.use('/webhooks', webhooks);
app.use('/subscription', subscription);
app.use('/archive', archive); app.use('/archive', archive);
app.use('/editorapi', editorapi); app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs); app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico); app.use('/mosaico', mosaico);
app.use('/subscription', subscription);
// API endpoints // API endpoints
app.use('/api', api); app.use('/api', api);
@ -283,6 +280,7 @@ app.use('/users', usersLegacyIntegration);
app.use('/namespaces', namespacesLegacyIntegration); app.use('/namespaces', namespacesLegacyIntegration);
app.use('/account', accountLegacyIntegration); app.use('/account', accountLegacyIntegration);
app.use('/lists', listsLegacyIntegration); app.use('/lists', listsLegacyIntegration);
app.use('/templates', templatesLegacyIntegration);
app.use('/blacklist', blacklistLegacyIntegration); app.use('/blacklist', blacklistLegacyIntegration);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
@ -302,7 +300,9 @@ app.use('/rest', fieldsRest);
app.use('/rest', sharesRest); app.use('/rest', sharesRest);
app.use('/rest', segmentsRest); app.use('/rest', segmentsRest);
app.use('/rest', subscriptionsRest); app.use('/rest', subscriptionsRest);
app.use('/rest', templatesRest);
app.use('/rest', blacklistRest); app.use('/rest', blacklistRest);
app.use('/rest', editorsRest);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest); app.use('/rest', reportTemplatesRest);

View file

@ -212,6 +212,11 @@
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true "dev": true
}, },
"attr-accept": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.0.tgz",
"integrity": "sha1-tc01In8WOTWo8d4Q7T66FpQfa+Y="
},
"autoprefixer": { "autoprefixer": {
"version": "6.7.7", "version": "6.7.7",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz",
@ -4858,6 +4863,15 @@
"prop-types": "15.6.0" "prop-types": "15.6.0"
} }
}, },
"react-dropzone": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-4.2.7.tgz",
"integrity": "sha512-BGEc/UtG0rHBEZjAkGsajPRO85d842LWeaP4CINHvXrSNyKp7Tq7s699NyZwWYHahvXaUNZzNJ17JMrfg5sxVg==",
"requires": {
"attr-accept": "1.1.0",
"prop-types": "15.6.0"
}
},
"react-i18next": { "react-i18next": {
"version": "4.8.0", "version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-4.8.0.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-4.8.0.tgz",

View file

@ -29,13 +29,14 @@
"react": "^15.6.1", "react": "^15.6.1",
"react-ace": "^5.1.0", "react-ace": "^5.1.0",
"react-day-picker": "^6.1.0", "react-day-picker": "^6.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"react-dom": "^15.6.1", "react-dom": "^15.6.1",
"react-dropzone": "^4.2.1",
"react-i18next": "^4.6.1", "react-i18next": "^4.6.1",
"react-router-dom": "^4.1.1", "react-router-dom": "^4.1.1",
"react-sortable-tree": "^1.2.0", "react-sortable-tree": "^1.2.0",
"slugify": "^1.1.0", "slugify": "^1.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"url-parse": "^1.1.9" "url-parse": "^1.1.9"
}, },
"devDependencies": { "devDependencies": {

148
client/src/lib/files.js Normal file
View file

@ -0,0 +1,148 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {translate} from "react-i18next";
import {requiresAuthenticatedUser} from "./lib/page";
import {ACEEditor, Button, Form, FormSendMethod, withForm} from "./lib/form";
import {withErrorHandling} from "./lib/error-handling";
import {Table} from "./lib/table";
import Dropzone from "react-dropzone";
import {ModalDialog} from "./lib/modals";
import {Icon} from "./lib/bootstrap-components";
import axios from './axios';
@translate()
@withForm
@withErrorHandling
@requiresAuthenticatedUser
export default class Files extends Component {
constructor(props) {
super(props);
this.state = {
fileToDeleteName: null,
fileToDeleteId: null
};
const t = props.t;
this.initForm();
}
static propTypes = {
title: PropTypes.string,
entity: PropTypes.object,
entityTypeId: PropTypes.string
}
getFilesUploadedMessage(response){
const t = this.props.t;
const details = [];
if (response.data.added) {
details.push(t('{{count}} file(s) added', {count: response.data.added}));
}
if (response.data.replaced) {
details.push(t('{{count}} file(s) replaced', {count: response.data.replaced}));
}
if (response.data.ignored) {
details.push(t('{{count}} file(s) ignored', {count: response.data.ignored}));
}
const detailsMessage = details ? ' (' + details.join(', ') + ')' : '';
return t('{{count}} file(s) uploaded', {count: response.data.uploaded}) + detailsMessage;
}
onDrop(files){
const t = this.props.t;
if (files.length > 0) {
this.setFormStatusMessage('info', t('Uploading {{count}} file(s)', files.length));
const data = new FormData();
for (const file of files) {
data.append('file', file)
}
axios.put(`/rest/files/${this.props.entityTypeId}, ${this.props.entity.id}`, data)
.then(res => {
this.filesTable.refresh();
const message = this.getFilesUploadedMessage(res);
this.setFormStatusMessage('info', message);
})
.catch(res => this.setFormStatusMessage('danger', t('File upload failed: ') + res.message));
}
else{
this.setFormStatusMessage('info', t('No files to upload'));
}
}
deleteFile(fileId, fileName){
this.setState({fileToDeleteId: fileId, fileToDeleteName: fileName})
}
async hideDeleteFile(){
this.setState({fileToDeleteId: null, fileToDeleteName: null})
}
async performDeleteFile() {
const t = this.props.t;
const fileToDeleteId = this.state.fileToDeleteId;
await this.hideDeleteFile();
try {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting file ...'));
await axios.delete(`/rest/files/${this.props.entityTypeId}/${fileToDeleteId}`);
this.filesTable.refresh();
this.setFormStatusMessage('info', t('File deleted'));
this.enableForm();
} catch (err) {
this.filesTable.refresh();
this.setFormStatusMessage('danger', t('Delete file failed: ') + err.message);
this.enableForm();
}
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: "Name" },
{ data: 2, title: "Size" },
{
actions: data => {
const actions = [
{
label: <Icon icon="download" title={t('Download')}/>,
href: `/rest/files/${this.props.entityTypeId}/${data[0]}`
},
{
label: <Icon icon="remove" title={t('Delete')}/>,
action: () => this.deleteFile(data[0], data[1])
}
];
return actions;
}
}
];
return (
<div>
<ModalDialog
hidden={this.state.fileToDeleteId === null}
title={t('Confirm file deletion')}
onCloseAsync={::this.hideDeleteFile}
buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteFile },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDeleteFile }
]}>
{t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})}
</ModalDialog>
<Dropzone onDrop={::this.onDrop} className="dropZone" activeClassName="dropZoneActive">
{state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')}
</Dropzone>
<Table withHeader ref={node => this.filesTable = node} dataUrl={`/rest/template-files-table/${this.props.entity.id}`} columns={columns} />
</div>
);
}
}

View file

@ -13,9 +13,11 @@ import { Table, TableSelectMode } from './table';
import {Button, Icon} from "./bootstrap-components"; import {Button, Icon} from "./bootstrap-components";
import brace from 'brace'; import brace from 'brace';
import AceEditor from 'react-ace'; import ACEEditorRaw from 'react-ace';
import 'brace/theme/github'; import 'brace/theme/github';
import CKEditorRaw from "react-ckeditor-component";
import DayPicker from 'react-day-picker'; import DayPicker from 'react-day-picker';
import 'react-day-picker/lib/style.css'; import 'react-day-picker/lib/style.css';
import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../shared/date'; import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../shared/date';
@ -823,7 +825,7 @@ class ACEEditor extends Component {
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<AceEditor <ACEEditorRaw
id={htmlId} id={htmlId}
mode={props.mode} mode={props.mode}
theme="github" theme="github"
@ -841,6 +843,35 @@ class ACEEditor extends Component {
} }
class CKEditor extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
height: PropTypes.string
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<CKEditorRaw
onChange={evt => owner.updateFormValue(id, evt.editor.getData())}
content={owner.getFormValue(id)}
config={{width: '100%', height: props.height}}
/>
);
}
}
function withForm(target) { function withForm(target) {
const inst = target.prototype; const inst = target.prototype;
@ -1251,5 +1282,6 @@ export {
TableSelect, TableSelect,
TableSelectMode, TableSelectMode,
ACEEditor, ACEEditor,
CKEditor,
FormSendMethod FormSendMethod
} }

View file

@ -165,7 +165,7 @@ export default class CUD extends Component {
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
{isEdit && {isEdit &&
<StaticField id="cid" className={styles.formDisabled} label="List ID" help={t('This is the list ID displayed to the subscribers')}> <StaticField id="cid" className={styles.formDisabled} label={t('List ID')} help={t('This is the list ID displayed to the subscribers')}>
{this.getFormValue('cid')} {this.getFormValue('cid')}
</StaticField> </StaticField>
} }

View file

@ -56,22 +56,19 @@ export default class CUD extends Component {
@withAsyncErrorHandler @withAsyncErrorHandler
async loadTreeData() { async loadTreeData() {
axios.get('/rest/namespaces-tree') const response = await axios.get('/rest/namespaces-tree');
.then(response => { const data = response.data;
for (const root of data) {
root.expanded = true;
}
const data = response.data; if (this.props.entity && !this.isEditGlobal()) {
for (const root of data) { this.removeNsIdSubtree(data);
root.expanded = true; }
}
if (this.props.entity && !this.isEditGlobal()) { this.setState({
this.removeNsIdSubtree(data); treeData: data
} });
this.setState({
treeData: data
});
});
} }
componentDidMount() { componentDidMount() {

View file

@ -218,7 +218,7 @@ export default class CUD extends Component {
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/reports/${this.props.entity.id}`} deleteUrl={`/rest/reports/${this.props.entity.id}`}
cudUrl={`/reports/${this.props.entity.id}/edit`} cudUrl={`/reports/${this.props.entity.id}/edit`}
listUrl="/reports" listUrl="/reports"
deletingMsg={t('Deleting report ...')} deletingMsg={t('Deleting report ...')}

View file

@ -282,7 +282,7 @@ export default class CUD extends Component {
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/reports/templates/${this.props.entity.id}`} deleteUrl={`/rest/reports/templates/${this.props.entity.id}`}
cudUrl={`/reports/templates/${this.props.entity.id}/edit`} cudUrl={`/reports/templates/${this.props.entity.id}/edit`}
listUrl="/reports/templates" listUrl="/reports/templates"
deletingMsg={t('Deleting report template ...')} deletingMsg={t('Deleting report template ...')}

309
client/src/templates/CUD.js Normal file
View file

@ -0,0 +1,309 @@
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page'
import {
withForm,
Form,
FormSendMethod,
InputField,
TextArea,
Dropdown,
ACEEditor,
ButtonRow,
Button,
AlignedRow,
StaticField
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import { getTemplateTypes } from './helpers';
import {ActionLink} from "../lib/bootstrap-components";
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.state = {
showMergeTagReference: false
};
this.initForm();
}
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/templates/${this.props.entity.id}`);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0],
text: '',
html: ''
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['type', 'value'])) {
state.setIn(['type', 'error'], t('Type must be selected'));
} else {
state.setIn(['type', 'error'], null);
}
validateNamespace(t, state);
}
async submitHandler() {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `/rest/templates/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/templates'
}
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
});
if (submitResponse) {
if (this.props.entity) {
this.navigateToWithFlashMessage('/templates', 'success', t('Template saved'));
} else {
this.navigateToWithFlashMessage(`/templates/${submitResponse}/edit`, 'success', t('Template saved'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
async extractPlainText() {
const html = this.getFormValue('html');
if (!html) {
alert('Missing HTML content');
return;
}
if (this.isFormDisabled()) {
return;
}
this.disableForm();
const response = await axios.post('/rest/html-to-text', { html });
this.updateFormValue('text', response.data.text);
this.enableForm();
}
async toggleMergeTagReference() {
this.setState({
showMergeTagReference: !this.state.showMergeTagReference
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const typeOptions = [];
for (const key of mailtrainConfig.editors) {
typeOptions.push({key, label: this.templateTypes[key].typeName});
}
// TODO: Toggle HTML preview
const typeKey = this.getFormValue('type');
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/rest/templates/${this.props.entity.id}`}
cudUrl={`/templates/${this.props.entity.id}/edit`}
listUrl="/templates"
deletingMsg={t('Deleting template ...')}
deletedMsg={t('Template deleted')}/>
}
<Title>{isEdit ? t('Edit Template') : t('Create Template')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
{isEdit
?
<StaticField id="type" className={styles.formDisabled} label={t('Type')}>
{typeKey && this.templateTypes[typeKey].typeName}
</StaticField>
:
<Dropdown id="type" label={t('Type')} options={typeOptions}/>
}
<NamespaceSelect/>
<AlignedRow>
<Button className="btn-default" onClickAsync={::this.toggleMergeTagReference} label={t('Merge tag reference')}/>
{this.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}>
<Trans><p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p></Trans>
<table className="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>
<Trans>Merge tag</Trans>
</th>
<th>
<Trans>Description</Trans>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
[LINK_UNSUBSCRIBE]
</th>
<td>
<Trans>URL that points to the unsubscribe page</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_PREFERENCES]
</th>
<td>
<Trans>URL that points to the preferences page of the subscriber</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_BROWSER]
</th>
<td>
<Trans>URL to preview the message in a browser</Trans>
</td>
</tr>
<tr>
<th scope="row">
[EMAIL]
</th>
<td>
<Trans>Email address</Trans>
</td>
</tr>
<tr>
<th scope="row">
[FIRST_NAME]
</th>
<td>
<Trans>First name</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LAST_NAME]
</th>
<td>
<Trans>Last name</Trans>
</td>
</tr>
<tr>
<th scope="row">
[FULL_NAME]
</th>
<td>
<Trans>Full name (first and last name combined)</Trans>
</td>
</tr>
<tr>
<th scope="row">
[SUBSCRIPTION_ID]
</th>
<td>
<Trans>Unique ID that identifies the recipient</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LIST_ID]
</th>
<td>
<Trans>Unique ID that identifies the list used for this campaign</Trans>
</td>
</tr>
<tr>
<th scope="row">
[CAMPAIGN_ID]
</th>
<td>
<Trans>Unique ID that identifies current campaign</Trans>
</td>
</tr>
</tbody>
</table>
<Trans><p>In addition to that any custom field can have its own merge tag.</p></Trans>
</div>}
</AlignedRow>
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
{isEdit && typeKey && this.templateTypes[typeKey].form}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={isEdit ? t('Save') : t('Save and edit template')}/>
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/templates/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

View file

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

View file

@ -0,0 +1,35 @@
'use strict';
import React from "react";
import {ACEEditor, SummernoteEditor} from "../lib/form";
import 'brace/mode/text';
import 'brace/mode/html'
export function getTemplateTypes(t) {
const templateTypes = {};
templateTypes.mosaico = {
typeName: t('Mosaico')
};
templateTypes.grapejs = {
typeName: t('GrapeJS')
};
templateTypes.ckeditor = {
typeName: t('CKEditor'),
form: <SummernoteEditor id="html" height="600px" label={t('Template content (HTML)')}/>
};
templateTypes.codeeditor = {
typeName: t('Code Editor'),
form: <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>
};
templateTypes.mjml = {
typeName: t('MJML')
};
return templateTypes;
}

View file

@ -0,0 +1,64 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';
const getStructure = t => {
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
'templates': {
title: t('Templates'),
link: '/templates',
panelComponent: TemplatesList,
children: {
':templateId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.template.name}),
resolve: {
template: params => `/rest/templates/${params.templateId}`
},
link: params => `/templates/${params.templateId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/templates/${params.templateId}/edit`,
visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
},
share: {
title: t('Share'),
link: params => `/templates/${params.templateId}/share`,
visible: resolved => resolved.template.permissions.includes('share'),
panelRender: props => <Share title={t('Share')} entity={props.resolved.template} entityTypeId="template" />
}
}
},
create: {
title: t('Create'),
panelRender: props => <TemplatesCUD action="create" />
}
}
}
}
}
}
};
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/templates' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -196,7 +196,7 @@ export default class CUD extends Component {
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/users/${this.props.entity.id}`} deleteUrl={`/rest/users/${this.props.entity.id}`}
cudUrl={`/users/${this.props.entity.id}/edit`} cudUrl={`/users/${this.props.entity.id}/edit`}
listUrl="/users" listUrl="/users"
deletingMsg={t('Deleting user ...')} deletingMsg={t('Deleting user ...')}

View file

@ -8,6 +8,7 @@ module.exports = {
account: ['babel-polyfill', './src/account/root.js'], account: ['babel-polyfill', './src/account/root.js'],
reports: ['babel-polyfill', './src/reports/root.js'], reports: ['babel-polyfill', './src/reports/root.js'],
lists: ['babel-polyfill', './src/lists/root.js'], lists: ['babel-polyfill', './src/lists/root.js'],
templates: ['babel-polyfill', './src/templates/root.js'],
blacklist: ['babel-polyfill', './src/blacklist/root.js'] blacklist: ['babel-polyfill', './src/blacklist/root.js']
}, },
output: { output: {

View file

@ -22,12 +22,7 @@
title="mailtrain" title="mailtrain"
# Enabled HTML editors # Enabled HTML editors
editors=[ editors=["ckeditor", "codeeditor"]
["summernote", "Summernote"],
["grapejs", "GrapeJS"],
["mosaico", "Mosaico"],
["codeeditor", "Code Editor"]
]
# Default language to use # Default language to use
language="en" language="en"
@ -203,7 +198,7 @@ rootNamespaceRole="master"
[roles.namespace.master] [roles.namespace.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "manageUsers"] permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"]
[roles.namespace.master.children] [roles.namespace.master.children]
list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"]
@ -212,7 +207,7 @@ campaign=["view", "edit", "delete", "share"]
template=["view", "edit", "delete", "share"] template=["view", "edit", "delete", "share"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
reportTemplate=["view", "edit", "delete", "share", "execute"] reportTemplate=["view", "edit", "delete", "share", "execute"]
namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "manageUsers"] namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"]
[roles.list.master] [roles.list.master]
name="Master" name="Master"

View file

@ -23,7 +23,8 @@ async function getAuthenticatedConfig(context) {
id: context.user.id, id: context.user.id,
namespace: context.user.namespace namespace: context.user.namespace
}, },
globalPermissions: shares.getGlobalPermissions(context) globalPermissions: shares.getGlobalPermissions(context),
editors: config.editors
} }
} }

View file

@ -630,8 +630,14 @@ async function forHbs(context, listId, subscription) { // assumes grouped subscr
return customFields; return customFields;
} }
// Converts subscription data received via POST request from subscription form or via subscribe request to API v1 to subscription structure supported by subscriptions model.
async function fromPost(context, listId, data) { // assumes grouped subscription async function fromPost(context, listId, data) { // assumes grouped subscription
// This is to handle option values from API v1
function isSelected(value) {
return ['false', 'no', '0', ''].indexOf((value || '').toString().trim().toLowerCase()) >= 0 ? false : true;
}
const flds = await listGrouped(context, listId); const flds = await listGrouped(context, listId);
const subscription = {}; const subscription = {};
@ -650,7 +656,10 @@ async function fromPost(context, listId, data) { // assumes grouped subscription
for (const optCol in fld.groupedOptions) { for (const optCol in fld.groupedOptions) {
const opt = fld.groupedOptions[optCol]; const opt = fld.groupedOptions[optCol];
if (data[fld.key] === opt.key) { // This handles two different formats for grouped dropdowns and radios.
// The first part of the condition handles the POST requests from the subscription form, while the
// second part handles the subscribe request to API v1
if (data[fld.key] === opt.key || isSelected(data[opt.key])) {
value = opt.column value = opt.column
} }
} }
@ -660,7 +669,7 @@ async function fromPost(context, listId, data) { // assumes grouped subscription
for (const optCol in fld.groupedOptions) { for (const optCol in fld.groupedOptions) {
const opt = fld.groupedOptions[optCol]; const opt = fld.groupedOptions[optCol];
if (data[opt.key]) { if (isSelected(data[opt.key])) {
value.push(opt.column); value.push(opt.column);
} }
} }

155
models/files.js Normal file
View file

@ -0,0 +1,155 @@
'use strict';
const knex = require('../lib/knex');
const { enforce } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const shares = require('./shares');
const fs = require('fs-extra-promise');
const path = require('path');
const filesDir = path.join(__dirname, '..', 'files');
const permittedTypes = new Set(['template']);
function getFilePath(type, entityId, filename) {
return path.join(path.join(filesDir, type, id.toString()), filename);
}
function getFilesTable(type) {
return 'files_' + type;
}
async function listFilesDTAjax(context, type, entityId, params) {
enforce(permittedTypes.has(type));
await shares.enforceEntityPermission(context, type, entityId, 'edit');
return await dtHelpers.ajaxList(
params,
builder => builder.from(getFilesTable(type)).where({entity: entityId}),
['id', 'originalname', 'size', 'created']
);
}
async function getFileById(context, type, id) {
enforce(permittedTypes.has(type));
const file = await knex.transaction(async tx => {
const file = await knex(getFilesTable(type)).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'edit');
return file;
});
return {
mimetype: file.mimetype,
name: file.originalname,
path: getFilePath(type, file.entity, file.filename)
};
}
async function getFileByName(context, type, entityId, name) {
enforce(permittedTypes.has(type));
const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view');
const file = await knex(getFilesTable(type)).where({entity: entityId, originalname: name}).first();
return file;
});
return {
mimetype: file.mimetype,
name: file.originalname,
path: getFilePath(type, file.entity, file.filename)
};
}
async function createFiles(context, type, entityId, files) {
enforce(permittedTypes.has(type));
if (files.length == 0) {
// No files uploaded
return {uploaded: 0};
}
const originalNameSet = new Set();
const fileEntities = new Array();
const filesToMove = new Array();
const ignoredFiles = new Array();
// Create entities for files
for (const file of files) {
if (originalNameSet.has(file.originalname)) {
// The file has an original name same as another file
ignoredFiles.push(file);
} else {
originalNameSet.add(file.originalname);
const fileEntity = {
entity: entityId,
filename: file.filename,
originalname: file.originalname,
mimetype: file.mimetype,
encoding: file.encoding,
size: file.size
};
fileEntities.push(fileEntity);
filesToMove.push(file);
}
}
const originalNameArray = Array.from(originalNameSet);
const removedFiles = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'edit');
const removedFiles = await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).select(['filename', 'originalname']);
await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
if(fileEntities){
await knex(getFilesTable(type)).insert(fileEntities);
}
return removedFiles;
});
const removedNameSet = new Set();
// Move new files from upload directory to files directory
for(const file of filesToMove){
const filePath = getFilePath(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.move(file.path, filePath, {});
}
// Remove replaced files from files directory
for(const file of removedFiles){
removedNameSet.add(file.originalname);
const filePath = getFilePath(type, entityId, file.filename);
await fs.remove(filePath);
}
// Remove ignored files from upload directory
for(const file of ignoredFiles){
await fs.remove(file.path);
}
return {
uploaded: files.length,
added: fileEntities.length - removedNameSet.size,
replaced: removedFiles.length,
ignored: ignoredFiles.length
};
}
async function removeFile(context, type, id) {
const file = await knex.transaction(async tx => {
const file = await knex(getFilesTable(type)).where('id', id).select('entity', 'filename').first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'edit');
await tx(getFilesTable(type)).where('id', id).del();
return {filename: file.filename, entity: file.entity};
});
const filePath = getFilePath(type, file.entity, file.filename);
await fs.remove(filePath);
}
module.exports = {
listFilesDTAjax,
getFileById,
getFileByName,
createFiles,
removeFile
};

View file

@ -400,7 +400,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
} }
const existingWithKey = await existingWithKeyQuery.first(); const existingWithKey = await existingWithKeyQuery.first();
if (existingWithKey) { if (existingWithKey) {
if (meta && meta.replaceOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED) { if (meta && (meta.updateAllowed || meta.updateOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED)) {
meta.update = true; meta.update = true;
meta.existing = existingWithKey; meta.existing = existingWithKey;
} else { } else {
@ -476,7 +476,7 @@ async function create(context, listId, entity, meta /* meta is provided when cal
filteredEntity.opt_in_country = meta && meta.country; filteredEntity.opt_in_country = meta && meta.country;
filteredEntity.imported = meta && !!meta.imported; filteredEntity.imported = meta && !!meta.imported;
if (meta && meta.update) { if (meta && meta.update) { // meta.update is set by _validateAndPreprocess
await _update(tx, listId, meta.existing, filteredEntity); await _update(tx, listId, meta.existing, filteredEntity);
meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid
return meta.existing.id; return meta.existing.id;

91
models/templates.js Normal file
View file

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

View file

@ -42,31 +42,17 @@ router.postAsync('/subscribe/:listId', async (req, res) => {
throw new APIError(errMsg, 400); throw new APIError(errMsg, 400);
} }
const subscription = {
email: input.EMAIL
};
if (input.TIMEZONE) { if (input.TIMEZONE) {
subscription.tz = (input.TIMEZONE || '').toString().trim(); subscription.tz = (input.TIMEZONE || '').toString().trim();
} }
const fieldList = await fields.fromPost(req.context, listId); const subscription = await fields.fromPost(req.context, listId);
for (const field of fieldList) {
if (field.key in input && field.column) {
if (field.type === 'option') {
subscription[field.column] = ['false', 'no', '0', ''].indexOf((input[field.key] || '').toString().trim().toLowerCase()) >= 0 ? '' : '1';
} else {
subscription[field.column] = input[field.key];
}
}
}
if (/^(yes|true|1)$/i.test(input.FORCE_SUBSCRIBE)) { if (/^(yes|true|1)$/i.test(input.FORCE_SUBSCRIBE)) {
subscription.status = SubscriptionStatus.SUBSCRIBED; subscription.status = SubscriptionStatus.SUBSCRIBED;
} }
if (/^(yes|true|1)$/i.test(input.REQUIRE_CONFIRMATION)) { if (/^(yes|true|1)$/i.test(input.REQUIRE_CONFIRMATION)) { // if REQUIRE_CONFIRMATION is set, we assume that the user is not subscribed and will be subscribed
const list = await lists.getByCid(contextHelpers.getAdminContext(), listId); const list = await lists.getByCid(contextHelpers.getAdminContext(), listId);
await shares.enforceEntityPermission(req.context, 'list', listId, 'manageSubscriptions'); await shares.enforceEntityPermission(req.context, 'list', listId, 'manageSubscriptions');
@ -85,7 +71,12 @@ router.postAsync('/subscribe/:listId', async (req, res) => {
} }
}); });
} else { } else {
const meta = {}; subscription.email = input.EMAIL;
const meta = {
updateAllowed: true
};
await subscriptions.create(req.context, listId, subscription, meta); await subscriptions.create(req.context, listId, subscription, meta);
res.status(200); res.status(200);

View file

@ -11,8 +11,7 @@ router.postAsync('/blacklist-table', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/blacklist', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/blacklist', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await blacklist.add(req.context, req.body.email); return res.json(await blacklist.add(req.context, req.body.email));
return res.json();
}); });
router.deleteAsync('/blacklist/:email', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.deleteAsync('/blacklist/:email', passport.loggedIn, passport.csrfProtection, async (req, res) => {

539
routes/rest/editors.js Normal file
View file

@ -0,0 +1,539 @@
'use strict';
const passport = require('../../lib/passport');
const bluebird = require('bluebird');
const premailerApi = require('premailer-api');
const premailerPrepareAsync = bluebird.promisify(premailerApi.prepare);
const router = require('../../lib/router-async').create();
/*
const { nodeifyFunction } = require('../lib/nodeify');
const getSettings = nodeifyFunction(require('../models/settings').get);
const htmlToText = require('html-to-text');
const log = require('npmlog');
const config = require('config');
const express = require('express');
const router = new express.Router();
const passport = require('../lib/passport');
const os = require('os');
const fs = require('fs');
const path = require('path');
const mkdirp = require('mkdirp');
const crypto = require('crypto');
const events = require('events');
const httpMocks = require('node-mocks-http');
const multiparty = require('multiparty');
const escapeStringRegexp = require('escape-string-regexp');
const jqueryFileUpload = require('jquery-file-upload-middleware');
const gm = require('gm').subClass({
imageMagick: true
});
const url = require('url');
const _ = require('../lib/translate')._;
const mailer = require('../lib/mailer');
const templates = require('../lib/models/templates');
const campaigns = require('../lib/models/campaigns');
router.all('/*', (req, res, next) => {
if (!req.user) {
return res.status(403).send(_('Need to be logged in to access restricted content'));
}
if (req.originalUrl.startsWith('/editorapi/img?')) {
return next();
}
if (!config.editors.map(e => e[0]).includes(req.query.editor)) {
return res.status(500).send(_('Invalid editor name'));
}
next();
});
jqueryFileUpload.on('begin', fileInfo => {
fileInfo.name = fileInfo.name
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^a-z0-9+-.]+/g, '');
});
const listImages = (dir, dirURL, callback) => {
fs.readdir(dir, (err, files = []) => {
if (err && err.code !== 'ENOENT') {
return callback(err.message || err);
}
files = files.filter(name => /\.(jpe?g|png|gif)$/i.test(name));
files = files.map(name => ({
// mosaico
name,
url: dirURL + '/' + name,
thumbnailUrl: dirURL + '/thumbnail/' + name,
// grapejs
src: dirURL + '/' + name
}));
callback(null, files);
});
};
const placeholderImage = (width, height, callback) => {
const magick = gm(width, height, '#707070');
const size = 40;
let x = 0;
let y = 0;
// stripes
while (y < height) {
magick
.fill('#808080')
.drawPolygon([x, y], [x + size, y], [x + size * 2, y + size], [x + size * 2, y + size * 2])
.drawPolygon([x, y + size], [x + size, y + size * 2], [x, y + size * 2]);
x = x + size * 2;
if (x > width) {
x = 0;
y = y + size * 2;
}
}
// text
magick
.fill('#B0B0B0')
.fontSize(20)
.drawText(0, 0, width + ' x ' + height, 'center');
magick.stream('png', (err, stream) => {
if (err) {
return callback(err);
}
const image = {
format: 'PNG',
stream
};
callback(null, image);
});
};
const resizedImage = (src, method, width, height, callback) => {
const pathname = path.join('/', url.parse(src).pathname);
const filePath = path.join(__dirname, '..', 'public', pathname);
const magick = gm(filePath);
magick.format((err, format) => {
if (err) {
return callback(err);
}
const streamHandler = (err, stream) => {
if (err) {
return callback(err);
}
const image = {
format,
stream
};
callback(null, image);
};
switch (method) {
case 'resize':
return magick
.autoOrient()
.resize(width, height)
.stream(streamHandler);
case 'cover':
return magick
.autoOrient()
.resize(width, height + '^')
.gravity('Center')
.extent(width, height + '>')
.stream(streamHandler);
default:
return callback(new Error(_('Method not supported')));
}
});
};
const getProcessedImage = (dynamicUrl, callback) => {
if (!dynamicUrl.includes('/editorapi/img?')) {
return callback(new Error('Invalid dynamicUrl'));
}
const {
src,
method,
params = '600,null'
} = url.parse(dynamicUrl, true).query;
let width = params.split(',')[0];
let height = params.split(',')[1];
const sanitizeSize = (val, min, max, defaultVal, allowNull) => {
if (val === 'null' && allowNull) {
return null;
}
val = Number(val) || defaultVal;
val = Math.max(min, val);
val = Math.min(max, val);
return val;
};
if (method === 'placeholder') {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, false);
placeholderImage(width, height, callback);
} else {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true);
resizedImage(src, method, width, height, callback);
}
};
const getStaticImageUrl = (dynamicUrl, staticDir, staticDirUrl, callback) => {
if (!dynamicUrl.includes('/editorapi/img?')) {
return callback(null, dynamicUrl);
}
mkdirp(staticDir, err => {
if (err) {
return callback(err);
}
fs.readdir(staticDir, (err, files) => {
if (err) {
return callback(err);
}
const hash = crypto.createHash('md5').update(dynamicUrl).digest('hex');
const match = files.find(el => el.startsWith(hash));
if (match) {
return callback(null, staticDirUrl + '/' + match);
}
getProcessedImage(dynamicUrl, (err, image) => {
if (err) {
return callback(err);
}
const fileName = hash + '.' + image.format.toLowerCase();
const filePath = path.join(staticDir, fileName);
const fileUrl = staticDirUrl + '/' + fileName;
const writeStream = fs.createWriteStream(filePath);
writeStream.on('error', err => callback(err));
writeStream.on('finish', () => callback(null, fileUrl));
image.stream.pipe(writeStream);
});
});
});
};
const prepareHtml = (html, editorName, callback) => {
getSettings('serviceUrl', (err, serviceUrl) => {
if (err) {
return callback(err.message || err);
}
const srcs = new Map();
const re = /<img[^>]+src="([^"]*\/editorapi\/img\?[^"]+)"/ig;
let jobs = 0;
let result;
while ((result = re.exec(html)) !== null) {
srcs.set(result[1], result[1]);
}
const done = () => {
if (jobs === 0) {
for (const [key, value] of srcs) {
// console.log(`replace dynamicUrl: ${key} - with staticUrl: ${value}`);
html = html.replace(new RegExp(escapeStringRegexp(key), 'g'), value);
}
return callback(null, html);
}
};
const staticDir = path.join(__dirname, '..', 'public', editorName, 'uploads', 'static');
const staticDirUrl = url.resolve(serviceUrl, editorName + '/uploads/static');
for (const key of srcs.keys()) {
jobs++;
const dynamicUrl = key.replace(/&amp;/g, '&');
getStaticImageUrl(dynamicUrl, staticDir, staticDirUrl, (err, staticUrl) => {
if (err) {
// TODO: Send a warning back to the editor. For now we just skip image resizing.
log.error('editorapi', err);
if (dynamicUrl.includes('/editorapi/img?')) {
staticUrl = url.parse(dynamicUrl, true).query.src || dynamicUrl;
} else {
staticUrl = dynamicUrl;
}
if (!/^https?:\/\/|^\/\//i.test(staticUrl)) {
staticUrl = url.resolve(serviceUrl, staticUrl);
}
}
srcs.set(key, staticUrl);
jobs--;
done();
});
}
done();
});
};
// URL structure defined by Mosaico
// /editorapi/img?src=" + encodeURIComponent(src) + "&method=" + encodeURIComponent(method) + "&params=" + encodeURIComponent(width + "," + height);
router.get('/img', (req, res) => {
getProcessedImage(req.originalUrl, (err, image) => {
if (err) {
res.status(err.status || 500);
res.send(err.message || err);
return;
}
res.set('Content-Type', 'image/' + image.format.toLowerCase());
image.stream.pipe(res);
});
});
router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) => {
const sendResponse = err => {
if (err) {
return res.status(500).send(err.message || err);
}
res.send('ok');
};
prepareHtml(req.body.html, req.query.editor, (err, html) => {
if (err) {
return sendResponse(err);
}
req.body.html = html;
switch (req.query.type) {
case 'template':
return templates.update(req.body.id, req.body, sendResponse);
case 'campaign':
return campaigns.update(req.body.id, req.body, sendResponse);
default:
return sendResponse(new Error(_('Invalid resource type')));
}
});
});
// https://github.com/artf/grapesjs/wiki/API-Asset-Manager
// https://github.com/aguidrevitch/jquery-file-upload-middleware
router.get('/upload', passport.csrfProtection, (req, res) => {
getSettings('serviceUrl', (err, serviceUrl) => {
if (err) {
return res.status(500).send(err.message || err);
}
const baseDir = path.join(__dirname, '..', 'public', req.query.editor, 'uploads');
const baseDirUrl = serviceUrl + req.query.editor + '/uploads';
listImages(path.join(baseDir, '0'), baseDirUrl + '/0', (err, sharedImages) => {
if (err) {
return res.status(500).send(err.message || err);
}
if (req.query.type === 'campaign' && Number(req.query.id) > 0) {
listImages(path.join(baseDir, req.query.id), baseDirUrl + '/' + req.query.id, (err, campaignImages) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.json({
files: sharedImages.concat(campaignImages)
});
});
} else {
res.json({
files: sharedImages
});
}
});
});
});
router.post('/upload', passport.csrfProtection, (req, res) => {
getSettings('serviceUrl', (err, serviceUrl) => {
if (err) {
return res.status(500).send(err.message || err);
}
const getDirName = () => {
switch (req.query.type) {
case 'template':
return '0';
case 'campaign':
return Number(req.query.id) > 0 ? req.query.id : false;
default:
return false;
}
};
const dirName = getDirName();
const serviceUrlParts = url.parse(serviceUrl);
if (dirName === false) {
return res.status(500).send(_('Invalid resource type or ID'));
}
const opts = {
tmpDir: config.www.tmpdir || os.tmpdir(),
imageVersions: req.query.editor === 'mosaico' ? {
thumbnail: {
width: 90,
height: 90
}
} : {},
uploadDir: path.join(__dirname, '..', 'public', req.query.editor, 'uploads', dirName),
uploadUrl: '/' + req.query.editor + '/uploads/' + dirName, // must be root relative
acceptFileTypes: /\.(gif|jpe?g|png)$/i,
hostname: serviceUrlParts.host, // include port
ssl: serviceUrlParts.protocol === 'https:'
};
const mockres = httpMocks.createResponse({
eventEmitter: events.EventEmitter
});
mockres.on('error', err => {
res.status(500).json({
error: err.message || err,
data: []
});
});
mockres.on('end', () => {
const data = [];
try {
JSON.parse(mockres._getData()).files.forEach(file => {
data.push({
src: file.url
});
});
res.json({
data
});
} catch(err) {
res.status(500).json({
error: err.message || err,
data
});
}
});
jqueryFileUpload.fileHandler(opts)(req, req.query.editor === 'grapejs' ? mockres : res);
});
});
router.post('/download', passport.csrfProtection, (req, res) => {
prepareHtml(req.body.html, req.query.editor, (err, html) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.setHeader('Content-disposition', 'attachment; filename=' + req.body.filename);
res.setHeader('Content-type', 'text/html');
res.send(html);
});
});
const parseGrapejsMultipartTestForm = (req, res, next) => {
if (req.query.editor === 'grapejs') {
new multiparty.Form().parse(req, (err, fields) => {
if (err) {
return next(err);
}
req.body.email = fields.email[0];
req.body.subject = fields.subject[0];
req.body.html = fields.html[0];
req.body._csrf = fields._csrf[0];
next();
});
} else {
next();
}
};
router.post('/test', parseGrapejsMultipartTestForm, passport.csrfProtection, (req, res) => {
const sendError = err => {
if (req.query.editor === 'grapejs') {
res.status(500).json({
errors: err.message || err
});
} else {
res.status(500).send(err.message || err);
}
};
prepareHtml(req.body.html, req.query.editor, (err, html) => {
if (err) {
return sendError(err);
}
getSettings(['defaultAddress', 'defaultFrom'], (err, configItems) => {
if (err) {
return sendError(err);
}
mailer.getMailer((err, transport) => {
if (err) {
return sendError(err);
}
const opts = {
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: req.body.email,
subject: req.body.subject,
text: htmlToText.fromString(html, {
wordwrap: 100
}),
html
};
transport.sendMail(opts, err => {
if (err) {
return sendError(err);
}
if (req.query.editor === 'grapejs') {
res.json({
data: 'ok'
});
} else {
res.send('ok');
}
});
});
});
});
});
*/
router.postAsync('/html-to-text', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const email = await premailerPrepareAsync({
html: req.body.html,
fetchHTML: false
});
res.json({text: email.text.replace(/%5B/g, '[').replace(/%5D/g, ']')});
});
module.exports = router;

View file

@ -31,8 +31,7 @@ router.getAsync('/fields-grouped/:listId', passport.loggedIn, async (req, res) =
}); });
router.postAsync('/fields/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/fields/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await fields.create(req.context, req.params.listId, req.body); return res.json(await fields.create(req.context, req.params.listId, req.body));
return res.json();
}); });
router.putAsync('/fields/:listId/:fieldId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/fields/:listId/:fieldId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

38
routes/rest/files.js Normal file
View file

@ -0,0 +1,38 @@
'use strict';
const passport = require('../../lib/passport');
const files = require('../../models/files');
const router = require('../../lib/router-async').create();
const multer = require('../../lib/multer');
router.postAsync('/files-table/:type/:entityId', passport.loggedIn, async (req, res) => {
const files = await files.listFilesDTAjax(req.context, req.params.type, req.params.entityId, req.body);
return res.json(files);
});
router.getAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => {
const file = await files.getFileById(req.context, req.params.type, req.params.fileId);
res.type(file.mimetype);
return res.download(file.path, file.name);
});
router.getAsync('/files-by-name/:type/:entityId/:fileName', passport.loggedIn, async (req, res) => {
const file = await templates.getFileByName(req.context, req.params.type, req.params.entityId, req.params.fileName);
res.type(file.mimetype);
// return res.sendFile(file.path); FIXME - remove this comment if the download below is OK
return res.download(file.path, file.name);
});
router.putAsync('/files/:type/:entityId', passport.loggedIn, multer.array('file'), async (req, res) => {
const summary = await files.createFiles(req.context, req.params.type, req.params.entityId, req.files);
return res.json(summary);
});
router.deleteAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => {
await files.removeFile(req.context, req.params.type, req.params.fileId);
return res.json();
});
module.exports = router;

View file

@ -17,8 +17,7 @@ router.getAsync('/forms/:formId', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/forms', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/forms', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await forms.create(req.context, req.body); return res.json(await forms.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/forms/:formId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/forms/:formId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -17,8 +17,7 @@ router.getAsync('/lists/:listId', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/lists', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/lists', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await lists.create(req.context, req.body); return res.json(await lists.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/lists/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/lists/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -16,8 +16,7 @@ router.getAsync('/namespaces/:nsId', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/namespaces', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/namespaces', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await namespaces.create(req.context, req.body); return res.json(await namespaces.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/namespaces/:nsId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -14,8 +14,7 @@ router.getAsync('/report-templates/:reportTemplateId', passport.loggedIn, async
}); });
router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.create(req.context, req.body); return res.json(await reportTemplates.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -18,8 +18,7 @@ router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/reports', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/reports', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reports.create(req.context, req.body); return res.json(await reports.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -21,8 +21,7 @@ router.getAsync('/segments/:listId/:segmentId', passport.loggedIn, async (req, r
}); });
router.postAsync('/segments/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/segments/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await segments.create(req.context, req.params.listId, req.body); return res.json(await segments.create(req.context, req.params.listId, req.body));
return res.json();
}); });
router.putAsync('/segments/:listId/:segmentId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/segments/:listId/:segmentId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -17,8 +17,7 @@ router.getAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, asy
}); });
router.postAsync('/subscriptions/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/subscriptions/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await subscriptions.create(req.context, req.params.listId, req.body); return res.json(await subscriptions.create(req.context, req.params.listId, req.body));
return res.json();
}); });
router.putAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

36
routes/rest/templates.js Normal file
View file

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

View file

@ -16,8 +16,7 @@ router.getAsync('/users/:userId', passport.loggedIn, async (req, res) => {
}); });
router.postAsync('/users', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/users', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await users.create(req.context, req.body); return res.json(await users.create(req.context, req.body));
return res.json();
}); });
router.putAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -1,948 +0,0 @@
'use strict';
let log = require('npmlog');
let config = require('config');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let passport = require('../lib/passport');
let express = require('express');
let router = new express.Router();
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let subscriptions = require('../lib/models/subscriptions');
let settings = require('../lib/models/settings');
let openpgp = require('openpgp');
let _ = require('../lib/translate')._;
let util = require('util');
let cors = require('cors');
let cache = require('memory-cache');
let geoip = require('geoip-ultralight');
let confirmations = require('../lib/models/confirmations');
let mailHelpers = require('../lib/subscription-mail-helpers');
let originWhitelist = config.cors && config.cors.origins || [];
let corsOptions = {
allowedHeaders: ['Content-Type', 'Origin', 'Accept', 'X-Requested-With'],
methods: ['GET', 'POST'],
optionsSuccessStatus: 200, // IE11 chokes on 204
origin: (origin, callback) => {
if (originWhitelist.includes(origin)) {
callback(null, true);
} else {
let err = new Error(_('Not allowed by CORS'));
err.status = 403;
callback(err);
}
}
};
let corsOrCsrfProtection = (req, res, next) => {
if (req.get('X-Requested-With') === 'XMLHttpRequest') {
cors(corsOptions)(req, res, next);
} else {
passport.csrfProtection(req, res, next);
}
};
function checkAndExecuteConfirmation(req, action, errorMsg, next, exec) {
confirmations.takeConfirmation(req.params.cid, (err, confirmation) => {
if (!err && (!confirmation || confirmation.action !== action)) {
err = new Error(_(errorMsg));
err.status = 404;
}
if (err) {
return next(err);
}
lists.get(confirmation.listId, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
exec(confirmation, list);
});
});
}
router.get('/confirm/subscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'subscribe', 'Request invalid or already completed. If your subscription request is still pending, please subscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
let optInCountry = geoip.lookupCountry(confirmation.ip) || null;
const meta = {
cid: req.params.cid,
email: data.email,
optInIp: confirmation.ip,
optInCountry,
status: subscriptions.Status.SUBSCRIBED
};
subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => {
if (err) {
return next(err);
}
if (!result.entryId) {
return next(new Error(_('Could not save subscription')));
}
subscriptions.getById(list.id, result.entryId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/subscribed-notice');
});
});
});
});
});
router.get('/confirm/change-address/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'change-address', 'Request invalid or already completed. If your address change request is still pending, please change the address again.', next, (confirmation, list) => {
const data = confirmation.data;
if (!data.subscriptionId) { // Something went terribly wrong and we don't have data that we have originally provided
return next(new Error(_('Subscriber info corrupted or missing')));
}
subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => {
if (err) {
return next(err);
}
subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => {
if (err) {
return next(err);
}
req.flash('info', _('Email address changed'));
res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid);
});
});
});
});
});
router.get('/confirm/unsubscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'unsubscribe', 'Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
});
});
});
router.get('/:cid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
if (err) {
return next(err);
}
// TODO: process subscriber cid param for resubscription requests
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.layout = 'subscription/layout';
data.title = list.name;
data.cid = list.cid;
data.csrfToken = req.csrfToken();
function nextStep() {
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
data.customFields = fields.getRow(fieldList, data);
data.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-subscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
}
const ucid = req.query.cid;
if (ucid) {
subscriptions.get(list.id, ucid, (err, subscription) => {
if (err) {
return next(err);
}
for (let key in subscription) {
if (!(key in data)) {
data[key] = subscription[key];
}
}
nextStep();
});
} else {
nextStep();
}
});
});
router.options('/:cid/widget', cors(corsOptions));
router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
let cached = cache.get(req.path);
if (cached) {
return res.status(200).json(cached);
}
let sendError = err => {
res.status(err.status || 500);
res.json({
error: err.message || err
});
};
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return sendError(err);
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
settings.list(['serviceUrl', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
return sendError(err);
}
let data = {
title: list.name,
cid: list.cid,
serviceUrl: configItems.serviceUrl,
hasPubkey: !!configItems.pgpPrivateKey,
customFields: fields.getRow(fieldList),
template: {},
layout: null,
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
if (err) {
return sendError(err);
}
res.render('subscription/widget-subscribe', data, (err, html) => {
if (err) {
return sendError(err);
}
let response = {
data: {
title: data.title,
cid: data.cid,
html
}
};
cache.put(req.path, response, 30000); // ms
res.status(200).json(response);
});
});
});
});
});
});
router.options('/:cid/subscribe', cors(corsOptions));
router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => {
let sendJsonError = (err, status) => {
res.status(status || err.status || 500);
res.json({
error: err.message || err
});
};
let email = (req.body.email || '').toString().trim();
if (!email) {
if (req.xhr) {
return sendJsonError(_('Email address not set'), 400);
}
req.flash('danger', _('Email address not set'));
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
tools.validateEmail(email, false, err => {
if (err) {
if (req.xhr) {
return sendJsonError(err.message, 400);
}
req.flash('danger', err.message);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
// Check if the subscriber seems legit. This is a really simple check, the only requirement is that
// the subscriber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this
// simple check should be replaced with an actual captcha
let subTime = Number(req.body.sub) || 0;
// allow clock skew 24h in the past and 24h to the future
let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000);
let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest;
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
let subscriptionData = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
subscriptionData[key] = (req.body[key] || '').toString().trim();
}
});
subscriptionData = tools.convertKeys(subscriptionData);
subscriptions.getByEmail(list.id, email, (err, subscription) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) {
mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
});
} else {
const data = {
email,
subscriptionData
};
confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => {
if (err) {
if (req.xhr) {
return sendJsonError(err);
}
req.flash('danger', err.message || err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
function sendWebResponse() {
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
}
if (!testsPass) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
sendWebResponse();
} else {
mailHelpers.sendConfirmSubscription(list, email, confirmCid, subscriptionData, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : sendWebResponse(err);
}
sendWebResponse();
})
}
});
}
});
});
});
});
router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
fields.list(list.id, (err, fieldList) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
subscription.customFields = fields.getRow(fieldList, subscription);
subscription.useEditor = true;
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscription.hasPubkey = !!configItems.pgpPrivateKey;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-manage.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
});
router.post('/:lcid/manage', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
subscriptions.get(list.id, req.body.cid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
subscriptions.update(list.id, subscription.cid, req.body, false, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + req.params.lcid + '/updated-notice');
});
});
});
});
router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-manage-address.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage-address', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
let bodyData = tools.convertKeys(req.body); // This is here to convert "email-new" to "emailNew"
const emailOld = (bodyData.email || '').toString().trim();
const emailNew = (bodyData.emailNew || '').toString().trim();
if (emailOld === emailNew) {
req.flash('info', _('Nothing seems to be changed'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
} else {
subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => {
if (err) {
return next(err);
}
function sendWebResponse(err) {
if (err) {
return next(err);
}
req.flash('info', _('An email with further instructions has been sent to the provided address'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
}
if (newEmailAvailable) {
const data = {
subscriptionId: subscription.id,
emailNew
};
confirmations.addConfirmation(list.id, 'change-address', req.ip, data, (err, confirmCid) => {
if (err) {
return next(err);
}
mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription, sendWebResponse);
});
} else {
mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse);
}
});
}
});
});
router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
const autoUnsubscribe = req.query.auto === 'yes';
if (autoUnsubscribe) {
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
} else if (req.query.formTest ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
subscription.lcid = req.params.lcid;
subscription.ucid = req.params.ucid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.campaign = req.query.c;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-unsubscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
}
});
});
});
});
router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
const campaignId = (req.body.campaign || '').toString().trim() || false;
subscriptions.get(list.id, req.body.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
return next(err);
}
handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next);
});
});
});
function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) {
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
const data = {
subscriptionId: subscription.id,
campaignId
};
confirmations.addConfirmation(list.id, 'unsubscribe', ip, data, (err, confirmCid) => {
if (err) {
return next(err);
}
mailHelpers.sendConfirmUnsubscription(list, subscription.email, confirmCid, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/confirm-unsubscription-notice');
});
});
} else { // UnsubscriptionMode.MANUAL
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
}
}
router.get('/:cid/confirm-subscription-notice', (req, res, next) => {
webNotice('confirm-subscription', req, res, next);
});
router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => {
webNotice('confirm-unsubscription', req, res, next);
});
router.get('/:cid/subscribed-notice', (req, res, next) => {
webNotice('subscribed', req, res, next);
});
router.get('/:cid/updated-notice', (req, res, next) => {
webNotice('updated', req, res, next);
});
router.get('/:cid/unsubscribed-notice', (req, res, next) => {
webNotice('unsubscribed', req, res, next);
});
router.get('/:cid/manual-unsubscribe-notice', (req, res, next) => {
webNotice('manual-unsubscribe', req, res, next);
});
router.post('/publickey', passport.parseForm, (req, res, next) => {
settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
return next(err);
}
if (!configItems.pgpPrivateKey) {
err = new Error(_('Public key is not set'));
err.status = 404;
return next(err);
}
let privKey;
try {
privKey = openpgp.key.readArmored(configItems.pgpPrivateKey).keys[0];
if (configItems.pgpPassphrase && !privKey.decrypt(configItems.pgpPassphrase)) {
privKey = false;
}
} catch (E) {
// just ignore if failed
}
if (!privKey) {
err = new Error(_('Public key is not set'));
err.status = 404;
return next(err);
}
let pubkey = privKey.toPublic().armor();
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment; filename=public.asc'
});
res.end(pubkey);
});
});
function webNotice(type, req, res, next) {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
contactAddress: configItems.defaultAddress,
template: {
template: 'subscription/web-' + type + '-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-' + type + '-notice', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.isConfirmNotice = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
}
module.exports = router;

View file

@ -142,7 +142,7 @@ router.getAsync('/confirm/subscribe/:cid', async (req, res) => {
const meta = { const meta = {
ip: confirmation.ip, ip: confirmation.ip,
country: geoip.lookupCountry(confirmation.ip) || null, country: geoip.lookupCountry(confirmation.ip) || null,
replaceOfUnsubscribedAllowed: true updateOfUnsubscribedAllowed: true
}; };
const subscription = data.subscriptionData; const subscription = data.subscriptionData;

View file

@ -0,0 +1,10 @@
'use strict';
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, 'templates', _('Templates'));
module.exports = router;

View file

@ -0,0 +1,25 @@
const entityTypesWithFiles = ['template', 'campaign'];
exports.up = (knex, Promise) => (async() => {
for (const entityType of entityTypesWithFiles) {
await knex.schema.createTable(`files_${entityType}`, table => {
table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('templates.id');
table.string('filename');
table.string('originalname');
table.string('mimetype');
table.string('encoding');
table.integer('size');
table.timestamp('created').defaultTo(knex.fn.now());
table.index(['entity', 'originalname'])
})
}
})();
exports.down = (knex, Promise) => (async() => {
for (const entityType of entityTypesWithFiles) {
await knex.schema.dropTable(`files_${entityType}`);
}
})();

View file

@ -0,0 +1,27 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('templates', table => {
table.json('data');
table.string('type');
});
const templates = await knex('templates');
for (const template of templates) {
let type = template.editor_name;
const data = JSON.parse(template.editor_data || '{}');
if (type == 'summernote') {
type = 'ckeditor';
}
await knex('templates').where('id', template.id).update({type, data: JSON.stringify(data)});
}
await knex.schema.table('templates', table => {
table.dropColumn('editor_name');
table.dropColumn('editor_data');
});
})();
exports.down = (knex, Promise) => (async() => {
})();