diff --git a/.gitignore b/.gitignore index 662e81ca..47825b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ public/grapejs/templates/* config/production.toml workers/reports/config/production.toml docker-compose.override.yml + +/files diff --git a/app.js b/app.js index ca1cc533..7e327fe4 100644 --- a/app.js +++ b/app.js @@ -30,6 +30,7 @@ const api = require('./routes/api'); const reports = require('./routes/reports'); const subscription = require('./routes/subscription'); const mosaico = require('./routes/mosaico'); +const files = require('./routes/files'); const namespacesRest = require('./routes/rest/namespaces'); const usersRest = require('./routes/rest/users'); @@ -46,6 +47,7 @@ const subscriptionsRest = require('./routes/rest/subscriptions'); const templatesRest = require('./routes/rest/templates'); const blacklistRest = require('./routes/rest/blacklist'); const editorsRest = require('./routes/rest/editors'); +const filesRest = require('./routes/rest/files'); const root = require('./routes/root'); @@ -55,15 +57,11 @@ const app = express(); function install404Fallback(url) { app.use(url, (req, res, next) => { - let err = new Error(_('Not Found')); - err.status = 404; - next(err); + next(new interoperableErrors.NotFoundError()); }); app.use(url + '/*', (req, res, next) => { - let err = new Error(_('Not Found')); - err.status = 404; - next(err); + next(new interoperableErrors.NotFoundError()); }); } @@ -254,17 +252,17 @@ app.use((req, res, next) => { // Regular endpoints useWith404Fallback('/subscription', subscription); +useWith404Fallback('/files', files); +useWith404Fallback('/mosaico', mosaico); if (config.reports && config.reports.enabled === true) { useWith404Fallback('/reports', reports); } -useWith404Fallback('/mosaico', mosaico); // API endpoints useWith404Fallback('/api', api); -/* ------------------------------------------------------------------- */ // REST endpoints app.use('/rest', namespacesRest); @@ -280,6 +278,7 @@ app.use('/rest', subscriptionsRest); app.use('/rest', templatesRest); app.use('/rest', blacklistRest); app.use('/rest', editorsRest); +app.use('/rest', filesRest); if (config.reports && config.reports.enabled === true) { app.use('/rest', reportTemplatesRest); diff --git a/client/src/lib/files.js b/client/src/lib/files.js index 24fb6948..226f0038 100644 --- a/client/src/lib/files.js +++ b/client/src/lib/files.js @@ -3,18 +3,19 @@ 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 {requiresAuthenticatedUser} from "./page"; +import {withErrorHandling} from "./error-handling"; +import {Table} from "./table"; import Dropzone from "react-dropzone"; -import {ModalDialog} from "./lib/modals"; -import {Icon} from "./lib/bootstrap-components"; +import {ModalDialog} from "./modals"; +import {Icon} from "./bootstrap-components"; import axios from './axios'; +import styles from "./styles.scss"; +import {withPageHelpers} from "./page"; @translate() -@withForm @withErrorHandling +@withPageHelpers @requiresAuthenticatedUser export default class Files extends Component { constructor(props) { @@ -26,16 +27,18 @@ export default class Files extends Component { }; const t = props.t; - - this.initForm(); } static propTypes = { title: PropTypes.string, entity: PropTypes.object, - entityTypeId: PropTypes.string + entityTypeId: PropTypes.string, + usePublicDownloadUrls: PropTypes.bool } + static defaultProps = { + usePublicDownloadUrls: true + } getFilesUploadedMessage(response){ const t = this.props.t; @@ -56,21 +59,21 @@ export default class Files extends Component { onDrop(files){ const t = this.props.t; 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(); 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 => { this.filesTable.refresh(); 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{ - 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(); try { - this.disableForm(); - this.setFormStatusMessage('info', t('Deleting file ...')); + this.setFlashMessage('info', t('Deleting file ...')); await axios.delete(`/rest/files/${this.props.entityTypeId}/${fileToDeleteId}`); this.filesTable.refresh(); - this.setFormStatusMessage('info', t('File deleted')); - this.enableForm(); + this.setFlashMessage('info', t('File deleted')); } catch (err) { this.filesTable.refresh(); - this.setFormStatusMessage('danger', t('Delete file failed: ') + err.message); - this.enableForm(); + this.setFlashMessage('danger', t('Delete file failed: ') + err.message); } } @@ -106,14 +106,21 @@ export default class Files extends Component { const columns = [ { data: 1, title: "Name" }, - { data: 2, title: "Size" }, + { data: 3, title: "Size" }, { 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 = [ { label: , - href: `/rest/files/${this.props.entityTypeId}/${data[0]}` + href: downloadUrl }, { label: , @@ -138,10 +145,10 @@ export default class Files extends Component { ]}> {t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})} - + {state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')} - this.filesTable = node} dataUrl={`/rest/template-files-table/${this.props.entity.id}`} columns={columns} /> +
this.filesTable = node} dataUrl={`/rest/files-table/${this.props.entityTypeId}/${this.props.entity.id}`} columns={columns} /> ); } diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss index bd746f95..910178ef 100644 --- a/client/src/lib/styles.scss +++ b/client/src/lib/styles.scss @@ -86,4 +86,17 @@ .secondaryNav > li > a { padding: 3px 10px; -} \ No newline at end of file +} + +.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; +} diff --git a/client/src/mosaico/root.js b/client/src/mosaico/root.js index 8b48d530..0e4f0bac 100644 --- a/client/src/mosaico/root.js +++ b/client/src/mosaico/root.js @@ -8,20 +8,41 @@ import { } from 'react-i18next'; import i18n from '../lib/i18n'; import PropTypes from "prop-types"; +import styles from "./styles.scss"; + +const ResourceType = { + TEMPLATE: 'template', + CAMPAIGN: 'campaign' +} @translate() class MosaicoEditor extends Component { constructor(props) { super(props); - + this.viewModel = null; + this.state = { + entityTypeId: ResourceType.TEMPLATE, // FIXME + entityId: 13 // FIXME + } } static propTypes = { //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() { - const basePath = '/public/mosaico'; + const publicPath = '/public/mosaico'; if (!Mosaico.isCompatible()) { alert('Update your browser!'); @@ -30,35 +51,57 @@ class MosaicoEditor extends Component { 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 => { // 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 = { - imgProcessorBackend: basePath+'/img/', - emailProcessorBackend: basePath+'/dl/', + imgProcessorBackend: `/mosaico/img/${this.state.entityTypeId}/${this.state.entityId}`, + emailProcessorBackend: '/mosaico/dl/', titleToken: "MOSAICO Responsive Email Designer", fileuploadConfig: { - url: basePath+'/upload/' + url: `/mosaico/upload/${this.state.entityTypeId}/${this.state.entityId}` }, strings: window.mosaicoLanguageStrings }; const metadata = 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() { - } render() { + const t = this.props.t; + return ( -
+
+ + {t('CLOSE')} +
); } diff --git a/client/src/mosaico/styles.scss b/client/src/mosaico/styles.scss new file mode 100644 index 00000000..aaf8417c --- /dev/null +++ b/client/src/mosaico/styles.scss @@ -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; +} \ No newline at end of file diff --git a/client/src/templates/List.js b/client/src/templates/List.js index 5ebbbece..da70dc36 100644 --- a/client/src/templates/List.js +++ b/client/src/templates/List.js @@ -64,6 +64,13 @@ export default class List extends Component { }); } + if (perms.includes('manageFiles')) { + actions.push({ + label: , + link: `/templates/${data[0]}/files` + }); + } + if (perms.includes('share')) { actions.push({ label: , diff --git a/client/src/templates/root.js b/client/src/templates/root.js index 2a45aa09..7fac049c 100644 --- a/client/src/templates/root.js +++ b/client/src/templates/root.js @@ -9,6 +9,7 @@ import { Section } from '../lib/page'; import TemplatesCUD from './CUD'; import TemplatesList from './List'; import Share from '../shares/Share'; +import Files from "../lib/files"; function getMenus(t) { @@ -31,6 +32,12 @@ function getMenus(t) { visible: resolved => resolved.template.permissions.includes('edit'), panelRender: props => }, + files: { + title: t('Files'), + link: params => `/templates/${params.templateId}/files`, + visible: resolved => resolved.template.permissions.includes('edit'), + panelRender: props => + }, share: { title: t('Share'), link: params => `/templates/${params.templateId}/share`, diff --git a/client/webpack.config.js b/client/webpack.config.js index af5ab6ef..5c35f6fa 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -15,7 +15,7 @@ module.exports = { rules: [ { 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' ] }, { diff --git a/config/default.toml b/config/default.toml index 7ff74233..b3029b1d 100644 --- a/config/default.toml +++ b/config/default.toml @@ -208,8 +208,8 @@ permissions=["view", "edit", "delete", "share", "createNamespace", "createList", [roles.namespace.master.children] list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] customForm=["view", "edit", "delete", "share"] -campaign=["view", "edit", "delete", "share"] -template=["view", "edit", "delete", "share"] +campaign=["view", "edit", "delete", "share", "manageFiles"] +template=["view", "edit", "delete", "share", "manageFiles"] report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] reportTemplate=["view", "edit", "delete", "share", "execute"] namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"] @@ -227,12 +227,12 @@ permissions=["view", "edit", "delete", "share"] [roles.campaign.master] name="Master" description="All permissions" -permissions=["view", "edit", "delete", "share"] +permissions=["view", "edit", "delete", "share", "manageFiles"] [roles.template.master] name="Master" description="All permissions" -permissions=["view", "edit", "delete", "share"] +permissions=["view", "edit", "delete", "share", "manageFiles"] [roles.report.master] name="Master" diff --git a/lib/file-helpers.js b/lib/file-helpers.js index deee31e1..2eb89331 100644 --- a/lib/file-helpers.js +++ b/lib/file-helpers.js @@ -1,32 +1,21 @@ 'use strict'; +const passport = require('./passport'); +const files = require('../models/files'); + const path = require('path'); +const uploadedFilesDir = path.join(files.filesDir, 'uploaded'); -function nameToFileName(name) { - return name. - trim(). - toLowerCase(). - replace(/[ .+/]/g, '-'). - replace(/[^a-z0-9\-_]/gi, ''). - replace(/--*/g, '-'); +const multer = require('multer')({ + dest: uploadedFilesDir +}); + +function installUploadHandler(router, url, dontReplace = false) { + router.postAsync(url, passport.loggedIn, multer.array('files[]'), async (req, res) => { + 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 = { - getReportContentFile, - getReportOutputFile, - nameToFileName -}; + installUploadHandler +}; \ No newline at end of file diff --git a/lib/report-helpers.js b/lib/report-helpers.js new file mode 100644 index 00000000..deee31e1 --- /dev/null +++ b/lib/report-helpers.js @@ -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 +}; diff --git a/models/files.js b/models/files.js index 01c77f85..0ec709a9 100644 --- a/models/files.js +++ b/models/files.js @@ -6,37 +6,49 @@ const dtHelpers = require('../lib/dt-helpers'); const shares = require('./shares'); const fs = require('fs-extra-promise'); const path = require('path'); +const interoperableErrors = require('../shared/interoperable-errors'); 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); + return path.join(path.join(filesDir, type, entityId.toString()), filename); } function getFilesTable(type) { return 'files_' + type; } -async function listFilesDTAjax(context, type, entityId, params) { +async function listDTAjax(context, type, entityId, params) { enforce(permittedTypes.has(type)); - await shares.enforceEntityPermission(context, type, entityId, 'edit'); + await shares.enforceEntityPermission(context, type, entityId, 'manageFiles'); return await dtHelpers.ajaxList( params, 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) { 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'); + const file = await tx(getFilesTable(type)).where('id', id).first(); + await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles'); return file; }); + if (!file) { + throw new interoperableErrors.NotFoundError(); + } + return { mimetype: file.mimetype, 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)); 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(); + const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first(); return file; }); + if (!file) { + throw new interoperableErrors.NotFoundError(); + } + return { mimetype: file.mimetype, 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)); 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(); + const fileEntities = []; + const filesToMove = []; + const ignoredFiles = []; + const removedFiles = []; + const filesRet = []; - // 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); + await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'manageFiles'); - 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 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 originalNameArray = Array.from(originalNameSet); + const originalNameSet = new Set(); - 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']); + // Create entities for files + for (const file of files) { + 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 + ignoredFiles.push(file); + + } else { + filesToMove.push(file); + + fileEntities.push({ + entity: entityId, + filename: file.filename, + originalname: originalName, + mimetype: file.mimetype, + encoding: file.encoding, + size: file.size + }); + + filesRet.push({ + 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); await knex(getFilesTable(type)).where('entity', entityId).whereIn('originalname', originalNameArray).del(); - if(fileEntities){ + + 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); + for (const file of filesToMove) { + const filePath = getFilePath(type, 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); + for (const file of removedFiles) { const filePath = getFilePath(type, entityId, file.filename); await fs.remove(filePath); } // Remove ignored files from upload directory - for(const file of ignoredFiles){ + for (const file of ignoredFiles) { await fs.remove(file.path); } return { uploaded: files.length, - added: fileEntities.length - removedNameSet.size, + added: fileEntities.length - removedFiles.length, replaced: removedFiles.length, - ignored: ignoredFiles.length + ignored: ignoredFiles.length, + files: filesRet }; } 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'); + const file = await tx(getFilesTable(type)).where('id', id).select('entity', 'filename').first(); + await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles'); await tx(getFilesTable(type)).where('id', id).del(); return {filename: file.filename, entity: file.entity}; }); @@ -147,9 +192,11 @@ async function removeFile(context, type, id) { } module.exports = { - listFilesDTAjax, + listDTAjax, + list, getFileById, - getFileByName, + getFileByFilename, createFiles, - removeFile + removeFile, + filesDir }; \ No newline at end of file diff --git a/models/shares.js b/models/shares.js index dcc1b3b3..0aacf5a2 100644 --- a/models/shares.js +++ b/models/shares.js @@ -414,6 +414,10 @@ async function removeDefaultShares(tx, user) { } function checkGlobalPermission(context, requiredOperations) { + if (!context.user) { + return false; + } + if (context.user.admin) { // This handles the getAdminContext() case return true; } @@ -443,6 +447,10 @@ function enforceGlobalPermission(context, requiredOperations) { } async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) { + if (!context.user) { + return false; + } + 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. @@ -530,12 +538,20 @@ async function enforceTypePermissionTx(tx, context, entityTypeId, requiredOperat } function getGlobalPermissions(context) { + if (!context.user) { + return []; + } + enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); return (config.roles.global[context.user.role] || {}).permissions || []; } 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'); const entityType = permissions.getEntityType(entityTypeId); diff --git a/package-lock.json b/package-lock.json index 5e368930..e25d7eb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2376,6 +2376,34 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index ae2d463d..1a415f27 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "faker": "^4.1.0", "feedparser": "^2.2.7", "fs-extra": "^4.0.2", + "fs-extra-promise": "^1.0.1", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.3.0", "gm": "^1.23.1", diff --git a/routes/files.js b/routes/files.js new file mode 100644 index 00000000..0b890e91 --- /dev/null +++ b/routes/files.js @@ -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; diff --git a/routes/mosaico.js b/routes/mosaico.js index 0ea91ec8..4af6516d 100644 --- a/routes/mosaico.js +++ b/routes/mosaico.js @@ -4,16 +4,139 @@ const config = require('config'); const router = require('../lib/router-async').create(); const passport = require('../lib/passport'); const clientHelpers = require('../lib/client-helpers'); +const gm = require('gm').subClass({ + imageMagick: true +}); const bluebird = require('bluebird'); const fsReadFile = bluebird.promisify(require('fs').readFile); const path = require('path'); +const files = require('../models/files'); +const fileHelpers = require('../lib/file-helpers'); + // 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 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', { layout: 'mosaico/layout', editorConfig: config.mosaico, @@ -47,4 +161,5 @@ router.getAsync('/editor', passport.csrfProtection, async (req, res) => { }); }); + module.exports = router; diff --git a/routes/reports.js b/routes/reports.js index 53d91e0d..95cb97e1 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -3,7 +3,7 @@ const passport = require('../lib/passport'); const _ = require('../lib/translate')._; const reports = require('../models/reports'); -const fileHelpers = require('../lib/file-helpers'); +const reportHelpers = require('../lib/report-helpers'); const shares = require('../models/shares'); 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) { 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 }; - res.sendFile(fileHelpers.getReportContentFile(report), {headers: headers}); + res.sendFile(reportHelpers.getReportContentFile(report), {headers: headers}); } else { return res.status(404).send(_('Report not found')); diff --git a/routes/rest/files.js b/routes/rest/files.js index 68a47af4..35919eaa 100644 --- a/routes/rest/files.js +++ b/routes/rest/files.js @@ -2,13 +2,11 @@ const passport = require('../../lib/passport'); const files = require('../../models/files'); - 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) => { - const files = await files.listFilesDTAjax(req.context, req.params.type, req.params.entityId, req.body); - return res.json(files); + return res.json(await files.listDTAjax(req.context, req.params.type, req.params.entityId, req.body)); }); 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); }); -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(); }); +fileHelpers.installUploadHandler(router, '/files/:type/:entityId'); + module.exports = router; \ No newline at end of file diff --git a/routes/rest/reports.js b/routes/rest/reports.js index 405d5c58..bdd39ec3 100644 --- a/routes/rest/reports.js +++ b/routes/rest/reports.js @@ -4,7 +4,7 @@ const passport = require('../../lib/passport'); const _ = require('../../lib/translate')._; const reports = require('../../models/reports'); const reportProcessor = require('../../lib/report-processor'); -const fileHelpers = require('../../lib/file-helpers'); +const reportHelpers = require('../../lib/report-helpers'); const shares = require('../../models/shares'); 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'); 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) => { await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewOutput'); const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id); - res.sendFile(fileHelpers.getReportOutputFile(report)); + res.sendFile(reportHelpers.getReportOutputFile(report)); }); diff --git a/services/executor.js b/services/executor.js index 0632f887..9e9dabc2 100644 --- a/services/executor.js +++ b/services/executor.js @@ -4,7 +4,7 @@ that can chroot. */ -const fileHelpers = require('../lib/file-helpers'); +const reportHelpers = require('../lib/report-helpers'); const fork = require('child_process').fork; const path = require('path'); const log = require('npmlog'); @@ -111,7 +111,7 @@ process.on('message', msg => { if (type === 'start-report-processor-worker') { 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') { const child = processes[msg.tid]; diff --git a/shared/interoperable-errors.js b/shared/interoperable-errors.js index c6626eae..34a37497 100644 --- a/shared/interoperable-errors.js +++ b/shared/interoperable-errors.js @@ -22,7 +22,8 @@ class ChangedError extends InteroperableError { class NotFoundError extends InteroperableError { 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 { constructor(msg, data) { - super('PermissionDeniedError', msg, data); + super('PermissionDeniedError', msg || 'Permission Denied', data); this.status = 403; } }