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 %] + + +