Beginning of work on templates.
This commit is contained in:
parent
47b8d80c22
commit
508d6b3b2f
40 changed files with 1685 additions and 1031 deletions
20
app.js
20
app.js
|
@ -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);
|
||||||
|
|
14
client/package-lock.json
generated
14
client/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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
148
client/src/lib/files.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 ...')}
|
||||||
|
|
|
@ -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
309
client/src/templates/CUD.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
94
client/src/templates/List.js
Normal file
94
client/src/templates/List.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
35
client/src/templates/helpers.js
Normal file
35
client/src/templates/helpers.js
Normal 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;
|
||||||
|
}
|
64
client/src/templates/root.js
Normal file
64
client/src/templates/root.js
Normal 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')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 ...')}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
155
models/files.js
Normal 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
|
||||||
|
};
|
|
@ -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
91
models/templates.js
Normal 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
|
||||||
|
};
|
|
@ -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);
|
||||||
|
|
|
@ -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
539
routes/rest/editors.js
Normal 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(/&/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) + "¶ms=" + 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;
|
|
@ -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
38
routes/rest/files.js
Normal 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;
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
36
routes/rest/templates.js
Normal 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;
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
||||||
|
|
10
routes/templates-legacy-integration.js
Normal file
10
routes/templates-legacy-integration.js
Normal 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;
|
25
setup/knex/migrations/20180110120444_add_files.js
Normal file
25
setup/knex/migrations/20180110120444_add_files.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
})();
|
27
setup/knex/migrations/20180111120444_upgrade_templates.js
Normal file
27
setup/knex/migrations/20180111120444_upgrade_templates.js
Normal 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() => {
|
||||||
|
})();
|
Loading…
Add table
Add a link
Reference in a new issue