From 43f0b189d9630115695183d1748d68a6c378c33f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 18 Feb 2019 13:50:50 +0100 Subject: [PATCH] close #1671: implement mobile UI for quarantine this patch implements a UI for the Quarantine, designed to be looked at on mobile phones for this we use Framework7 instead of extjs, since it has much more features and looks more native on phones Signed-off-by: Dominik Csapak --- Makefile | 9 +- css/ext6-pmg-mobile.css | 46 +++++ debian/control | 12 +- js/Makefile | 17 +- js/mobile/app.js | 80 +++++++++ js/mobile/component.js | 26 +++ js/mobile/loginscreen.js | 114 +++++++++++++ js/mobile/mailview.js | 62 +++++++ js/mobile/quarantineview.js | 329 ++++++++++++++++++++++++++++++++++++ js/mobile/utils.js | 163 ++++++++++++++++++ pmg-mobile-index.html.tt | 36 ++++ 11 files changed, 888 insertions(+), 6 deletions(-) create mode 100644 css/ext6-pmg-mobile.css create mode 100644 js/mobile/app.js create mode 100644 js/mobile/component.js create mode 100644 js/mobile/loginscreen.js create mode 100644 js/mobile/mailview.js create mode 100644 js/mobile/quarantineview.js create mode 100644 js/mobile/utils.js create mode 100644 pmg-mobile-index.html.tt diff --git a/Makefile b/Makefile index 045f273..52a16ff 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ IMAGES= \ logo-128.png \ proxmox_logo.png -CSSFILES = ext6-pmg.css +CSSFILES = ext6-pmg.css ext6-pmg-mobile.css all: @@ -31,12 +31,17 @@ deb ${DEB}: js/pmgmanagerlib.js: make -C js pmgmanagerlib.js -install: pmg-index.html.tt js/pmgmanagerlib.js +js/pmgmanagerlib-mobile.js: + make -C js pmgmanagerlib-mobile.js + +install: pmg-index.html.tt js/pmgmanagerlib.js js/pmgmanagerlib-mobile.js install -d -m 755 ${WWWCSSDIR} install -d -m 755 ${WWWIMAGESDIR} install -d -m 755 ${WWWJSDIR} install -m 0644 pmg-index.html.tt ${WWWBASEDIR} + install -m 0644 pmg-mobile-index.html.tt ${WWWBASEDIR} install -m 0644 js/pmgmanagerlib.js ${WWWJSDIR} + install -m 0644 js/pmgmanagerlib-mobile.js ${WWWJSDIR} for i in ${IMAGES}; do install -m 0644 images/$$i ${WWWIMAGESDIR}; done for i in ${CSSFILES}; do install -m 0644 css/$$i ${WWWCSSDIR}; done diff --git a/css/ext6-pmg-mobile.css b/css/ext6-pmg-mobile.css new file mode 100644 index 0000000..adbd88b --- /dev/null +++ b/css/ext6-pmg-mobile.css @@ -0,0 +1,46 @@ +.item-title .item-header { + white-space: inherit; + overflow: hidden; + text-overflow: ellipsis; +} + +.empty { + padding: 1em; + color: var(--f7-label-text-color); +} + +img.logo { + padding: 0 10px; + vertical-align: middle; + height: 64px; +} + +img.logo-navbar { + padding: 0 10px; + height: 32; +} + +.settings-form { + position: absolute; + bottom: calc(var(--f7-fab-margin) + var(--f7-safe-area-bottom)); + right: calc(var(--f7-fab-margin) + var(--f7-safe-area-right)); + z-index: 1500; + width: 200px; + background-color: var(--f7-list-bg-color); +} + +.button.subscription i.icon { + display: inline; +} + +@media only screen and (max-width: 500px) { + .login-screen-title { + font-size: 6vw; + } +} + +@media only screen and (min-width: 500px) { + .login-screen-title { + font-size: 32px; + } +} diff --git a/debian/control b/debian/control index a4e93ed..74a3d84 100644 --- a/debian/control +++ b/debian/control @@ -2,12 +2,20 @@ Source: pmg-gui Section: perl Priority: optional Maintainer: Proxmox Support Team -Build-Depends: debhelper (>= 9), perl (>= 5.10.0-19), libtemplate-perl +Build-Depends: debhelper (>= 9), + libtemplate-perl, + perl (>= 5.10.0-19), Standards-Version: 3.9.5 Homepage: http://www.proxmox.com Package: pmg-gui Architecture: all -Depends: ${perl:Depends}, libtemplate-perl, libjs-extjs (>= 6.0.1), fonts-font-awesome, pmg-i18n, proxmox-widget-toolkit +Depends: fonts-font-awesome, + libjs-extjs (>= 6.0.1), + libjs-framework7, + libtemplate-perl, + pmg-i18n, + proxmox-widget-toolkit, + ${perl:Depends}, Description: Proxmox Mail Gateway GUI Graphical user interface for Proxmox Mail Gateway. diff --git a/js/Makefile b/js/Makefile index 882cbc4..6f4d449 100644 --- a/js/Makefile +++ b/js/Makefile @@ -84,6 +84,15 @@ JSSRC= \ SpamContextMenu.js \ Application.js +# caution: order is important +MOBILESRC= \ + mobile/component.js \ + mobile/loginscreen.js \ + mobile/mailview.js \ + mobile/quarantineview.js \ + mobile/utils.js \ + mobile/app.js \ + OnlineHelpInfo.js: /usr/bin/asciidoc-pmg /usr/bin/asciidoc-pmg scan-extjs ${JSSRC} >$@.tmp mv $@.tmp $@ @@ -95,12 +104,16 @@ pmgmanagerlib.js: OnlineHelpInfo.js ${JSSRC} cat OnlineHelpInfo.js ${JSSRC} >$@.tmp mv $@.tmp $@ -all: pmgmanagerlib.js +pmgmanagerlib-mobile.js: ${MOBILESRC} + cat ${MOBILESRC} >$@.tmp + mv $@.tmp $@ + +all: pmgmanagerlib.js pmgmanagerlib-mobile.js .PHONY: clean clean: find . -name '*~' -exec rm {} ';' - rm -rf pmgmanagerlib.js OnlineHelpInfo.js + rm -rf pmgmanagerlib.js pmgmanagerlib-mobile.js OnlineHelpInfo.js diff --git a/js/mobile/app.js b/js/mobile/app.js new file mode 100644 index 0000000..68fb9e5 --- /dev/null +++ b/js/mobile/app.js @@ -0,0 +1,80 @@ +var $$ = Dom7; +var app = new Framework7({ + root: '#app', + init: false, + name: 'Proxmox Mail Gateway', + routes: [ + { + path: '/:path/:subpath?', + async: function(routeTo, routeFrom, resolve, reject) { + if (routeTo.params.path === 'mail') { + let mail = new MailView(); + resolve({ + template: mail.getTpl() + },{ + context: { + mailid: routeTo.params.subpath + } + }); + } else { + reject(); + } + } + }, + { + path: '/mail/:mailid/:action', + async: function(routeTo, routeFrom, resolve, reject) { + let action = routeTo.params.action; + let mailid = routeTo.params.mailid; + let confirmText = gettext('') + app.dialog.confirm( + `${action}: ${mailid}`, + gettext('Confirm'), + () => { + let loader = app.dialog.preloader(); + app.request({ + method: 'POST', + url: '/api2/json/quarantine/content/', + data: { + action: action, + id: mailid + }, + headers: { + CSRFPreventionToken: Proxmox.CSRFPreventionToken + }, + success: (data, status, xhr) => { + loader.close(); + app.dialog.alert( + `Action '${action}' successful`, + gettext("Info"), + () => { + if (action === 'delete' || + action === 'deliver') + { + // refresh the main list when a mail + // got deleted or delivered + app.ptr.refresh(); + } + } + ); + reject(); + }, + error: xhr => { + loader.close(); + PMG.Utils.showError(xhr); + reject(); + } + }) + }, + () => { + reject(); + } + ); + } + } + ] +}); + +let quarlist = new QuarantineView(); + +app.init(); diff --git a/js/mobile/component.js b/js/mobile/component.js new file mode 100644 index 0000000..72312a7 --- /dev/null +++ b/js/mobile/component.js @@ -0,0 +1,26 @@ +class Component { + constructor(config = {}) { + var me = this; + me.config = config; + me.data = config.data || {}; + me.tpl = me.config.tpl || '
'; + } + getTpl() { + var me = this; + if (!me._compiledtpl) { + me._compiledtpl = Template7.compile(me.tpl); + } + return me._compiledtpl; + } + getEl(data) { + var me = this; + if (data === undefined && me._el) { + return me._el; + } else if (data !== undefined) { + me.data =data; + } + me._el = Dom7(me.getTpl()(me.data)); + return me._el; + } +} + diff --git a/js/mobile/loginscreen.js b/js/mobile/loginscreen.js new file mode 100644 index 0000000..36dc994 --- /dev/null +++ b/js/mobile/loginscreen.js @@ -0,0 +1,114 @@ +class LoginScreen extends Component { + constructor(config = {}) { + config.tpl = ` + + `; + super(config); + var me = this; + me._screen = app.loginScreen.create({ + content: me.getEl(), + }); + + let login = config.loginInfo; + me._form = me.getEl().find('form'); + + if (login.username && login.ticket) { + app.form.fillFromData(me._form, { + username: login.username, + password: login.ticket, + }); + me._autoLogin = true; + } else if (PMG.Utils.authOK()) { + app.form.fillFromData(me._form, { + username: Proxmox.UserName, + password: decodeURIComponent(PMG.Utils.getCookie('PMGAuthCookie')), + }); + me._autoLogin = true; + } + } + open(onLogin) { + var me = this; + return new Promise(function(resolve, reject) { + me._form.on('formajax:beforesend', (el, data, xhr) => { + me.loader = app.dialog.preloader(); + }); + + me._form.on('formajax:success', (el, data, xhr) => { + let json; + try { + json = JSON.parse(xhr.responseText); + } catch (err) { + xhr.error = err; + PMG.Utils.showError(xhr); + return; + } + + resolve(json); + }); + + me._form.on('formajax:error', (el, data, xhr) => { + me.loader.close(); + PMG.Utils.showError(xhr); + }); + + if (me._autoLogin) { + delete me._autoLogin; + me._screen.on('open', () => { + me._form.trigger('submit'); + }) + } + + me._screen.open(); + }); + } + close() { + var me = this; + if (me.loader) { + me.loader.close(); + } + me._screen.close(false); + } +} + diff --git a/js/mobile/mailview.js b/js/mobile/mailview.js new file mode 100644 index 0000000..c89d134 --- /dev/null +++ b/js/mobile/mailview.js @@ -0,0 +1,62 @@ +class MailView extends Component { + constructor(config = {}) { + config.tpl = ` + + `; + super(config); + } +} + diff --git a/js/mobile/quarantineview.js b/js/mobile/quarantineview.js new file mode 100644 index 0000000..c2e7e94 --- /dev/null +++ b/js/mobile/quarantineview.js @@ -0,0 +1,329 @@ +class QuarantineView extends Component { + constructor(config = {}) { + config.tpl = config.tpl || ` +
+
+ +
+
{{gettext "Range"}}
+
+
    +
  • +
    +
    {{gettext "From"}}
    +
    + +
    +
    +
  • +
  • +
    +
    {{gettext "To"}}
    +
    + +
    +
    +
  • +
+ {{gettext "OK"}} +
+
+ + +
+
+
+
+
+
+
+
+
`; + config.itemTemplate = config.itemTemplate || ` +
  • + + + +
  • `; + config.dividerTemplate = config.dividerTemplate || + '
  • {{group}}
  • '; + super(config); + + var me = this; + + me._compiledItemTemplate = Template7.compile(me.config.itemTemplate); + me._compiledDividerTemplate = Template7.compile(me.config.dividerTemplate); + me.skelTpl = ` +
  • + +
    +
    +
    _______________________
    + ____ ______ __ _______ ____ _______ _______ ___ +
    +
    Score: 15
    +
    +
    +
  • `; + me.skelDividerTpl = '
  • ____-__-__
  • '; + me.setEndtime(new Date()); + let startdate = new Date(); + startdate.setDate(startdate.getDate() - 7); + me.setStarttime(startdate); + + // add to dom + $$(me.config.target || '#app').append(me.getEl()); + + $$(document).on('page:init', '.page[data-name=quarantine-list]', (e, page) => { + me.vList = app.virtualList.create({ + el: '.virtual-list', + items: [], + renderItem: function(item) { + return me._renderItem(item); + }, + emptyTemplate: '
    No data in database
    ' + }); + + // setup pull to refresh + $$('.ptr-content').on('ptr:refresh', (e) => { + me.setItems([ + { skel: true, divider: true }, + { skel: true }, + { skel: true }, + { skel: true }, + { skel: true, divider: true }, + { skel: true }, + { skel: true }, + { skel: true }, + { skel: true }, + { skel: true }, + { skel: true }, + { skel: true }, + ]); + me.load().then(data => { + me.setItems(data, { + sorter: { + property: 'time', + numeric: true, + direction: 'DESC' + }, + grouperFn: (val) => PMG.Utils.unixToIso(val['time']) + }); + }).catch(PMG.Utils.showError).then(() => { + e.detail(); + }); + }); + + // process query parameters + let { mail, action, date, username, ticket } = PMG.Utils.extractParams(); + if (date) { + me.setStarttime(date); + } + + // setup range form + $$('input[name=from]').val(PMG.Utils.unixToIso(me.starttime)); + $$('input[name=to]').val(PMG.Utils.unixToIso(me.endtime)); + + $$('.fab').on('fab:close', () => { + let fromChanged = me.setStarttime($$('input[name=from]').val()); + let toChanged = me.setEndtime($$('input[name=to]').val()); + if (fromChanged || toChanged) { + app.ptr.refresh(); + } + }); + + // check login + + let loginInfo = { username, ticket }; + let showPopup = (username && ticket) || !PMG.Utils.authOK(); + me._loginScreen = new LoginScreen({ loginInfo }); + + me._loginScreen.open().then(data => { + me._loginScreen.close(); + PMG.Utils.setLoginInfo(data); + return PMG.Utils.getSubscriptionInfo(); + }).then(data => { + return PMG.Utils.checkSubscription(data, showPopup); + }).then(data => { + app.ptr.refresh(); + if (mail) { + let url = "/mail/" + mail + "/" + (action || ""); + me._view.router.navigate(url); + } + }).catch(PMG.Utils.showError); + }); + + me._view = app.views.create('.view-quarantine', { + main: me.config.mainView !== undefined ? me.config.mainView : true, + url: '/', + pushState: true, + pushStateAnimateOnLoad: true + }); + } + setStarttime(starttime) { + var me = this; + let date = starttime; + if (!(starttime instanceof Date)) { + // we assume an ISO string + if (starttime == '') { + return; + } + date = new Date(PMG.Utils.isoToUnix(starttime)*1000); + } + // starttime is at beginning of date + date.setHours(0,0,0,0); + let result = Math.round(date.getTime()/1000); + if (result !== me.starttime) { + me.starttime = result; + return true; + } + return false + } + setEndtime(endtime) { + var me = this; + let date = endtime; + if (!(endtime instanceof Date)) { + if (endtime == '') { + return; + } + // we assume an ISO string + date = new Date(PMG.Utils.isoToUnix(endtime)*1000); + } + // endtime is at the end of the day + date.setHours(23, 59, 59); + let result = Math.round(date.getTime()/1000); + if (result !== me.endtime) { + me.endtime = result; + return true; + } + return false; + } + _renderItem(item) { + var me = this; + + if(typeof item === 'object') { + if (item.skel) { + return item.divider? me.skelDividerTpl : me.skelTpl; + } else if (item.divider) { + return me._compiledDividerTemplate(item); + } else { + return me._compiledItemTemplate(item); + } + } + + return item.toString(); + } + setItems(items, options) { + var me = this; + if (options && options.sorter) { + if (options.sorter.sorterFn) { + items.sort(options.sorter.sorterFn); + } else { + let prop = options.sorter.property; + let numeric = options.sorter.numeric; + let dir = options.sorter.direction === "ASC" ? 1 : -1; + items.sort((a,b) => { + let result; + + if (numeric) { + result = a[prop] - b[prop]; + } else { + result = a[prop] === b[prop] ? 0 : (a[prop] < b[prop] ? 1 : -1); + } + + return result * dir; + }); + } + } + me.vList.replaceAllItems(items); + if (options && options.grouperFn) { + let lastgroup; + let offset = 0; + for (let i = 0; i+offset < items.length; i++) { + let item = items[i+offset]; + let curgroup = options.grouperFn(item); + if (curgroup != lastgroup) { + me.vList.insertItemBefore(i+(offset++), { + divider: true, + group: curgroup + }); + lastgroup = curgroup; + } + } + } + } + load() { + var me = this; + return new Promise(function(resolve, reject) { + app.request({ + url: '/api2/json/quarantine/spam', + data: { + starttime: me.starttime, + endtime: me.endtime + }, + dataType: 'json', + success: (response, status, xhr) => { + resolve(response.data); + }, + error: xhr => { + reject(xhr); + } + }); + }); + } +} + diff --git a/js/mobile/utils.js b/js/mobile/utils.js new file mode 100644 index 0000000..b0e082b --- /dev/null +++ b/js/mobile/utils.js @@ -0,0 +1,163 @@ +Template7.registerHelper('gettext', function(value) { + return gettext(value); +}); + +var PMG = { + Utils: { + getCookie(name) { + let cookies = document.cookie.split(/;\s*/); + for (let i = 0; i < cookies.length; i++) { + let cookie = cookies[i].split('='); + if (cookie[0] === name && cookie.length > 1) { + return cookie[1]; + } + } + return undefined; + }, + setCookie(name, value, expires) { + value = encodeURIComponent(value); + let cookie = `${name}=${value}`; + if (expires) { + cookie += `; expires=${expires}`; + } + document.cookie = cookie; + }, + deleteCookie(name) { + PMG.Utils.setCookie(name, "", "Thu, 01 Jan 1970 00:00:00 UTC"); + }, + authOK(options) { + var authCookie = PMG.Utils.getCookie('PMGAuthCookie') || ""; + return (authCookie.substr(0,7) === 'PMGQUAR' && Proxmox.UserName !== ''); + }, + isoToUnix(iso) { + let fields = iso.split('-').map((field) => parseInt(field, 10)); + // monthIndex starts at 0 + let date = new Date(fields[0],fields[1]-1, fields[2]); + return Math.round(date.getTime()/1000); + }, + unixToIso(unix) { + let date = new Date(unix*1000); + let year = date.getFullYear().toString(); + let month = (date.getMonth()+1).toString().padStart(2, "0"); + let day = date.getDate().toString().padStart(2, "0"); + return `${year}-${month}-${day}`; + }, + showError(xhr) { + let statusText = "", errorText = ""; + if (xhr instanceof Error) { + statusText = gettext("Error"); + errorText = xhr.message; + } else if (xhr.error instanceof Error) { + statusText = gettext("Error"); + errorText = xhr.error.message; + } else { + statusText = xhr.status.toString() + ' ' + xhr.statusText; + try { + let errorObj = JSON.parse(xhr.responseText); + if (errorObj.errors) { + let errors = Object.keys(errorObj.errors).map((key) => key + ": " + errorObj.errors[key]); + errorText = errors.join('
    '); + } + } catch (e) { + statusText = gettext("Error"); + errorText = e.message; + } + } + app.toast.show({ + text: `Error:
    + ${statusText}
    + ${errorText} + `, + closeButton: true, + destroyOnClose: true + }); + }, + extractParams() { + let queryObj = app.utils.parseUrlQuery(location.search); + let mail, action, date, username, ticket; + if (queryObj.ticket) { + let tocheck = decodeURIComponent(queryObj.ticket); + let match = tocheck.match(/^PMGQUAR:([^\s\:]+):/); + if (match) { + ticket = tocheck; + username = match[1]; + } + delete queryObj.ticket; + } + + if (queryObj.date) { + date =queryObj.date; + delete queryObj.date; + } + + if (queryObj.cselect) { + mail = queryObj.cselect; + action = queryObj.action; + delete queryObj.cselect; + delete queryObj.action; + } + + if (mail || action || date || ticket) { + let queryString = app.utils.serializeObject(queryObj); + window.history.replaceState( + window.history.state, + document.title, + location.pathname + (queryString? "?" + queryString : '') + ); + } + + return { mail, action, date, username, ticket }; + }, + setLoginInfo(result) { + PMG.Utils.setCookie('PMGAuthCookie', result.data.ticket); + Proxmox.CSRFPreventionToken = result.data.CSRFPreventionToken; + }, + getSubscriptionInfo() { + return new Promise(function(resolve, reject) { + app.request({ + url: '/api2/json/nodes/localhost/subscription', + dataType: 'json', + success: (result, status, xhr) => { + resolve(result.data); + }, + error: (xhr, status) => { + reject(xhr); + } + }); + }); + }, + checkSubscription(data, showPopup) { + return new Promise(function(resolve, reject) { + if (data.status !== 'Active') { + let url = data.url || 'https://wwww.proxmox.com'; + let err = `You do not have a valid subscription for this server. + Please visit + www.proxmox.com + to get a list of available options.`; + app.toolbar.show('.toolbar.subscription'); + $$('.button.subscription').on('click', () => { + app.dialog.alert( + err, + gettext("No valid subscription"), + ); + }); + if (showPopup) { + app.dialog.alert( + err, + gettext("No valid subscription"), + () => { + resolve(data); + } + ); + } else { + resolve(); + } + } else { + app.toolbar.hide('.toolbar.subscription'); + resolve(); + } + }); + } + } +}; + diff --git a/pmg-mobile-index.html.tt b/pmg-mobile-index.html.tt new file mode 100644 index 0000000..cb08971 --- /dev/null +++ b/pmg-mobile-index.html.tt @@ -0,0 +1,36 @@ + + + + + + + + Proxmox Mail Gateway - Quarantine + + + + + [% IF langfile %] + + [% ELSE %] + + [%- END %] + + + +
    +
    +
    + [% IF debug %] + + [% ELSE %] + + [% END %] + + +