Files can be added to templates and managed in a dedicated "Files" view.

Mosaico integration in progress. The files seem to be working for Mosaico.
This commit is contained in:
Tomas Bures 2018-03-24 23:55:50 +01:00
parent c85f2d4440
commit b5cdf57f72
23 changed files with 506 additions and 164 deletions

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ public/grapejs/templates/*
config/production.toml config/production.toml
workers/reports/config/production.toml workers/reports/config/production.toml
docker-compose.override.yml docker-compose.override.yml
/files

15
app.js
View file

@ -30,6 +30,7 @@ const api = require('./routes/api');
const reports = require('./routes/reports'); const reports = require('./routes/reports');
const subscription = require('./routes/subscription'); const subscription = require('./routes/subscription');
const mosaico = require('./routes/mosaico'); const mosaico = require('./routes/mosaico');
const files = require('./routes/files');
const namespacesRest = require('./routes/rest/namespaces'); const namespacesRest = require('./routes/rest/namespaces');
const usersRest = require('./routes/rest/users'); const usersRest = require('./routes/rest/users');
@ -46,6 +47,7 @@ const subscriptionsRest = require('./routes/rest/subscriptions');
const templatesRest = require('./routes/rest/templates'); 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 editorsRest = require('./routes/rest/editors');
const filesRest = require('./routes/rest/files');
const root = require('./routes/root'); const root = require('./routes/root');
@ -55,15 +57,11 @@ const app = express();
function install404Fallback(url) { function install404Fallback(url) {
app.use(url, (req, res, next) => { app.use(url, (req, res, next) => {
let err = new Error(_('Not Found')); next(new interoperableErrors.NotFoundError());
err.status = 404;
next(err);
}); });
app.use(url + '/*', (req, res, next) => { app.use(url + '/*', (req, res, next) => {
let err = new Error(_('Not Found')); next(new interoperableErrors.NotFoundError());
err.status = 404;
next(err);
}); });
} }
@ -254,17 +252,17 @@ app.use((req, res, next) => {
// Regular endpoints // Regular endpoints
useWith404Fallback('/subscription', subscription); useWith404Fallback('/subscription', subscription);
useWith404Fallback('/files', files);
useWith404Fallback('/mosaico', mosaico);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
useWith404Fallback('/reports', reports); useWith404Fallback('/reports', reports);
} }
useWith404Fallback('/mosaico', mosaico);
// API endpoints // API endpoints
useWith404Fallback('/api', api); useWith404Fallback('/api', api);
/* ------------------------------------------------------------------- */
// REST endpoints // REST endpoints
app.use('/rest', namespacesRest); app.use('/rest', namespacesRest);
@ -280,6 +278,7 @@ app.use('/rest', subscriptionsRest);
app.use('/rest', templatesRest); app.use('/rest', templatesRest);
app.use('/rest', blacklistRest); app.use('/rest', blacklistRest);
app.use('/rest', editorsRest); app.use('/rest', editorsRest);
app.use('/rest', filesRest);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest); app.use('/rest', reportTemplatesRest);

View file

@ -3,18 +3,19 @@
import React, {Component} from "react"; import React, {Component} from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {translate} from "react-i18next"; import {translate} from "react-i18next";
import {requiresAuthenticatedUser} from "./lib/page"; import {requiresAuthenticatedUser} from "./page";
import {ACEEditor, Button, Form, FormSendMethod, withForm} from "./lib/form"; import {withErrorHandling} from "./error-handling";
import {withErrorHandling} from "./lib/error-handling"; import {Table} from "./table";
import {Table} from "./lib/table";
import Dropzone from "react-dropzone"; import Dropzone from "react-dropzone";
import {ModalDialog} from "./lib/modals"; import {ModalDialog} from "./modals";
import {Icon} from "./lib/bootstrap-components"; import {Icon} from "./bootstrap-components";
import axios from './axios'; import axios from './axios';
import styles from "./styles.scss";
import {withPageHelpers} from "./page";
@translate() @translate()
@withForm
@withErrorHandling @withErrorHandling
@withPageHelpers
@requiresAuthenticatedUser @requiresAuthenticatedUser
export default class Files extends Component { export default class Files extends Component {
constructor(props) { constructor(props) {
@ -26,16 +27,18 @@ export default class Files extends Component {
}; };
const t = props.t; const t = props.t;
this.initForm();
} }
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
entity: PropTypes.object, entity: PropTypes.object,
entityTypeId: PropTypes.string entityTypeId: PropTypes.string,
usePublicDownloadUrls: PropTypes.bool
} }
static defaultProps = {
usePublicDownloadUrls: true
}
getFilesUploadedMessage(response){ getFilesUploadedMessage(response){
const t = this.props.t; const t = this.props.t;
@ -56,21 +59,21 @@ export default class Files extends Component {
onDrop(files){ onDrop(files){
const t = this.props.t; const t = this.props.t;
if (files.length > 0) { if (files.length > 0) {
this.setFormStatusMessage('info', t('Uploading {{count}} file(s)', files.length)); this.setFlashMessage('info', t('Uploading {{count}} file(s)', files.length));
const data = new FormData(); const data = new FormData();
for (const file of files) { for (const file of files) {
data.append('file', file) data.append('files[]', file)
} }
axios.put(`/rest/files/${this.props.entityTypeId}, ${this.props.entity.id}`, data) axios.post(`/rest/files/${this.props.entityTypeId}/${this.props.entity.id}`, data)
.then(res => { .then(res => {
this.filesTable.refresh(); this.filesTable.refresh();
const message = this.getFilesUploadedMessage(res); const message = this.getFilesUploadedMessage(res);
this.setFormStatusMessage('info', message); this.setFlashMessage('info', message);
}) })
.catch(res => this.setFormStatusMessage('danger', t('File upload failed: ') + res.message)); .catch(res => this.setFlashMessage('danger', t('File upload failed: ') + res.message));
} }
else{ else{
this.setFormStatusMessage('info', t('No files to upload')); this.setFlashMessage('info', t('No files to upload'));
} }
} }
@ -88,16 +91,13 @@ export default class Files extends Component {
await this.hideDeleteFile(); await this.hideDeleteFile();
try { try {
this.disableForm(); this.setFlashMessage('info', t('Deleting file ...'));
this.setFormStatusMessage('info', t('Deleting file ...'));
await axios.delete(`/rest/files/${this.props.entityTypeId}/${fileToDeleteId}`); await axios.delete(`/rest/files/${this.props.entityTypeId}/${fileToDeleteId}`);
this.filesTable.refresh(); this.filesTable.refresh();
this.setFormStatusMessage('info', t('File deleted')); this.setFlashMessage('info', t('File deleted'));
this.enableForm();
} catch (err) { } catch (err) {
this.filesTable.refresh(); this.filesTable.refresh();
this.setFormStatusMessage('danger', t('Delete file failed: ') + err.message); this.setFlashMessage('danger', t('Delete file failed: ') + err.message);
this.enableForm();
} }
} }
@ -106,14 +106,21 @@ export default class Files extends Component {
const columns = [ const columns = [
{ data: 1, title: "Name" }, { data: 1, title: "Name" },
{ data: 2, title: "Size" }, { data: 3, title: "Size" },
{ {
actions: data => { actions: data => {
let downloadUrl;
if (this.props.usePublicDownloadUrls) {
downloadUrl =`/files/${this.props.entityTypeId}/${this.props.entity.id}/${data[2]}`;
} else {
downloadUrl =`/rest/files/${this.props.entityTypeId}/${data[0]}`;
}
const actions = [ const actions = [
{ {
label: <Icon icon="download" title={t('Download')}/>, label: <Icon icon="download" title={t('Download')}/>,
href: `/rest/files/${this.props.entityTypeId}/${data[0]}` href: downloadUrl
}, },
{ {
label: <Icon icon="remove" title={t('Delete')}/>, label: <Icon icon="remove" title={t('Delete')}/>,
@ -138,10 +145,10 @@ export default class Files extends Component {
]}> ]}>
{t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})} {t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})}
</ModalDialog> </ModalDialog>
<Dropzone onDrop={::this.onDrop} className="dropZone" activeClassName="dropZoneActive"> <Dropzone onDrop={::this.onDrop} className={styles.dropZone} activeClassName="dropZoneActive">
{state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')} {state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')}
</Dropzone> </Dropzone>
<Table withHeader ref={node => this.filesTable = node} dataUrl={`/rest/template-files-table/${this.props.entity.id}`} columns={columns} /> <Table withHeader ref={node => this.filesTable = node} dataUrl={`/rest/files-table/${this.props.entityTypeId}/${this.props.entity.id}`} columns={columns} />
</div> </div>
); );
} }

View file

@ -87,3 +87,16 @@
.secondaryNav > li > a { .secondaryNav > li > a {
padding: 3px 10px; padding: 3px 10px;
} }
.dropZone {
padding-top: 30px;
padding-bottom: 30px;
margin-bottom: 15px;
margin-top: 8px;
border: 2px solid #E6E9ED;
border-radius: 5px;
background-color: #FAFAD2;
text-align: center;
font-size: 20px;
color: #808080;
}

View file

@ -8,20 +8,41 @@ import {
} from 'react-i18next'; } from 'react-i18next';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import styles from "./styles.scss";
const ResourceType = {
TEMPLATE: 'template',
CAMPAIGN: 'campaign'
}
@translate() @translate()
class MosaicoEditor extends Component { class MosaicoEditor extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.viewModel = null;
this.state = {
entityTypeId: ResourceType.TEMPLATE, // FIXME
entityId: 13 // FIXME
}
} }
static propTypes = { static propTypes = {
//structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired, //structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
} }
async onClose(evt) {
const t = this.props.t;
evt.preventDefault();
evt.stopPropagation();
if (confirm(t('Unsaved changes will be lost. Close now?'))) {
window.location.href = `/${this.state.entityTypeId}s/${this.state.entityId}/edit`;
}
}
componentDidMount() { componentDidMount() {
const basePath = '/public/mosaico'; const publicPath = '/public/mosaico';
if (!Mosaico.isCompatible()) { if (!Mosaico.isCompatible()) {
alert('Update your browser!'); alert('Update your browser!');
@ -30,35 +51,57 @@ class MosaicoEditor extends Component {
const plugins = window.mosaicoPlugins; const plugins = window.mosaicoPlugins;
plugins.push(viewModel => {
this.viewModel = viewModel;
});
// (Custom) HTML postRenderers
plugins.push(viewModel => {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = () => {
let html = viewModel.originalExportHTML();
for (const portRender of window.mosaicoHTMLPostRenderers) {
html = postRender(html);
}
return html;
};
});
plugins.unshift(vm => { plugins.unshift(vm => {
// This is a fix for the use of hardcoded path in Mosaico // This is a fix for the use of hardcoded path in Mosaico
vm.logoPath = basePath + '/img/mosaico32.png' vm.logoPath = publicPath + '/img/mosaico32.png'
}); });
const config = { const config = {
imgProcessorBackend: basePath+'/img/', imgProcessorBackend: `/mosaico/img/${this.state.entityTypeId}/${this.state.entityId}`,
emailProcessorBackend: basePath+'/dl/', emailProcessorBackend: '/mosaico/dl/',
titleToken: "MOSAICO Responsive Email Designer", titleToken: "MOSAICO Responsive Email Designer",
fileuploadConfig: { fileuploadConfig: {
url: basePath+'/upload/' url: `/mosaico/upload/${this.state.entityTypeId}/${this.state.entityId}`
}, },
strings: window.mosaicoLanguageStrings strings: window.mosaicoLanguageStrings
}; };
const metadata = undefined; const metadata = undefined;
const model = undefined; const model = undefined;
const template = basePath + '/templates/versafix-1/index.html'; const template = publicPath + '/templates/versafix-1/index.html';
Mosaico.start(config, template, metadata, model, plugins); const allPlugins = plugins.concat(window.mosaicoPlugins);
Mosaico.start(config, template, metadata, model, allPlugins);
} }
componentDidUpdate() { componentDidUpdate() {
} }
render() { render() {
const t = this.props.t;
return ( return (
<div> <div className={styles.navbar}>
<img className={styles.logo} src="/public/mailtrain-header.png"/>
<a className={styles.btn} onClick={::this.onClose}>{t('CLOSE')}</a>
<a className={styles.btn}><span></span></a>
</div> </div>
); );
} }

View file

@ -0,0 +1,35 @@
:global .mo-standalone {
top: 34px;
bottom: 0px;
width: 100%;
position: absolute;
}
.navbar {
background: #DE4320;
overflow: hidden;
height: 34px;
top: -34px;
position: absolute;
width: 100%;
}
.logo {
height: 24px;
padding: 5px 0 5px 10px;
filter: brightness(0) invert(1);
}
.btn {
display: block;
float: right;
width: 150px;
line-height: 34px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
font-family: sans-serif;
cursor: pointer;
border-left: 1px solid #972E15;
}

View file

@ -64,6 +64,13 @@ export default class List extends Component {
}); });
} }
if (perms.includes('manageFiles')) {
actions.push({
label: <Icon icon="hdd" title={t('Files')}/>,
link: `/templates/${data[0]}/files`
});
}
if (perms.includes('share')) { if (perms.includes('share')) {
actions.push({ actions.push({
label: <Icon icon="share-alt" title={t('Share')}/>, label: <Icon icon="share-alt" title={t('Share')}/>,

View file

@ -9,6 +9,7 @@ import { Section } from '../lib/page';
import TemplatesCUD from './CUD'; import TemplatesCUD from './CUD';
import TemplatesList from './List'; import TemplatesList from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import Files from "../lib/files";
function getMenus(t) { function getMenus(t) {
@ -31,6 +32,12 @@ function getMenus(t) {
visible: resolved => resolved.template.permissions.includes('edit'), visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} /> panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
}, },
files: {
title: t('Files'),
link: params => `/templates/${params.templateId}/files`,
visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <Files title={t('Files')} entity={props.resolved.template} entityTypeId="template" />
},
share: { share: {
title: t('Share'), title: t('Share'),
link: params => `/templates/${params.templateId}/share`, link: params => `/templates/${params.templateId}/share`,

View file

@ -15,7 +15,7 @@ module.exports = {
rules: [ rules: [
{ {
test: /\.(js|jsx)$/, test: /\.(js|jsx)$/,
exclude: /(disposables|react-dnd-touch-backend)/ /* https://github.com/react-dnd/react-dnd/issues/407 */, exclude: /(disposables|react-dnd-touch-backend|attr-accept)/ /* https://github.com/react-dnd/react-dnd/issues/407 */,
use: [ 'babel-loader' ] use: [ 'babel-loader' ]
}, },
{ {

View file

@ -208,8 +208,8 @@ permissions=["view", "edit", "delete", "share", "createNamespace", "createList",
[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"]
customForm=["view", "edit", "delete", "share"] customForm=["view", "edit", "delete", "share"]
campaign=["view", "edit", "delete", "share"] campaign=["view", "edit", "delete", "share", "manageFiles"]
template=["view", "edit", "delete", "share"] template=["view", "edit", "delete", "share", "manageFiles"]
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", "createTemplate", "manageUsers"] namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"]
@ -227,12 +227,12 @@ permissions=["view", "edit", "delete", "share"]
[roles.campaign.master] [roles.campaign.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share"] permissions=["view", "edit", "delete", "share", "manageFiles"]
[roles.template.master] [roles.template.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view", "edit", "delete", "share"] permissions=["view", "edit", "delete", "share", "manageFiles"]
[roles.report.master] [roles.report.master]
name="Master" name="Master"

View file

@ -1,32 +1,21 @@
'use strict'; 'use strict';
const passport = require('./passport');
const files = require('../models/files');
const path = require('path'); const path = require('path');
const uploadedFilesDir = path.join(files.filesDir, 'uploaded');
function nameToFileName(name) { const multer = require('multer')({
return name. dest: uploadedFilesDir
trim(). });
toLowerCase().
replace(/[ .+/]/g, '-'). function installUploadHandler(router, url, dontReplace = false) {
replace(/[^a-z0-9\-_]/gi, ''). router.postAsync(url, passport.loggedIn, multer.array('files[]'), async (req, res) => {
replace(/--*/g, '-'); return res.json(await files.createFiles(req.context, req.params.type, req.params.entityId, req.files, dontReplace));
});
} }
function getReportFileBase(report) {
return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + nameToFileName(report.name));
}
function getReportContentFile(report) {
return getReportFileBase(report) + '.out';
}
function getReportOutputFile(report) {
return getReportFileBase(report) + '.err';
}
module.exports = { module.exports = {
getReportContentFile, installUploadHandler
getReportOutputFile,
nameToFileName
}; };

32
lib/report-helpers.js Normal file
View file

@ -0,0 +1,32 @@
'use strict';
const path = require('path');
function nameToFileName(name) {
return name.
trim().
toLowerCase().
replace(/[ .+/]/g, '-').
replace(/[^a-z0-9\-_]/gi, '').
replace(/--*/g, '-');
}
function getReportFileBase(report) {
return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + nameToFileName(report.name));
}
function getReportContentFile(report) {
return getReportFileBase(report) + '.out';
}
function getReportOutputFile(report) {
return getReportFileBase(report) + '.err';
}
module.exports = {
getReportContentFile,
getReportOutputFile,
nameToFileName
};

View file

@ -6,37 +6,49 @@ const dtHelpers = require('../lib/dt-helpers');
const shares = require('./shares'); const shares = require('./shares');
const fs = require('fs-extra-promise'); const fs = require('fs-extra-promise');
const path = require('path'); const path = require('path');
const interoperableErrors = require('../shared/interoperable-errors');
const filesDir = path.join(__dirname, '..', 'files'); const filesDir = path.join(__dirname, '..', 'files');
const permittedTypes = new Set(['template']); const permittedTypes = new Set(['template']);
function getFilePath(type, entityId, filename) { function getFilePath(type, entityId, filename) {
return path.join(path.join(filesDir, type, id.toString()), filename); return path.join(path.join(filesDir, type, entityId.toString()), filename);
} }
function getFilesTable(type) { function getFilesTable(type) {
return 'files_' + type; return 'files_' + type;
} }
async function listFilesDTAjax(context, type, entityId, params) { async function listDTAjax(context, type, entityId, params) {
enforce(permittedTypes.has(type)); enforce(permittedTypes.has(type));
await shares.enforceEntityPermission(context, type, entityId, 'edit'); await shares.enforceEntityPermission(context, type, entityId, 'manageFiles');
return await dtHelpers.ajaxList( return await dtHelpers.ajaxList(
params, params,
builder => builder.from(getFilesTable(type)).where({entity: entityId}), builder => builder.from(getFilesTable(type)).where({entity: entityId}),
['id', 'originalname', 'size', 'created'] ['id', 'originalname', 'filename', 'size', 'created']
); );
} }
async function list(context, type, entityId) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermission(context, type, entityId, 'view');
return await tx(getFilesTable(type)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
});
}
async function getFileById(context, type, id) { async function getFileById(context, type, id) {
enforce(permittedTypes.has(type)); enforce(permittedTypes.has(type));
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await knex(getFilesTable(type)).where('id', id).first(); const file = await tx(getFilesTable(type)).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'edit'); await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
return file; return file;
}); });
if (!file) {
throw new interoperableErrors.NotFoundError();
}
return { return {
mimetype: file.mimetype, mimetype: file.mimetype,
name: file.originalname, name: file.originalname,
@ -44,14 +56,18 @@ async function getFileById(context, type, id) {
}; };
} }
async function getFileByName(context, type, entityId, name) { async function getFileByFilename(context, type, entityId, name) {
enforce(permittedTypes.has(type)); enforce(permittedTypes.has(type));
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view'); await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view');
const file = await knex(getFilesTable(type)).where({entity: entityId, originalname: name}).first(); const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first();
return file; return file;
}); });
if (!file) {
throw new interoperableErrors.NotFoundError();
}
return { return {
mimetype: file.mimetype, mimetype: file.mimetype,
name: file.originalname, name: file.originalname,
@ -59,85 +75,114 @@ async function getFileByName(context, type, entityId, name) {
}; };
} }
async function createFiles(context, type, entityId, files) { async function createFiles(context, type, entityId, files, dontReplace = false) {
enforce(permittedTypes.has(type)); enforce(permittedTypes.has(type));
if (files.length == 0) { if (files.length == 0) {
// No files uploaded // No files uploaded
return {uploaded: 0}; return {uploaded: 0};
} }
const fileEntities = [];
const filesToMove = [];
const ignoredFiles = [];
const removedFiles = [];
const filesRet = [];
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'manageFiles');
const existingNamesRows = await tx(getFilesTable(type)).where('entity', entityId).select(['filename', 'originalname']);
const existingNameMap = new Map();
for (const row of existingNamesRows) {
existingNameMap.set(row.originalname, row);
}
const originalNameSet = new Set(); const originalNameSet = new Set();
const fileEntities = new Array();
const filesToMove = new Array();
const ignoredFiles = new Array();
// Create entities for files // Create entities for files
for (const file of files) { for (const file of files) {
if (originalNameSet.has(file.originalname)) { const parsedOriginalName = path.parse(file.originalname);
let originalName = parsedOriginalName.base;
if (dontReplace) {
let suffix = 1;
while (existingNameMap.has(originalName) || originalNameSet.has(originalName)) {
originalName = parsedOriginalName.name + '-' + suffix + parsedOriginalName.ext;
suffix++;
}
}
if (originalNameSet.has(originalName)) {
// The file has an original name same as another file // The file has an original name same as another file
ignoredFiles.push(file); ignoredFiles.push(file);
} else {
originalNameSet.add(file.originalname);
const fileEntity = { } else {
filesToMove.push(file);
fileEntities.push({
entity: entityId, entity: entityId,
filename: file.filename, filename: file.filename,
originalname: file.originalname, originalname: originalName,
mimetype: file.mimetype, mimetype: file.mimetype,
encoding: file.encoding, encoding: file.encoding,
size: file.size size: file.size
}; });
fileEntities.push(fileEntity); filesRet.push({
filesToMove.push(file); name: file.filename,
originalName: originalName,
size: file.size,
type: file.mimetype,
url: `/files/${type}/${entityId}/${file.filename}`
});
if (existingNameMap.has(originalName)) {
removedFiles.push(existingNameMap.get(originalName));
} }
} }
originalNameSet.add(originalName);
}
const originalNameArray = Array.from(originalNameSet); 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(); await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del();
if(fileEntities){
if (fileEntities) {
await knex(getFilesTable(type)).insert(fileEntities); await knex(getFilesTable(type)).insert(fileEntities);
} }
return removedFiles;
}); });
const removedNameSet = new Set();
// Move new files from upload directory to files directory // Move new files from upload directory to files directory
for(const file of filesToMove){ for (const file of filesToMove) {
const filePath = getFilePath(entityId, file.filename); const filePath = getFilePath(type, entityId, file.filename);
// The names should be unique, so overwrite is disabled // The names should be unique, so overwrite is disabled
// The directory is created if it does not exist // The directory is created if it does not exist
// Empty options argument is passed, otherwise fails // Empty options argument is passed, otherwise fails
await fs.move(file.path, filePath, {}); await fs.move(file.path, filePath, {});
} }
// Remove replaced files from files directory // Remove replaced files from files directory
for(const file of removedFiles){ for (const file of removedFiles) {
removedNameSet.add(file.originalname);
const filePath = getFilePath(type, entityId, file.filename); const filePath = getFilePath(type, entityId, file.filename);
await fs.remove(filePath); await fs.remove(filePath);
} }
// Remove ignored files from upload directory // Remove ignored files from upload directory
for(const file of ignoredFiles){ for (const file of ignoredFiles) {
await fs.remove(file.path); await fs.remove(file.path);
} }
return { return {
uploaded: files.length, uploaded: files.length,
added: fileEntities.length - removedNameSet.size, added: fileEntities.length - removedFiles.length,
replaced: removedFiles.length, replaced: removedFiles.length,
ignored: ignoredFiles.length ignored: ignoredFiles.length,
files: filesRet
}; };
} }
async function removeFile(context, type, id) { async function removeFile(context, type, id) {
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await knex(getFilesTable(type)).where('id', id).select('entity', 'filename').first(); const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'edit'); await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
await tx(getFilesTable(type)).where('id', id).del(); await tx(getFilesTable(type)).where('id', id).del();
return {filename: file.filename, entity: file.entity}; return {filename: file.filename, entity: file.entity};
}); });
@ -147,9 +192,11 @@ async function removeFile(context, type, id) {
} }
module.exports = { module.exports = {
listFilesDTAjax, listDTAjax,
list,
getFileById, getFileById,
getFileByName, getFileByFilename,
createFiles, createFiles,
removeFile removeFile,
filesDir
}; };

View file

@ -414,6 +414,10 @@ async function removeDefaultShares(tx, user) {
} }
function checkGlobalPermission(context, requiredOperations) { function checkGlobalPermission(context, requiredOperations) {
if (!context.user) {
return false;
}
if (context.user.admin) { // This handles the getAdminContext() case if (context.user.admin) { // This handles the getAdminContext() case
return true; return true;
} }
@ -443,6 +447,10 @@ function enforceGlobalPermission(context, requiredOperations) {
} }
async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) { async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) {
if (!context.user) {
return false;
}
const entityType = permissions.getEntityType(entityTypeId); const entityType = permissions.getEntityType(entityTypeId);
if (context.user.admin) { // This handles the getAdminContext() case. In this case we don't check the permission, but just the existence. if (context.user.admin) { // This handles the getAdminContext() case. In this case we don't check the permission, but just the existence.
@ -530,12 +538,20 @@ async function enforceTypePermissionTx(tx, context, entityTypeId, requiredOperat
} }
function getGlobalPermissions(context) { function getGlobalPermissions(context) {
if (!context.user) {
return [];
}
enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin');
return (config.roles.global[context.user.role] || {}).permissions || []; return (config.roles.global[context.user.role] || {}).permissions || [];
} }
async function getPermissionsTx(tx, context, entityTypeId, entityId) { async function getPermissionsTx(tx, context, entityTypeId, entityId) {
if (!context.user) {
return [];
}
enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin');
const entityType = permissions.getEntityType(entityTypeId); const entityType = permissions.getEntityType(entityTypeId);

28
package-lock.json generated
View file

@ -2376,6 +2376,34 @@
"universalify": "0.1.1" "universalify": "0.1.1"
} }
}, },
"fs-extra-promise": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fs-extra-promise/-/fs-extra-promise-1.0.1.tgz",
"integrity": "sha1-tu0azpexDga5X0WNBRt/BcZhPuY=",
"requires": {
"bluebird": "3.5.1",
"fs-extra": "2.1.2"
},
"dependencies": {
"fs-extra": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz",
"integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=",
"requires": {
"graceful-fs": "4.1.11",
"jsonfile": "2.4.0"
}
},
"jsonfile": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz",
"integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=",
"requires": {
"graceful-fs": "4.1.11"
}
}
}
},
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",

View file

@ -68,6 +68,7 @@
"faker": "^4.1.0", "faker": "^4.1.0",
"feedparser": "^2.2.7", "feedparser": "^2.2.7",
"fs-extra": "^4.0.2", "fs-extra": "^4.0.2",
"fs-extra-promise": "^1.0.1",
"geoip-ultralight": "^0.1.5", "geoip-ultralight": "^0.1.5",
"gettext-parser": "^1.3.0", "gettext-parser": "^1.3.0",
"gm": "^1.23.1", "gm": "^1.23.1",

13
routes/files.js Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const router = require('../lib/router-async').create();
const files = require('../models/files');
const contextHelpers = require('../lib/context-helpers');
router.getAsync('/:type/:entityId/:fileName', async (req, res) => {
const file = await files.getFileByFilename(contextHelpers.getAdminContext(), req.params.type, req.params.entityId, req.params.fileName);
res.type(file.mimetype);
return res.download(file.path, file.name);
});
module.exports = router;

View file

@ -4,16 +4,139 @@ const config = require('config');
const router = require('../lib/router-async').create(); const router = require('../lib/router-async').create();
const passport = require('../lib/passport'); const passport = require('../lib/passport');
const clientHelpers = require('../lib/client-helpers'); const clientHelpers = require('../lib/client-helpers');
const gm = require('gm').subClass({
imageMagick: true
});
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const fsReadFile = bluebird.promisify(require('fs').readFile); const fsReadFile = bluebird.promisify(require('fs').readFile);
const path = require('path'); const path = require('path');
const files = require('../models/files');
const fileHelpers = require('../lib/file-helpers');
// FIXME - add authentication by sandboxToken // FIXME - add authentication by sandboxToken
async function placeholderImage(width, height) {
const magick = gm(width, height, '#707070');
const streamAsync = bluebird.promisify(magick.stream.bind(magick));
router.getAsync('/editor', passport.csrfProtection, async (req, res) => { 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');
const stream = await streamAsync('png');
return {
format: 'png',
stream
};
}
async function resizedImage(src, method, width, height) {
const filePath = path.join(__dirname, '..', src);
const magick = gm(filePath);
const streamAsync = bluebird.promisify(magick.stream.bind(magick));
const formatAsync = bluebird.promisify(magick.format.bind(magick));
const format = (await formatAsync()).toLowerCase();
if (method === 'resize') {
magick
.autoOrient()
.resize(width, height);
} else if (method === 'cover') {
magick
.autoOrient()
.resize(width, height + '^')
.gravity('Center')
.extent(width, height + '>');
} else {
throw new Error(`Method ${method} not supported`);
}
const stream = await streamAsync();
return {
format,
stream
};
}
function 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;
}
router.getAsync('/img/:type/:fileId', passport.loggedIn, async (req, res) => {
const method = req.query.method;
const params = req.query.params;
let [width, height] = params.split(',');
let image;
if (method === 'placeholder') {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, false);
image = await placeholderImage(width, height);
} else {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true);
image = await resizedImage(req.query.src, method, width, height);
}
res.set('Content-Type', 'image/' + image.format);
image.stream.pipe(res);
});
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', true);
router.getAsync('/upload/:type/:fileId', passport.loggedIn, async (req, res) => {
const entries = await files.list(req.context, req.params.type, req.params.fileId);
const filesOut = [];
for (const entry of entries) {
filesOut.push({
name: entry.originalname,
url: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}`,
size: entry.size,
thumbnailUrl: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}` // TODO - use smaller thumbnails
})
}
res.json({
files: filesOut
});
});
router.getAsync('/editor', passport.csrfProtection, passport.loggedIn, async (req, res) => {
const resourceType = req.query.type; const resourceType = req.query.type;
const resourceId = req.query.id; const resourceId = req.query.id;
@ -29,15 +152,6 @@ router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
} }
} }
/* ????
resource.editorName = resource.editorName ||  'mosaico';
resource.editorData = !resource.editorData ?
{
template: req.query.template || 'versafix-1'
} :
JSON.parse(resource.editorData);
*/
res.render('mosaico/root', { res.render('mosaico/root', {
layout: 'mosaico/layout', layout: 'mosaico/layout',
editorConfig: config.mosaico, editorConfig: config.mosaico,
@ -47,4 +161,5 @@ router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
}); });
}); });
module.exports = router; module.exports = router;

View file

@ -3,7 +3,7 @@
const passport = require('../lib/passport'); const passport = require('../lib/passport');
const _ = require('../lib/translate')._; const _ = require('../lib/translate')._;
const reports = require('../models/reports'); const reports = require('../models/reports');
const fileHelpers = require('../lib/file-helpers'); const reportHelpers = require('../lib/report-helpers');
const shares = require('../models/shares'); const shares = require('../models/shares');
const contextHelpers = require('../lib/context-helpers'); const contextHelpers = require('../lib/context-helpers');
@ -16,11 +16,11 @@ router.getAsync('/:id/download', passport.loggedIn, async (req, res) => {
if (report.state == reports.ReportState.FINISHED) { if (report.state == reports.ReportState.FINISHED) {
const headers = { const headers = {
'Content-Disposition': 'attachment;filename=' + fileHelpers.nameToFileName(report.name) + '.csv', 'Content-Disposition': 'attachment;filename=' + reportHelpers.nameToFileName(report.name) + '.csv',
'Content-Type': report.mime_type 'Content-Type': report.mime_type
}; };
res.sendFile(fileHelpers.getReportContentFile(report), {headers: headers}); res.sendFile(reportHelpers.getReportContentFile(report), {headers: headers});
} else { } else {
return res.status(404).send(_('Report not found')); return res.status(404).send(_('Report not found'));

View file

@ -2,13 +2,11 @@
const passport = require('../../lib/passport'); const passport = require('../../lib/passport');
const files = require('../../models/files'); const files = require('../../models/files');
const router = require('../../lib/router-async').create(); const router = require('../../lib/router-async').create();
const multer = require('../../lib/multer'); const fileHelpers = require('../../lib/file-helpers');
router.postAsync('/files-table/:type/:entityId', passport.loggedIn, async (req, res) => { 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(await files.listDTAjax(req.context, req.params.type, req.params.entityId, req.body));
return res.json(files);
}); });
router.getAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => { router.getAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => {
@ -17,22 +15,11 @@ router.getAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => {
return res.download(file.path, file.name); 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) => { router.deleteAsync('/files/:type/:fileId', passport.loggedIn, async (req, res) => {
await files.removeFile(req.context, req.params.type, req.params.fileId); await files.removeFile(req.context, req.params.type, req.params.fileId);
return res.json(); return res.json();
}); });
fileHelpers.installUploadHandler(router, '/files/:type/:entityId');
module.exports = router; module.exports = router;

View file

@ -4,7 +4,7 @@ const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._; const _ = require('../../lib/translate')._;
const reports = require('../../models/reports'); const reports = require('../../models/reports');
const reportProcessor = require('../../lib/report-processor'); const reportProcessor = require('../../lib/report-processor');
const fileHelpers = require('../../lib/file-helpers'); const reportHelpers = require('../../lib/report-helpers');
const shares = require('../../models/shares'); const shares = require('../../models/shares');
const contextHelpers = require('../../lib/context-helpers'); const contextHelpers = require('../../lib/context-helpers');
@ -62,14 +62,14 @@ router.getAsync('/report-content/:id', async (req, res) => {
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent'); await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent');
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id); const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id);
res.sendFile(fileHelpers.getReportContentFile(report)); res.sendFile(reportHelpers.getReportContentFile(report));
}); });
router.getAsync('/report-output/:id', async (req, res) => { router.getAsync('/report-output/:id', async (req, res) => {
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewOutput'); await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewOutput');
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id); const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id);
res.sendFile(fileHelpers.getReportOutputFile(report)); res.sendFile(reportHelpers.getReportOutputFile(report));
}); });

View file

@ -4,7 +4,7 @@
that can chroot. that can chroot.
*/ */
const fileHelpers = require('../lib/file-helpers'); const reportHelpers = require('../lib/report-helpers');
const fork = require('child_process').fork; const fork = require('child_process').fork;
const path = require('path'); const path = require('path');
const log = require('npmlog'); const log = require('npmlog');
@ -111,7 +111,7 @@ process.on('message', msg => {
if (type === 'start-report-processor-worker') { if (type === 'start-report-processor-worker') {
const ids = privilegeHelpers.getConfigROUidGid(); const ids = privilegeHelpers.getConfigROUidGid();
spawnProcess(msg.tid, path.join(__dirname, '..', 'workers', 'reports', 'report-processor.js'), [msg.data.id], fileHelpers.getReportContentFile(msg.data), fileHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..', 'workers', 'reports'), ids.uid, ids.gid); spawnProcess(msg.tid, path.join(__dirname, '..', 'workers', 'reports', 'report-processor.js'), [msg.data.id], reportHelpers.getReportContentFile(msg.data), reportHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..', 'workers', 'reports'), ids.uid, ids.gid);
} else if (type === 'stop-process') { } else if (type === 'stop-process') {
const child = processes[msg.tid]; const child = processes[msg.tid];

View file

@ -22,7 +22,8 @@ class ChangedError extends InteroperableError {
class NotFoundError extends InteroperableError { class NotFoundError extends InteroperableError {
constructor(msg, data) { constructor(msg, data) {
super('NotFoundError', msg, data); super('NotFoundError', msg || 'Not Found', data);
this.status = 404;
} }
} }
@ -82,7 +83,7 @@ class NamespaceNotFoundError extends InteroperableError {
class PermissionDeniedError extends InteroperableError { class PermissionDeniedError extends InteroperableError {
constructor(msg, data) { constructor(msg, data) {
super('PermissionDeniedError', msg, data); super('PermissionDeniedError', msg || 'Permission Denied', data);
this.status = 403; this.status = 403;
} }
} }