diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 399d994..6c963c6 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -13,6 +13,7 @@ "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/frontend": "^0.2.5", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", @@ -308,6 +309,15 @@ "node": ">=16.0.0" } }, + "node_modules/@simpleworkjs/frontend": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz", + "integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/ldap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 8afc018..2315966 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -21,8 +21,9 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", - "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/app-stack": "^1.0.0", + "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/frontend": "^0.2.5", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 8dc8768..461d664 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -91,3 +91,72 @@ app.apiToken = (function(app){ return {list, get, add, update, remove, rotate}; })(app); + +// Host / target validation, mirrored from the backend (utils/hostname_validate.js): +// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The +// incoming host may be a wildcard ("*.example.com"); the target may not. +// Proxy-specific, so it's registered here (via @simpleworkjs/frontend's +// $.validateSettings) rather than in the shared package's generic rule set. +(function(){ + var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; + // Either one bare label (Docker service names, /etc/hosts entries) or a + // dotted hostname with an alphabetic TLD. + var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i; + var FORBIDDEN = /[\s/:]/; + + function isIPv4( value ) { + var parts = value.split( '.' ); + if ( parts.length !== 4 ) return false; + return parts.every( function( p ) { + return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255; + }); + } + + // Incoming-host pattern: labels may be normal, "*" (one fragment), or "**" + // (any number of fragments, incl. a bare "**" global catch-all). + function isHostPattern( value ) { + if ( value.length > 253 ) return false; + return value.split( '.' ).every( function( l ) { + return l === '*' || l === '**' || LABEL.test( l ); + }); + } + + function forbidden( value ) { + return FORBIDDEN.test( value ) || value.includes( '://' ); + } + + // Incoming host: IPv4 or a wildcard host pattern. + function checkHost( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || isHostPattern( value ) ) return; + return "Enter a valid host or wildcard (*, **)"; + } + + // Downstream target: IPv4 or a strict hostname, no wildcard. + function checkTarget( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || HOSTNAME.test( value ) ) return; + return "Enter a valid hostname or IP"; + } + + $.validateSettings({ + rule:{ + // Incoming host name — hostname, IPv4, or wildcard pattern (*, **). + host: function( value ) { + return checkHost( value ); + }, + + // Downstream target — hostname or IPv4, no wildcard. + target: function( value ) { + return checkTarget( value ); + }, + + // Back-compat alias (no wildcard). + hostname: function( value ) { + return checkTarget( value ); + }, + } + }); +})(); diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 7aa874d..7fe4a17 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -363,7 +363,7 @@ app.auth = (function(app){ } if(requiredGroups && !await memberOf(requiredGroups, user)){ - app.util.actionMessage( + app.messages.action( `

You do not have permission to be here. @@ -520,68 +520,15 @@ app.util = (function(app){ return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); }; - function actionMessage(message, $targetPassed, type, callback){ - message = message || ''; - - let $target = $targetPassed.closest('div.card').find('.actionMessage'); - if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); - - type = type || 'info'; - callback = callback || function(){}; - - if($target.html() === message) return; - - if($target.html()){ - $target.slideUp('fast', function(){ - $target.html('') - $target.removeClass (function(index, className){ - return (className.match (/(^|\s)bg-\S+/g) || []).join(' '); - }); - if(message) return actionMessage(message, $target, type, callback); - $target.hide() - }) - }else{ - if(type) $target.addClass('bg-' + type); - - // Messages that bring their own buttons (actionConfirm) are left - // alone; everything else gets the standard dismiss button. - if(!message.includes('${message} - - ` - $target.html(message).slideDown('fast'); - } - setTimeout(callback,10) - } - - function actionConfirm(message, $target, type, callback){ - return new Promise((resolve, reject) =>{ - let id = crypto.randomUUID(); - message = ` -

- - ${message} - - - - -

- ` - actionMessage(message, $target, type); - $("body").on('click', `.confirm-${id}`, function(){ - actionMessage('', $target, type); - resolve(!!$(this).data('confirm')); - }); - }); - + // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's + // app.util.escapeHtml and app.messages.action/confirm. + function escapeHtml(s){ + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } $.fn.serializeObject = function() { @@ -640,8 +587,7 @@ app.util = (function(app){ return { downloadFile: downloadFile, getUrlParameter: getUrlParameter, - actionMessage: actionMessage, - actionConfirm, + escapeHtml: escapeHtml, } })(app); @@ -696,9 +642,9 @@ $( document ).ready(async function(){ $(this).closest('.card').slideUp('fast'); }); - $('.actionMessage').on('click', 'button.action-close', function(event){ - app.util.actionMessage(null, $(this)); - }); + // action-close click handling is wired by @simpleworkjs/frontend's + // app.messages.js (delegated on document, so it also covers messages + // rendered after this ready handler runs). setInterval(()=>{ $('.momentFromNow').each((idx, el)=>{ @@ -729,11 +675,11 @@ function formAJAX(btn){ var method = ($form.attr('method') || 'post').toLowerCase(); if($form.validate && !$form.validate()){ - app.util.actionMessage('Please fix the form errors.', $form, 'danger') + app.messages.action('Please fix the form errors.', $form, 'danger') return false; } - app.util.actionMessage( + app.messages.action( `
Loading...
`, @@ -742,7 +688,7 @@ function formAJAX(btn){ ); app.api[method]($form.attr('action'), formData, function(error, data){ - app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table + app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table $form.validateClear(); if(!error){ $form.trigger("reset"); @@ -750,7 +696,7 @@ function formAJAX(btn){ }else{ console.log('formAJAX res error', error, data) if(data && data.name === 'ObjectValidateError'){ - app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table + app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table } if(data && data.keys){ console.log('form key errors', data.keys) diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js deleted file mode 100755 index fd3da8f..0000000 --- a/nodejs/public/lib/js/val.js +++ /dev/null @@ -1,201 +0,0 @@ -( function( $ ) { - var settings = { - rule: { - eq: function(value, options){ - var compare = $('[name=' + options + ']').val(); - - if ( value != compare ) { - return "Miss-match"; - } - } - }, - }; - - $.fn.validate = function(event) { - // let thisSettings = $.extend(true, settings, settingsObj); - let hasErrors = false; - - if(this.is('[validate]')) return this.validateField(event); - - if(!this.attr('isValid')){ - console.log('adding reset event') - this.on('reset', function(){ - $(this).attr('isValid', false); - $(this).validateClear(); - }) - } - - this.find('[validate]').each(function(){ - if(!$(this).validateField()) hasErrors = true; - }); - - this.attr('isValid', !hasErrors); - - if(hasErrors && event) event.preventDefault(); - - return !hasErrors; - }; - - $.fn.validateClear = function(){ - $(this).find('input').each(function(){ - $(this).removeClass('is-invalid'); - $(this).removeClass('is-valid'); - }) - } - - $.fn.validateField = function(){ - var attr = this.attr('validate').split(':'); //array of params - var rule = attr[0]; - var options = attr[1]; - var value = this.val(); //link to input value - var message; - - if(this.prop('disabled')) return true; - - - //checks if field is required, and length - if(!isNaN(options) && value.length < options){ - message = `Must be ${options} characters`; - } - - //checks if empty to stop processing - if(!isNaN(options) && value.length === 0) { - }else if(rule in settings.rule){ - message = settings.rule[rule].apply(this, [value, options]); - } - - this.validateMessage(message) - return !message; - } - - $.fn.validateMessage = function(message){ - if(message && message !== true){ - this.closest('.form-group').find('b.invalid-feedback').html(message); - this.addClass('is-invalid'); - }else{ - this.removeClass('is-invalid'); - this.addClass('is-valid'); - } - return this; - }; - - jQuery.extend({ - validateSettings: function( settingsObj ) { - $.extend( true, settings, settingsObj ); - }, - - validateInit: function( ettingsObj ) { - $( '[action]' ).on( 'submit', function ( event, settingsObj ){ - $( this ).validate( settingsObj, event ); - }); - } - }); - -}( jQuery )); - -// Host / target validation, mirrored from the backend (utils/hostname_validate.js): -// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The -// incoming host may be a wildcard ("*.example.com"); the target may not. -(function(){ - var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; - // Either one bare label (Docker service names, /etc/hosts entries) or a - // dotted hostname with an alphabetic TLD. - var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i; - var FORBIDDEN = /[\s/:]/; - - function isIPv4( value ) { - var parts = value.split( '.' ); - if ( parts.length !== 4 ) return false; - return parts.every( function( p ) { - return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255; - }); - } - - // Incoming-host pattern: labels may be normal, "*" (one fragment), or "**" - // (any number of fragments, incl. a bare "**" global catch-all). - function isHostPattern( value ) { - if ( value.length > 253 ) return false; - return value.split( '.' ).every( function( l ) { - return l === '*' || l === '**' || LABEL.test( l ); - }); - } - - function forbidden( value ) { - return FORBIDDEN.test( value ) || value.includes( '://' ); - } - - // Incoming host: IPv4 or a wildcard host pattern. - function checkHost( value ) { - if ( typeof value !== 'string' || value.length === 0 ) return "Required"; - if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; - if ( isIPv4( value ) || isHostPattern( value ) ) return; - return "Enter a valid host or wildcard (*, **)"; - } - - // Downstream target: IPv4 or a strict hostname, no wildcard. - function checkTarget( value ) { - if ( typeof value !== 'string' || value.length === 0 ) return "Required"; - if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; - if ( isIPv4( value ) || HOSTNAME.test( value ) ) return; - return "Enter a valid hostname or IP"; - } - - $.validateSettings({ - rule:{ - ip: function( value ) { - value = value.split( '.' ); - - if ( value.length != 4 ) { - return "Malformed IP"; - } - - $.each( value, function( key, value ) { - if( value > 255 || value < 0 ) { - return "Malformed IP"; - } - }); - }, - - // Incoming host name — hostname, IPv4, or wildcard pattern (*, **). - host: function( value ) { - return checkHost( value ); - }, - - // Downstream target — hostname or IPv4, no wildcard. - target: function( value ) { - return checkTarget( value ); - }, - - // Back-compat alias (no wildcard). - hostname: function( value ) { - return checkTarget( value ); - }, - - user: function( value ) { - var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, - - // Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars - // or at least 3 of {lowercase, uppercase, number, symbol}. - password: function( value ) { - if ( typeof value !== 'string' || value.length < 8 ) { - return "Password must be at least 8 characters"; - } - if ( value.length >= 12 ) return; - - var classes = 0; - if ( /[a-z]/.test( value ) ) classes++; - if ( /[A-Z]/.test( value ) ) classes++; - if ( /[0-9]/.test( value ) ) classes++; - if ( /[^A-Za-z0-9]/.test( value ) ) classes++; - - if ( classes < 3 ) { - return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)"; - } - } - } - }); -})(); \ No newline at end of file diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 60fd214..8647e92 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -23,7 +23,7 @@ const values ={ // every deploy and isn't cache-busted/fingerprinted. mountStaticModules(router, { root: path.join(__dirname, '..'), - deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'], + deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'], }); router.get('/', (req, res) => { diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs index 6f5e0f9..f09cb70 100644 --- a/nodejs/views/groups.ejs +++ b/nodejs/views/groups.ejs @@ -28,14 +28,14 @@ function removeGroup(name){ app.group.remove(name, function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); $.scope.LocalGroup.remove(name); }); } function removeMember(group, username){ app.group.removeMember(group, username, function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); // websocket update echoes the new member list. }); } @@ -47,14 +47,14 @@ let username = ($input.val() || '').trim(); if(!username) return; app.group.addMember(group, username, function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); $input.val(''); }); } $(document).ready(function(){ app.group.list(function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); for(let g of data.results) $.scope.LocalGroup.push(g); }); diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index fa18728..df00c85 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -63,7 +63,7 @@ function hostPopulate(){ app.api.get('host?detail=1&provider=1', function(error, res){ - if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger'); for(let host of res.results){ $.scope.hosts.push(hostParseRow(host)); @@ -152,9 +152,9 @@ let password = $pass.val(); if(!password) return; app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){ - if(error) return app.util.actionMessage((data && data.message) || 'Failed to update password', $rows, 'danger'); + if(error) return app.messages.action((data && data.message) || 'Failed to update password', $rows, 'danger'); $pass.val(''); - app.util.actionMessage('Password updated for "' + username + '".', $rows, 'success'); + app.messages.action('Password updated for "' + username + '".', $rows, 'success'); }); }); // No confirm step, matching this form's existing "Delete" button @@ -163,7 +163,7 @@ let $del = $(''); $del.on('click', function(){ app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){ - if(error) return app.util.actionMessage((data && data.message) || 'Failed to delete user', $rows, 'danger'); + if(error) return app.messages.action((data && data.message) || 'Failed to delete user', $rows, 'danger'); $tr.remove(); $('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none'); }); @@ -291,7 +291,7 @@ function hostDownloadCert(host, type){ app.host.getCert({host}, function(error, data){ - if(error) app.util.actionMessage(error.message, $.scope.hosts.$this, 'danger'); + if(error) app.messages.action(error.message, $.scope.hosts.$this, 'danger'); app.util.downloadFile(`${host}-${type}.crt`, data[type]) }); } @@ -322,9 +322,9 @@ app.host.clearCache(function(error, data){ $btn.prop('disabled', false); if(error){ - return app.util.actionMessage(error.message || error, $.scope.hosts.$this, 'danger'); + return app.messages.action(error.message || error, $.scope.hosts.$this, 'danger'); } - app.util.actionMessage(data.message, $.scope.hosts.$this, 'success'); + app.messages.action(data.message, $.scope.hosts.$this, 'success'); }); } diff --git a/nodejs/views/permissions.ejs b/nodejs/views/permissions.ejs index 7abdc37..52e06b4 100644 --- a/nodejs/views/permissions.ejs +++ b/nodejs/views/permissions.ejs @@ -42,7 +42,7 @@ function removePermission(id){ app.permission.remove(id, function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger'); // The websocket echo removes the row; drop it locally too for snappiness. $.scope.Permission.remove(id); }); @@ -51,7 +51,7 @@ $(document).ready(function(){ // Existing permissions. app.permission.list(function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger'); for(let p of data.results) $.scope.Permission.push(p); }); diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index 6815399..70d4ffb 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -63,7 +63,7 @@ $(document).ready(function(){ app.api.get('user/me', function(error, data){ - if(error) return app.util.actionMessage(error, $('#profile-card'), 'danger'); + if(error) return app.messages.action(error, $('#profile-card'), 'danger'); renderProfile(data); }); }); @@ -170,7 +170,7 @@ function tableAJAX(){ app.apiToken.list(function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger'); $.scope.apiTokenCard.empty(); (data.results || []).forEach(function(token){ $.scope.apiTokenCard.push(processToken(token)); @@ -181,7 +181,7 @@ function revokeToken(id, name, btn){ if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return; app.apiToken.remove({id: id}, function(error, data){ - if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); + if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger'); $.scope.apiTokenCard.remove('id', id); }); } @@ -189,7 +189,7 @@ function rotateToken(id, name, btn){ if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return; app.apiToken.rotate({id: id}, function(error, data){ - if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger'); + if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger'); showSecret(data.token); tableAJAX(); }); diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index cc3c1cf..1fe6879 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -21,9 +21,11 @@ - + + + diff --git a/nodejs/views/users.ejs b/nodejs/views/users.ejs index b5b636a..debfe4c 100755 --- a/nodejs/views/users.ejs +++ b/nodejs/views/users.ejs @@ -22,7 +22,7 @@ function populateUsers(actionMessage){ app.user.list(function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.users.$this, 'danger'); for(let user of data.results){ $.scope.users.push(user); } @@ -37,7 +37,7 @@ function removeUser(username){ app.user.remove({username: username}, function(error, data){ - if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger'); + if(error) return app.messages.action(error, $.scope.users.$this, 'danger'); $.scope.users.remove(username); }); }