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('