CKEditor components replaced by CKEditor 5.

Remains of the sandboxed CKEditor - will be removed, but the version here may be useful for another editor that is prone to XSS (like Summernote).
This commit is contained in:
Tomas Bures 2018-11-03 21:46:23 +01:00
parent 213039c141
commit eacdc74c29
43 changed files with 12499 additions and 1382 deletions

View file

@ -10,6 +10,9 @@
### Message delivery
- Better integration with ZoneMTA to allow multiple send configurations (with different DKIM) against one ZoneMTA instance via different HTTP configuration of ZoneMTA. This may need an extension of ZoneMTA to provide some header entry that identifies the campaign.
### Lists
- CSV Export
### Campaigns
- Statistics for a sent campaign
- List of sent RSS campaigns (?)

View file

@ -22,7 +22,8 @@ const api = require('./routes/api');
// These are routes for the new React-based client
const reports = require('./routes/reports');
const subscription = require('./routes/subscription');
const mosaico = require('./routes/mosaico');
const sandboxedMosaico = require('./routes/sandboxed-mosaico');
const sandboxedCKEditor = require('./routes/sandboxed-ckeditor');
const files = require('./routes/files');
const links = require('./routes/links');
const archive = require('./routes/archive');
@ -221,7 +222,8 @@ function createApp(appType) {
useWith404Fallback('/files', files);
}
useWith404Fallback('/mosaico', mosaico.getRouter(appType));
useWith404Fallback('/mosaico', sandboxedMosaico.getRouter(appType));
useWith404Fallback('/ckeditor', sandboxedCKEditor.getRouter(appType));
if (appType === AppType.TRUSTED || appType === AppType.SANDBOXED) {
if (config.reports && config.reports.enabled === true) {

View file

@ -1,4 +0,0 @@
{
"presets": ["es2015", "stage-1"],
"plugins": ["transform-react-jsx", "transform-decorators-legacy", "transform-function-bind"]
}

5578
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,24 @@
"license": "GPL-3.0",
"homepage": "https://mailtrain.org/",
"dependencies": {
"@ckeditor/ckeditor5-alignment": "^10.0.3",
"@ckeditor/ckeditor5-basic-styles": "^10.0.3",
"@ckeditor/ckeditor5-block-quote": "^10.1.0",
"@ckeditor/ckeditor5-core": "^11.0.1",
"@ckeditor/ckeditor5-easy-image": "^10.0.3",
"@ckeditor/ckeditor5-editor-classic": "^11.0.1",
"@ckeditor/ckeditor5-essentials": "^10.1.2",
"@ckeditor/ckeditor5-heading": "^10.1.0",
"@ckeditor/ckeditor5-image": "^11.0.0",
"@ckeditor/ckeditor5-link": "^10.0.4",
"@ckeditor/ckeditor5-list": "^11.0.2",
"@ckeditor/ckeditor5-media-embed": "^10.0.0",
"@ckeditor/ckeditor5-paragraph": "^10.0.3",
"@ckeditor/ckeditor5-react": "^1.0.0",
"@ckeditor/ckeditor5-table": "^11.0.0",
"@ckeditor/ckeditor5-theme-lark": "^11.1.0",
"@ckeditor/ckeditor5-ui": "^11.1.0",
"@ckeditor/ckeditor5-upload": "^10.0.3",
"axios": "^0.16.2",
"datatables.net": "^1.10.15",
"datatables.net-bs": "^1.10.15",
@ -28,7 +46,6 @@
"querystringify": "^1.0.0",
"react": "^15.6.1",
"react-ace": "^5.1.0",
"react-ckeditor-component": "^1.0.7",
"react-day-picker": "^6.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
@ -41,16 +58,20 @@
"url-parse": "^1.1.9"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "^11.0.1",
"@ckeditor/ckeditor5-dev-webpack-plugin": "^7.0.1",
"babel-cli": "^6.24.1",
"babel-loader": "^7.1.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-function-bind": "^6.22.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"css-loader": "^0.28.4",
"i18next-conv": "^3.0.3",
"node-sass": "^4.5.3",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"url-loader": "^0.5.9",

View file

@ -36,7 +36,8 @@ import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {
getTemplateTypes,
getTypeForm
getTypeForm,
ResourceType
} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
@ -50,7 +51,6 @@ import {
} from "../../../shared/campaigns";
import moment from 'moment';
import {getMailerTypes} from "../send-configurations/helpers";
import {ResourceType} from "../lib/mosaico";
import {getCampaignLabels} from "./helpers";
@translate()

View file

@ -21,12 +21,12 @@ import mailtrainConfig from 'mailtrainConfig';
import {
getEditForm,
getTemplateTypes,
getTypeForm
getTypeForm,
ResourceType
} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {ResourceType} from "../lib/mosaico";
@translate()

View file

@ -58,7 +58,7 @@ export default class List extends Component {
actions: data => {
const actions = [];
if (mailtrainConfig.globalPermissions.includes('setupAutomation') && this.props.campaign.permissions.includes('manageTriggers')) {
if (mailtrainConfig.globalPermissions.setupAutomation && this.props.campaign.permissions.includes('manageTriggers')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/campaigns/${this.props.campaign.id}/triggers/${data[0]}/edit`
@ -77,7 +77,7 @@ export default class List extends Component {
return (
<div>
{tableDeleteDialogRender(this, `rest/triggers/${this.props.campaign.id}`, t('Deleting trigger ...'), t('Trigger deleted'))}
{mailtrainConfig.globalPermissions.includes('setupAutomation') && this.props.campaign.permissions.includes('manageTriggers') &&
{mailtrainConfig.globalPermissions.setupAutomation && this.props.campaign.permissions.includes('manageTriggers') &&
<Toolbar>
<NavButton linkTo={`/campaigns/${this.props.campaign.id}/triggers/create`} className="btn-primary" icon="plus" label={t('Create Trigger')}/>
</Toolbar>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
>
<path
style="fill:#000000;fill-rule:nonzero;stroke-width:0.89999998"
d="m 4.9296875,1.5273438 c -0.549,0 -0.9902344,0.4233124 -0.9902344,0.9453124 V 8.1542969 C 3.9929718,8.1073482 4.0362288,8.0501836 4.09375,8.0078125 4.4641691,7.6414817 4.869899,7.3679645 5.2890625,7.1503906 V 2.8496094 H 17.501953 V 11.957031 L 15.234375,9.2851562 c -0.220904,-0.204796 -0.562299,-0.204796 -0.783203,0 l -2.00586,1.8535158 -0.451171,-0.421875 c 0.101968,0.808412 -0.008,1.673794 -0.398438,2.529297 -0.04665,0.163457 -0.103202,0.325377 -0.201172,0.464844 l -0.002,0.002 -0.257812,0.36914 h 6.736328 c 0.54,0 0.982422,-0.422125 0.982422,-0.953125 V 2.4726562 c 0,-0.522 -0.442422,-0.9453124 -0.982422,-0.9453124 z M 14.714844,3.8828125 C 13.350658,3.8741006 12.49215,5.3495586 13.173828,6.53125 14.349609,8.5716138 17.410156,6.8079419 16.234375,4.7675781 15.921123,4.2235411 15.342608,3.8866937 14.714844,3.8828125 Z"
id="path2"/>
<path
style="fill:#000000;fill-rule:evenodd;stroke-width:0.69999999"
d="m 6.8138243,16.679773 0.6937,-0.9912 a 0.52500478,0.52500478 0 1 1 0.8603,0.602 l -0.8036,1.148 a 0.5236,0.5236 0 0 1 -0.1519,0.1442 3.6757,3.6757 0 0 1 -5.9521,-4.1685 c 0.014,-0.0665 0.042,-0.1323 0.084,-0.1918 l 0.8029,-1.1473 a 0.525,0.525 0 1 1 0.8596,0.602 l -0.6937,0.9926 0.0042,0.0021 a 2.625,2.625 0 0 0 4.2924,3.0058 l 0.0042,0.0028 z m 3.8457997,-3.7345 a 0.5236,0.5236 0 0 1 -0.084,0.1918 l -0.8028997,1.1473 a 0.525,0.525 0 1 1 -0.8596,-0.602 l 0.602,-0.861 a 2.625,2.625 0 0 0 -4.3008,-3.0106998 l -0.602,0.8602998 a 0.52500478,0.52500478 0 0 1 -0.8603,-0.602 l 0.8036,-1.1479998 a 0.5236,0.5236 0 0 1 0.1519,-0.1442 3.6757,3.6757 0 0 1 5.9520997,4.1684998 z m -3.1940997,-1.7724 a 0.525,0.525 0 0 1 0.1288,0.7315 l -2.2085,3.1535 a 0.52500478,0.52500478 0 1 1 -0.8603,-0.602 l 2.2085,-3.1542 a 0.525,0.525 0 0 1 0.7315,-0.1288 z" />
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

190
client/src/lib/ckeditor.js vendored Normal file
View file

@ -0,0 +1,190 @@
'use strict';
import React, {Component} from 'react';
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import EssentialsPlugin from '@ckeditor/ckeditor5-essentials/src/essentials';
import BoldPlugin from '@ckeditor/ckeditor5-basic-styles/src/bold';
import UnderlinePlugin from '@ckeditor/ckeditor5-basic-styles/src/underline';
import StrikethroughPlugin from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
import CodePlugin from '@ckeditor/ckeditor5-basic-styles/src/code';
import ItalicPlugin from '@ckeditor/ckeditor5-basic-styles/src/italic';
import BlockQuotePlugin from '@ckeditor/ckeditor5-block-quote/src/blockquote';
import EasyImagePlugin from '@ckeditor/ckeditor5-easy-image/src/easyimage';
import HeadingPlugin from '@ckeditor/ckeditor5-heading/src/heading';
import ImagePlugin from '@ckeditor/ckeditor5-image/src/image';
import ImageCaptionPlugin from '@ckeditor/ckeditor5-image/src/imagecaption';
import ImageStylePlugin from '@ckeditor/ckeditor5-image/src/imagestyle';
import ImageToolbarPlugin from '@ckeditor/ckeditor5-image/src/imagetoolbar';
import ImageUploadPlugin from '@ckeditor/ckeditor5-image/src/imageupload';
import LinkPlugin from '@ckeditor/ckeditor5-link/src/link';
import ListPlugin from '@ckeditor/ckeditor5-list/src/list';
import ParagraphPlugin from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import AlignmentPlugin from '@ckeditor/ckeditor5-alignment/src/alignment';
import TablePlugin from '@ckeditor/ckeditor5-table/src/table';
import TableToolbarPlugin from '@ckeditor/ckeditor5-table/src/tabletoolbar';
import CKEditor from '@ckeditor/ckeditor5-react';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import insertImageIcon from './ckeditor-insert-image.svg';
class InsertImage extends Plugin {
init() {
const editor = this.editor;
editor.ui.componentFactory.add( 'insertImage', locale => {
const view = new ButtonView( locale );
view.set( {
label: 'Insert image',
icon: insertImageIcon,
tooltip: true
} );
// Callback executed once the image is clicked.
view.on( 'execute', () => {
let url = '';
const selectedElement = editor.model.document.selection.getSelectedElement();
if (selectedElement) {
if (selectedElement.is('element', 'image')) {
url = selectedElement.getAttribute('src');
}
}
const imageUrl = prompt('Image URL', url);
if (imageUrl) {
editor.model.change( writer => {
const imageElement = writer.createElement( 'image', {
src: imageUrl
} );
// Insert the image in the current selection location.
editor.model.insertContent( imageElement, editor.model.document.selection );
} );
}
} );
return view;
} );
}
}
class UploadAdapter {
constructor(loader, url, t) {
this.loader = loader;
}
async upload() {
console.log(this.loader);
return {
default: 'http://server/default-size.image.png'
};
}
abort() {
}
}
class MailtrainUploadAdapter extends Plugin {
static get requires() {
return [ FileRepository ];
}
static get pluginName() {
return 'MailtrainUploadAdapter';
}
init() {
this.editor.plugins.get(FileRepository).createUploadAdapter = loader => new UploadAdapter(loader, this.editor.t);
}
}
class ClassicEditor extends ClassicEditorBase {}
ClassicEditor.builtinPlugins = [
EssentialsPlugin,
BoldPlugin,
ItalicPlugin,
UnderlinePlugin,
StrikethroughPlugin,
CodePlugin,
BlockQuotePlugin,
HeadingPlugin,
ImagePlugin,
ImageCaptionPlugin,
ImageStylePlugin,
ImageToolbarPlugin,
ImageUploadPlugin,
LinkPlugin,
ListPlugin,
ParagraphPlugin,
AlignmentPlugin,
TablePlugin,
TableToolbarPlugin,
MailtrainUploadAdapter,
InsertImage
];
ClassicEditor.defaultConfig = {
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'code',
'|',
'alignment',
'|',
'link',
'bulletedList',
'numberedList',
'|',
'insertImage',
'imageUpload',
'blockQuote',
'|',
'insertTable',
'|',
'undo',
'redo'
]
},
alignment: {
options: [ 'left', 'center', 'right', 'justify' ]
},
image: {
toolbar: [
'imageStyle:full',
'imageStyle:side',
'|',
'imageTextAlternative'
]
},
table: {
contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
},
language: 'en'
};
export default class CKEditorWrapper extends Component {
render() {
return (
<CKEditor
editor={ ClassicEditor }
{...this.props}
/>
);
}
}

View file

@ -160,7 +160,7 @@ export default class Files extends Component {
{
this.props.entity.permissions.includes(this.props.managePermission) &&
<Dropzone onDrop={::this.onDrop} className={styles.dropZone} activeClassName="dropZoneActive">
<Dropzone onDrop={::this.onDrop} className={styles.dropZone} activeClassName={styles.dropZoneActive}>
{state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')}
</Dropzone>
}

View file

@ -17,7 +17,7 @@ import ACEEditorRaw from 'react-ace';
import 'brace/theme/github';
import 'brace/ext/searchbox';
import CKEditorRaw from "react-ckeditor-component";
import CKEditorRaw from './ckeditor';
import DayPicker from 'react-day-picker';
import 'react-day-picker/lib/style.css';
@ -892,11 +892,11 @@ class CKEditor extends Component {
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<CKEditorRaw
events={{
"change": evt => owner.updateFormValue(id, evt.editor.getData())
}}
content={owner.getFormValue(id)}
config={{width: '100%', height: props.height}}
onChange={(event, editor) => owner.updateFormValue(id, editor.getData())}
onInit={ editor => {
editor.ui.view.editable.editableElement.style.height = props.height;
} }
data={owner.getFormValue(id)}
/>
);
}

View file

@ -40,7 +40,7 @@ export class RestActionModalDialog extends Component {
async hideModal(isBack) {
if (this.props.backUrl) {
this.props.stateOwner.navigateTo(this.props.backUrl);
this.navigateTo(this.props.backUrl);
} else {
if (isBack) {
this.props.onBack();
@ -68,7 +68,7 @@ export class RestActionModalDialog extends Component {
await axios.method(props.actionMethod, getUrl(props.actionUrl));
if (props.successUrl) {
owner.navigateToWithFlashMessage(props.successUrl, 'success', props.actionDoneMsg);
this.navigateToWithFlashMessage(props.successUrl, 'success', props.actionDoneMsg);
} else {
props.onSuccess();
this.setFlashMessage('success', props.actionDoneMsg);

View file

@ -345,7 +345,7 @@ class SectionContent extends Component {
async closeFlashMessage() {
this.setState({
flashMessageText: ''
})
});
}
renderRoute(route) {
@ -462,8 +462,12 @@ function requiresAuthenticatedUser(target) {
const comp1 = withPageHelpers(target);
function comp2(props, context) {
if (!new.target) {
throw new TypeError();
}
context.sectionContent.ensureAuthenticated();
comp1.call(this, props, context);
return Reflect.construct(comp1, [props, context], new.target);
}
comp2.prototype = comp1.prototype;

View file

@ -0,0 +1,23 @@
'use strict';
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import {I18nextProvider,} from 'react-i18next';
import i18n from './i18n';
import {CKEditorSandbox} from './sandboxed-ckeditor';
import {UntrustedContentRoot, parentRPC} from './untrusted';
export default function() {
parentRPC.init();
ReactDOM.render(
<I18nextProvider i18n={ i18n }>
<UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} />
</I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -0,0 +1,110 @@
'use strict';
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import PropTypes from "prop-types";
import styles from "./sandboxed-ckeditor.scss";
import {UntrustedContentHost, parentRPC} from './untrusted';
import {Icon} from "./bootstrap-components";
import {
getPublicUrl,
getSandboxUrl,
getTrustedUrl
} from "./urls";
import {
base,
unbase
} from "../../../shared/templates";
import CKEditor from './ckeditor';
@translate(null, { withRef: true })
export class CKEditorHost extends Component {
constructor(props) {
super(props);
this.state = {}
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
initialHtml: PropTypes.string
}
async exportState() {
return await this.contentNode.ask('exportState');
}
render() {
const t = this.props.t;
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
initialHtml: this.props.initialHtml
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={styles.editor}>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
</div>
);
}
}
CKEditorHost.prototype.exportState = async function() {
return await this.getWrappedInstance().exportState();
};
@translate(null, { withRef: true })
export class CKEditorSandbox extends Component {
constructor(props) {
super(props);
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const html = this.props.initialHtml && base(this.props.initialHtml, trustedUrlBase, sandboxUrlBase, publicUrlBase);
this.state = {
html
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
initialHtml: PropTypes.string
}
async exportState(method, params) {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
return {
html: unbase(this.state.html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
};
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
}
render() {
return (
<div className={styles.sandbox}>
<CKEditor
onChange={(event, editor) => this.setState({html: editor.getData()})}
data={this.state.html}
/>
</div>
);
}
}

View file

@ -0,0 +1,10 @@
.editor {
.host {
}
}
.sandbox {
:global .ck-editor__editable {
height: 500px;
}
}

View file

@ -6,7 +6,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import {I18nextProvider,} from 'react-i18next';
import i18n from './i18n';
import {MosaicoSandbox} from './mosaico';
import {MosaicoSandbox} from './sandboxed-mosaico';
import {UntrustedContentRoot, parentRPC} from './untrusted';
export default function() {

View file

@ -3,7 +3,7 @@
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import PropTypes from "prop-types";
import styles from "./mosaico.scss";
import styles from "./sandboxed-mosaico.scss";
import {UntrustedContentHost, parentRPC} from './untrusted';
import {Icon} from "./bootstrap-components";
@ -18,13 +18,8 @@ import {
} from "../../../shared/templates";
export const ResourceType = {
TEMPLATE: 'template',
CAMPAIGN: 'campaign'
}
@translate(null, { withRef: true })
export class MosaicoEditor extends Component {
export class MosaicoEditorHost extends Component {
constructor(props) {
super(props);
@ -59,7 +54,7 @@ export class MosaicoEditor extends Component {
render() {
const t = this.props.t;
const mosaicoData = {
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
templateId: this.props.templateId,
@ -68,6 +63,11 @@ export class MosaicoEditor extends Component {
initialMetadata: this.props.initialMetadata
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
@ -75,13 +75,13 @@ export class MosaicoEditor extends Component {
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={mosaicoData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={mosaicoData}/>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
</div>
);
}
}
MosaicoEditor.prototype.exportState = async function() {
MosaicoEditorHost.prototype.exportState = async function() {
return await this.getWrappedInstance().exportState();
};

View file

@ -121,6 +121,12 @@
color: #808080;
}
.dropZoneActive{
border-color: #90EE90;
color: #000;
background-color: #DDFFDD;
}
.untrustedContent {
border: 0px none;

View file

@ -16,7 +16,6 @@ import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";
import {getUrl} from "./urls";
import {Table} from "./table";
const TreeSelectMode = {
NONE: 0,

View file

@ -59,7 +59,7 @@ export default class List extends Component {
const perms = data[9];
const campaignId = data[8];
if (mailtrainConfig.globalPermissions.includes('setupAutomation') && perms.includes('manageTriggers')) {
if (mailtrainConfig.globalPermissions.setupAutomation && perms.includes('manageTriggers')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/campaigns/${campaignId}/triggers/${data[0]}/edit`

View file

@ -66,7 +66,7 @@ export default class List extends Component {
refreshTimeout = 1000;
}
if (mailtrainConfig.globalPermissions.includes('setupAutomation') && this.props.list.permissions.includes('manageImports')) {
if (mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/lists/${this.props.list.id}/imports/${data[0]}/edit`
@ -90,7 +90,7 @@ export default class List extends Component {
return (
<div>
{tableDeleteDialogRender(this, `rest/imports/${this.props.list.id}`, t('Deleting import ...'), t('Import deleted'))}
{mailtrainConfig.globalPermissions.includes('setupAutomation') && this.props.list.permissions.includes('manageImports') &&
{mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports') &&
<Toolbar>
<NavButton linkTo={`/lists/${this.props.list.id}/imports/create`} className="btn-primary" icon="plus" label={t('Create Import')}/>
</Toolbar>

View file

@ -76,7 +76,7 @@ function getMenus(t) {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/reports/templates/${params.templateId}/edit`,
visible: resolved => mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && resolved.template.permissions.includes('edit'),
visible: resolved => mailtrainConfig.globalPermissions.createJavascriptWithROAccess && resolved.template.permissions.includes('edit'),
panelRender: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
},
share: {

View file

@ -38,7 +38,7 @@ export default class List extends Component {
});
this.setState({
createPermitted: result.data.createReportTemplate && mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess')
createPermitted: result.data.createReportTemplate && mailtrainConfig.globalPermissions.createJavascriptWithROAccess
});
}
@ -60,7 +60,7 @@ export default class List extends Component {
const actions = [];
const perms = data[5];
if (mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && perms.includes('edit')) {
if (mailtrainConfig.globalPermissions.createJavascriptWithROAccess && perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/reports/templates/${data[0]}/edit`

View file

@ -84,9 +84,9 @@ class Root extends Component {
<DropdownMenuItem label={t('Administration')}>
<MenuLink to="/users"><Icon icon='cog'/> {t('Users')}</MenuLink>
<MenuLink to="/namespaces"><Icon icon='cog'/> {t('Namespaces')}</MenuLink>
{mailtrainConfig.globalPermissions.includes('manageSettings') && <MenuLink to="/settings"><Icon icon='cog'/> {t('Global Settings')}</MenuLink>}
{mailtrainConfig.globalPermissions.manageSettings && <MenuLink to="/settings"><Icon icon='cog'/> {t('Global Settings')}</MenuLink>}
<MenuLink to="/send-configurations"><Icon icon='cog'/> {t('Send Configurations')}</MenuLink>
{mailtrainConfig.globalPermissions.includes('manageBlacklist') && <MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</MenuLink>}
{mailtrainConfig.globalPermissions.manageBlacklist && <MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</MenuLink>}
<MenuLink to="/account/api"><Icon icon='retweet'/> {t('API')}</MenuLink>
</DropdownMenuItem>
</ul>

View file

@ -12,10 +12,8 @@ import {
import 'brace/mode/text';
import 'brace/mode/html';
import {
MosaicoEditor,
ResourceType
} from "../lib/mosaico";
import { MosaicoEditorHost } from "../lib/sandboxed-mosaico";
import { CKEditorHost } from "../lib/sandboxed-ckeditor";
import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers';
import {getSandboxUrl} from "../lib/urls";
@ -28,6 +26,10 @@ import {Trans} from "react-i18next";
import styles from "../lib/styles.scss";
export const ResourceType = {
TEMPLATE: 'template',
CAMPAIGN: 'campaign'
}
export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEMPLATE) {
// The prefix is used to to enable use within other forms (i.e. campaign form)
@ -67,7 +69,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
<TableSelect id={prefix + 'mosaicoTemplate'} label={t('Mosaico template')} withHeader dropdown dataUrl='rest/mosaico-templates-table' columns={mosaicoTemplatesColumns} selectionLabelIndex={1} disabled={isEdit} />,
getHTMLEditor: owner =>
<AlignedRow label={t('Template content (HTML)')}>
<MosaicoEditor
<MosaicoEditorHost
ref={node => owner.editorNode = node}
entity={owner.props.entity}
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
@ -129,7 +131,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
},
getHTMLEditor: owner =>
<AlignedRow label={t('Template content (HTML)')}>
<MosaicoEditor
<MosaicoEditorHost
ref={node => owner.editorNode = node}
entity={owner.props.entity}
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
@ -172,7 +174,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
validate: state => {}
};
templateTypes.grapejs = { // TODO
templateTypes.grapejs = { // FIXME
typeName: t('GrapeJS'),
getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => null,
@ -186,10 +188,34 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
validate: state => {}
};
templateTypes.xckeditor = {
typeName: t('CKEditor'),
getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner =>
<AlignedRow label={t('Template content (HTML)')}>
<CKEditorHost
ref={node => owner.editorNode = node}
entity={owner.props.entity}
initialHtml={owner.getFormValue(prefix + 'html')}
entityTypeId={entityTypeId}/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
},
initData: () => ({}),
afterLoad: data => {},
beforeSave: data => {
clearBeforeSave(data);
},
afterTypeChange: mutState => {},
validate: state => {}
};
templateTypes.ckeditor = {
typeName: t('CKEditor'),
getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" label={t('Template content (HTML)')}/>,
getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" mode="html" label={t('Template content (HTML)')}/>,
exportHTMLEditorData: async owner => {},
initData: () => ({}),
afterLoad: data => {},
@ -214,7 +240,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
validate: state => {}
};
templateTypes.mjml = { // TODO
templateTypes.mjml = { // FIXME
getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => null,
exportHTMLEditorData: async owner => {},

View file

@ -1,10 +1,21 @@
const webpack = require('webpack');
const path = require('path');
// The CKEditor part comes from https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/advanced-setup.html
const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin' );
const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );
module.exports = {
plugins: [
new CKEditorWebpackPlugin( {
// See https://ckeditor.com/docs/ckeditor5/latest/features/ui-language.html
language: 'en'
} )
],
entry: {
root: ['babel-polyfill', './src/root.js'],
mosaico: ['babel-polyfill', './src/lib/mosaico-sandbox-root.js'],
mosaico: ['babel-polyfill', './src/lib/sandboxed-mosaico-root.js'],
ckeditor: ['babel-polyfill', './src/lib/sandboxed-ckeditor-root.js'],
},
output: {
library: 'MailtrainReactBody',
@ -15,12 +26,51 @@ module.exports = {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(disposables|react-dnd-touch-backend|attr-accept)/ /* https://github.com/react-dnd/react-dnd/issues/407 */,
use: [ 'babel-loader' ]
exclude: path.join(__dirname, 'node_modules'),
use: [
{
loader: 'babel-loader',
options: {
presets: [
['env', {
targets: {
"chrome": "58",
"edge": "15",
"firefox": "55",
"ios": "10"
}
}],
'stage-1'
],
plugins: ['transform-react-jsx', 'transform-decorators-legacy', 'transform-function-bind']
}
}
]
// exclude: /(disposables|react-dnd-touch-backend|attr-accept)/ /* https://github.com/react-dnd/react-dnd/issues/407 */,
// use: [ 'babel-loader' ]
},
{
test: /\.css$/,
use: [ 'style-loader', 'css-loader' ]
use: [
{
loader: 'style-loader',
options: {
singleton: true
}
},
{
loader: 'css-loader'
},
{
loader: 'postcss-loader',
options: styles.getPostCssConfig( {
themeImporter: {
themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
},
minify: false
} )
},
]
},
{
test: /\.(png|jpg|gif)$/,
@ -33,6 +83,13 @@ module.exports = {
}
]
},
{
// Or /ckeditor5-[^/]+\/theme\/icons\/[^/]+\.svg$/ if you want to limit this loader
// to CKEditor 5 icons only.
test: /\.svg$/,
use: [ 'raw-loader' ]
},
{
test: /\.scss$/,
exclude: path.join(__dirname, 'node_modules'),

View file

@ -5,7 +5,6 @@ const config = require('config');
const forms = require('../models/forms');
const shares = require('../models/shares');
const urls = require('./urls');
const { AppType } = require('../shared/app');
async function getAnonymousConfig(context, appType) {
@ -26,6 +25,11 @@ async function getAnonymousConfig(context, appType) {
}
async function getAuthenticatedConfig(context) {
const globalPermissions = {};
for (const perm of shares.getGlobalPermissions(context)) {
globalPermissions[perm] = true;
}
return {
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
user: {
@ -33,7 +37,7 @@ async function getAuthenticatedConfig(context) {
username: context.user.username,
namespace: context.user.namespace
},
globalPermissions: shares.getGlobalPermissions(context),
globalPermissions,
editors: config.editors,
mosaico: config.mosaico,
verpEnabled: config.verp.enabled

View file

@ -55,4 +55,4 @@ async function ensureNoDependencies(tx, context, id, depSpecs) {
}
}
module.exports.ensureNoDependencies = ensureNoDependencies
module.exports.ensureNoDependencies = ensureNoDependencies;

View file

@ -1,7 +1,5 @@
'use strict';
let _ = require('./translate')._;
module.exports = {
enforce,
cleanupFromPost,

View file

@ -24,7 +24,7 @@ async function listTree(context) {
// Build a tree
const rows = await knex('namespaces')
.innerJoin(entityType.permissionsTable, {
.leftJoin(entityType.permissionsTable, {
[entityType.permissionsTable + '.entity']: 'namespaces.id',
[entityType.permissionsTable + '.user']: context.user.id
})
@ -63,7 +63,7 @@ async function listTree(context) {
entry.key = row.id;
entry.title = row.name;
entry.description = row.description;
entry.permissions = row.permissions.split(';');
entry.permissions = row.permissions ? row.permissions.split(';') : [];
}
// Prune out the inaccessible namespaces
@ -160,7 +160,7 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.DependencyNotFoundError();
}
if (iter.id == entity.id) {
if (iter.id === entity.id) {
throw new interoperableErrors.LoopDetectedError();
}
}

View file

@ -6,7 +6,7 @@ const { enforce } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const entitySettings = require('../lib/entity-settings');
const interoperableErrors = require('../shared/interoperable-errors');
const log = require('npmlog');
const log = require('../lib/log');
const {getGlobalNamespaceId} = require('../shared/namespaces');
const {getAdminId} = require('../shared/users');

View file

@ -0,0 +1,61 @@
'use strict';
const routerFactory = require('../lib/router-async');
const passport = require('../lib/passport');
const clientHelpers = require('../lib/client-helpers');
const users = require('../models/users');
const files = require('../models/files');
const fileHelpers = require('../lib/file-helpers');
const templates = require('../models/templates');
const contextHelpers = require('../lib/context-helpers');
const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
const { AppType } = require('../shared/app');
users.registerRestrictedAccessTokenMethod('ckeditor', async ({entityTypeId, entityId}) => {
if (entityTypeId === 'template') {
const tmpl = await templates.getById(contextHelpers.getAdminContext(), entityId, false);
if (tmpl.type === 'ckeditor') {
return {
permissions: {
'template': {
[entityId]: new Set(['manageFiles', 'view'])
}
}
};
}
}
});
function getRouter(appType) {
const router = routerFactory.create();
if (appType === AppType.SANDBOXED) {
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', files.ReplacementBehavior.RENAME, null, 'file');
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, appType);
res.render('ckeditor/root', {
layout: 'ckeditor/layout',
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(mailtrainConfig),
scriptFiles: [
getSandboxUrl('mailtrain/common.js'),
getSandboxUrl('mailtrain/ckeditor.js')
],
publicPath: getSandboxUrl()
});
});
}
return router;
}
module.exports.getRouter = getRouter;

View file

@ -1,8 +1,8 @@
'use strict';
const config = require('config');
const express = require('express');
const path = require('path');
const express = require('express');
const routerFactory = require('../lib/router-async');
const passport = require('../lib/passport');
const clientHelpers = require('../lib/client-helpers');
@ -201,7 +201,7 @@ function getRouter(appType) {
getSandboxUrl('mailtrain/common.js'),
getSandboxUrl('mailtrain/mosaico.js')
],
mosaicoPublicPath: getSandboxUrl('static/mosaico')
publicPath: getSandboxUrl()
});
});

View file

@ -136,7 +136,6 @@ const errorTypes = {
function deserialize(errorObj) {
if (errorObj.type) {
const ctor = errorTypes[errorObj.type];
if (ctor) {
return new ctor(errorObj.message, errorObj.data);

7532
shared/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,5 +16,21 @@
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-loader": "^7.1.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-function-bind": "^6.22.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"css-loader": "^0.28.4",
"i18next-conv": "^3.0.3",
"node-sass": "^4.5.3",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"url-loader": "^0.5.9",
"webpack": "^2.6.1"
}
}

37
views/ckeditor/layout.hbs Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}">
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico">
<title>Mailtrain
{{#if title}} | {{title}}{{/if}}
</title>
<link rel="stylesheet" href="{{publicPath}}static/bootstrap/themes/united.min.css">
<script src="{{publicPath}}static/jquery-2.2.1.min.js"></script>
<script src="{{publicPath}}static/bootstrap/js/bootstrap.min.js"></script>
{{#if mailtrainConfig}}
<script>
{{#if reactCsrfToken}}window.csfrToken = '{{reactCsrfToken}}';{{/if}}
window.mailtrainConfig = {{{mailtrainConfig}}};
</script>
{{#each scriptFiles}}
<script src="{{this}}"></script>
{{/each}}
{{/if}}
</head>
<body>
{{{body}}}
</body>
</html>

6
views/ckeditor/root.hbs Normal file
View file

@ -0,0 +1,6 @@
<div id="root"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
MailtrainReactBody.default();
});
</script>

View file

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}">
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
<link rel="icon" href="/static/favicon.ico">
<title>Mailtrain

View file

@ -4,27 +4,27 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=1024, initial-scale=1">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
<title>Mailtrain</title>
<script src="{{mosaicoPublicPath}}/vendor/jquery.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery-migrate.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/knockout.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery-ui.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.ui.touch-punch.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/load-image.all.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/canvas-to-blob.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.iframe-transport.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.fileupload.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.fileupload-process.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.fileupload-image.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/jquery.fileupload-validate.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/knockout-jqueryui.min.js"></script>
<script src="{{mosaicoPublicPath}}/vendor/tinymce.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery-migrate.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/knockout.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery-ui.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.ui.touch-punch.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/load-image.all.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/canvas-to-blob.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.iframe-transport.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.fileupload.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.fileupload-process.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.fileupload-image.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/jquery.fileupload-validate.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/knockout-jqueryui.min.js"></script>
<script src="{{publicPath}}static/mosaico/vendor/tinymce.min.js"></script>
<script src="{{mosaicoPublicPath}}/mosaico.min.js?v=0.16"></script>
<script src="{{publicPath}}static/mosaico/mosaico.min.js?v=0.16"></script>
{{#if languageStrings}}<script> window.mosaicoLanguageStrings = {{{languageStrings}}}; </script>{{/if}}
<script> window.mosaicoPlugins = []; </script>
@ -45,13 +45,11 @@
<script src="{{this}}"></script>
{{/each}}
<link rel="stylesheet" href="{{mosaicoPublicPath}}/mosaico-material.min.css?v=0.10" />
<link rel="stylesheet" href="{{mosaicoPublicPath}}/vendor/notoregular/stylesheet.css" />
<link rel="stylesheet" href="{{publicPath}}static/mosaico/mosaico-material.min.css?v=0.10" />
<link rel="stylesheet" href="{{publicPath}}static/mosaico/vendor/notoregular/stylesheet.css" />
</head>
<body class="mo-standalone">
{{{body}}}
{{> tracking_scripts}}
</body>
</html>