From 90f06cbe0b2ff3b1a9d85d2f73fe5864e692341b Mon Sep 17 00:00:00 2001 From: Udo Walter Date: Thu, 7 May 2020 19:30:48 +0200 Subject: Add location selection --- pom.xml | 82 ++++++++++++++- .../de/bwlehrpool/bwlp_guac/AvailableClient.java | 14 +++ .../bwlp_guac/BwlpAuthenticationProvider.java | 92 ++++++++++++++-- .../de/bwlehrpool/bwlp_guac/BwlpUserContext.java | 11 +- .../de/bwlehrpool/bwlp_guac/ConnectionManager.java | 116 ++++++++++++++++++--- .../java/de/bwlehrpool/bwlp_guac/JsonLocation.java | 34 +++++- .../de/bwlehrpool/bwlp_guac/LocationField.java | 26 +++++ src/main/resources/bwlpModule.js | 5 + src/main/resources/config/locationConfig.js | 11 ++ .../controllers/locationFieldController.js | 23 ++++ src/main/resources/guac-manifest.json | 14 ++- src/main/resources/styles/locationField.css | 47 +++++++++ src/main/resources/templates/locationField.html | 18 ++++ src/main/resources/translations/en.json | 5 + 14 files changed, 463 insertions(+), 35 deletions(-) create mode 100644 src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java create mode 100644 src/main/resources/bwlpModule.js create mode 100644 src/main/resources/config/locationConfig.js create mode 100644 src/main/resources/controllers/locationFieldController.js create mode 100644 src/main/resources/styles/locationField.css create mode 100644 src/main/resources/templates/locationField.html create mode 100644 src/main/resources/translations/en.json diff --git a/pom.xml b/pom.xml index f4c361f..559638e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,6 +1,6 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 de.bwlehrpool @@ -26,6 +26,77 @@ 1.7 + + + + com.keithbranton.mojo + angular-maven-plugin + 0.3.2 + + + generate-resources + + html2js + + + + + ${basedir}/src/main/resources + **/*.html + ${basedir}/src/main/resources/generated/templates-main/templates.js + app/ext/bwlp + + + + + + com.samaxes.maven + minify-maven-plugin + 1.7.5 + + + default-cli + + UTF-8 + + ${basedir}/src/main/resources + ${project.build.directory}/classes + + / + / + bwlp.css + + + **/*.css + + + / + / + bwlp.js + + + **/*.js + + + + + **/*.test.js + + CLOSURE + + + + OFF + OFF + + + + + minify + + + + @@ -41,6 +112,11 @@ guacamole-ext 1.1.0 - + + javax.servlet + servlet-api + 2.5 + provided + diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java b/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java index c886397..e9144f8 100644 --- a/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java +++ b/src/main/java/de/bwlehrpool/bwlp_guac/AvailableClient.java @@ -1,6 +1,7 @@ package de.bwlehrpool.bwlp_guac; import java.io.IOException; +import java.util.ArrayList; import java.util.concurrent.atomic.AtomicLong; import org.apache.guacamole.protocol.GuacamoleConfiguration; @@ -15,10 +16,14 @@ public class AvailableClient implements Cloneable { private static final AtomicLong CON_ID = new AtomicLong(); + public ArrayList locationList = new ArrayList(); + private final String clientip; private String password; + private int locationid; + private State state; private String inUseBy; @@ -35,6 +40,7 @@ public class AvailableClient implements Cloneable { public AvailableClient(JsonClient source) { this.clientip = source.clientip; + this.locationid = source.locationid; update(source); } @@ -110,6 +116,14 @@ public class AvailableClient implements Cloneable { return clientip + "/" + state + "/" + inUseBy; } + public State getState() { + return state; + } + + public int getLocationid() { + return locationid; + } + public GuacamoleConfiguration toGuacConfig() { GuacamoleConfiguration cfg = new GuacamoleConfiguration(); cfg.setProtocol("vnc"); diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java index 4ec4f48..b902621 100644 --- a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java +++ b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpAuthenticationProvider.java @@ -1,16 +1,25 @@ package de.bwlehrpool.bwlp_guac; -import java.util.Collections; -import java.util.Map; -import java.util.WeakHashMap; +import java.io.IOException; +import java.util.*; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.net.auth.AuthenticatedUser; -import org.apache.guacamole.net.auth.AuthenticationProvider; -import org.apache.guacamole.net.auth.Credentials; -import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.form.TextField; +import org.apache.guacamole.net.auth.*; +import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException; +import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException; +import org.codehaus.jackson.JsonGenerationException; +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.map.JsonMappingException; +import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.servlet.http.HttpServletRequest; +import javax.xml.soap.Text; + +import org.apache.guacamole.form.Field; +import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException; +import org.apache.guacamole.net.auth.credentials.CredentialsInfo; public class BwlpAuthenticationProvider implements AuthenticationProvider { @@ -44,7 +53,7 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider { } public UserContext updateUserContext(UserContext context, AuthenticatedUser authenticatedUser, - Credentials credentials) throws GuacamoleException { + Credentials credentials) throws GuacamoleException { LOGGER.warn("Ignoring updateUserContext called with " + context.toString()); return null; } @@ -52,13 +61,19 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider { public UserContext decorate(UserContext context, AuthenticatedUser authenticatedUser, Credentials credentials) throws GuacamoleException { String username = authenticatedUser.getCredentials().getUsername(); + LOGGER.warn("decorate called for " + username); BwlpUserContext user = oldMappings.get(username); + if (user != null) return user; + + int locationid = requestLocation(credentials); + LOGGER.warn("Doing the decoration"); - user = new BwlpUserContext(authenticatedUser, context); + user = new BwlpUserContext(authenticatedUser, context, locationid); oldMappings.put(username, user); + return user; } @@ -67,17 +82,74 @@ public class BwlpAuthenticationProvider implements AuthenticationProvider { public UserContext redecorate(UserContext decorated, UserContext context, AuthenticatedUser authenticatedUser, Credentials credentials) throws GuacamoleException { + String username = authenticatedUser.getCredentials().getUsername(); + LOGGER.warn("REdecorate called for " + username); BwlpUserContext user = oldMappings.get(username); + if (user != null && user.hasValidConnection()) return user; + + int locationid = requestLocation(credentials); + LOGGER.warn("Doing the REdecoration"); - user = new BwlpUserContext(authenticatedUser, context); + user = new BwlpUserContext(authenticatedUser, context, locationid); oldMappings.put(username, user); + return user; } + + private int requestLocation(Credentials credentials) throws GuacamoleException { + // Request the user to select a location + ConnectionManager.updateList(); + HttpServletRequest request = credentials.getRequest(); + String locationJson = request.getParameter("location"); + + if (locationJson == null) { + throw new GuacamoleInsufficientCredentialsException( + "Select Location", new CredentialsInfo( + Collections.singletonList(new LocationField()) + )); + } + + ObjectMapper mapper = new ObjectMapper(); + + String message = "Select a Location"; + + int selectedId = 0; + boolean tryAgain = false; + String password = ""; + String correctPassword = null; + try { + JsonNode location = mapper.readTree(locationJson); + selectedId = Integer.parseInt(location.get("id").asText()); + if (selectedId != 0) { + password = location.get("password").asText(); + correctPassword = ConnectionManager.getLocationPool().get(selectedId).password; + } + } catch (Exception e) { + LOGGER.info("Error reading location"); + LOGGER.info(e.toString()); + tryAgain = true; + } + + if (selectedId != 0 && correctPassword != null && !password.equals(correctPassword)) { + tryAgain = true; + message = "Wrong password!"; + } + + if (tryAgain) { + throw new GuacamoleCredentialsException( + message, new CredentialsInfo( + Collections.singletonList(new LocationField()) + )); + } + + return selectedId; + } + public void shutdown() { } diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java index 9a6e5d7..05fa78f 100644 --- a/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java +++ b/src/main/java/de/bwlehrpool/bwlp_guac/BwlpUserContext.java @@ -8,6 +8,8 @@ import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.Directory; import org.apache.guacamole.net.auth.User; import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException; +import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException; import org.apache.guacamole.net.auth.permission.ObjectPermissionSet; import org.apache.guacamole.net.auth.simple.SimpleConnection; import org.apache.guacamole.net.auth.simple.SimpleDirectory; @@ -29,6 +31,7 @@ public class BwlpUserContext extends AbstractUserContext { private final AuthenticatedUser authUser; private final UserContext originalContext; + private final Integer locationid; /** * The Directory with access to all connections within the root group associated @@ -36,15 +39,17 @@ public class BwlpUserContext extends AbstractUserContext { */ private Directory connectionDirectory; - public BwlpUserContext(AuthenticatedUser authenticatedUser, UserContext context) { + public BwlpUserContext(AuthenticatedUser authenticatedUser, UserContext context, int locationid) + throws GuacamoleCredentialsException { authUser = authenticatedUser; originalContext = context; + this.locationid = locationid; // OK addConn(); } - private void addConn() { - WrappedConnection connection = ConnectionManager.getForUser(authUser.getCredentials().getUsername()); + private void addConn() throws GuacamoleCredentialsException { + WrappedConnection connection = ConnectionManager.getForUser(authUser.getCredentials().getUsername(), locationid); if (connection != null) { connectionDirectory = new SimpleDirectory(connection); } else { diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java b/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java index f1ad057..eacb347 100644 --- a/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java +++ b/src/main/java/de/bwlehrpool/bwlp_guac/ConnectionManager.java @@ -10,8 +10,7 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.Iterator; -import java.util.LinkedHashMap; +import java.util.*; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; @@ -21,6 +20,10 @@ import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import org.apache.guacamole.form.Field; +import org.apache.guacamole.net.auth.credentials.CredentialsInfo; +import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException; +import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException; import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,6 +34,7 @@ import org.slf4j.LoggerFactory; */ public class ConnectionManager { + private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionManager.class); // TODO: Config @@ -38,11 +42,16 @@ public class ConnectionManager { private static final LinkedHashMap clientPool = new LinkedHashMap(); + private static final LinkedHashMap locationPool = new LinkedHashMap(); + + public static LinkedHashMap getLocationPool() { return locationPool; } + /** * Pass plain user name, get existing connection (if any), or a fresh one * @param user (LDAP) user name */ - public static WrappedConnection getForUser(String user) { + public static WrappedConnection getForUser(String user, int locationid) + throws GuacamoleCredentialsException { if (SOURCE_URL == null) { LOGGER.warn("Don't have a source URL for client machines!"); return null; @@ -51,17 +60,35 @@ public class ConnectionManager { updateList(); // Find existing/free client AvailableClient freeClient; - for (;;) { + for (; ; ) { freeClient = null; synchronized (clientPool) { - for (AvailableClient ac : clientPool.values()) { + Collection clients; + if (locationid == 0) { + clients = new HashSet(); + for (JsonLocation loc : locationPool.values()) { + if (!loc.hasPassword()) clients.addAll(loc.clientList); + } + } else { + JsonLocation location = locationPool.get(locationid); + if (location == null) { + // Request the user to select a location + throw new GuacamoleInsufficientCredentialsException( + "Select Location", new CredentialsInfo( + Collections.singletonList(new LocationField()) + )); + } + clients = location.clientList; + } + + for (AvailableClient ac : clients) { if (ac.isInUseBy(user)) { freeClient = ac; break; } } if (freeClient == null) { - for (AvailableClient ac : clientPool.values()) { + for (AvailableClient ac : clients) { if (ac.claim(user)) { freeClient = ac; break; @@ -69,8 +96,13 @@ public class ConnectionManager { } } } - if (freeClient == null) - return null; // TODO: No more clients available -- how to handle? + if (freeClient == null) { + // Request the user to select another location + throw new GuacamoleCredentialsException( + "No free client. Select another location.", new CredentialsInfo( + Collections.singletonList(new LocationField()) + )); + } // Found free or existing client, check if connection is (still) possible if (freeClient.checkConnection(0)) { LOGGER.info("Establishing mapping for user " + user + " to " + freeClient); @@ -79,6 +111,8 @@ public class ConnectionManager { // Connection check failed - release and loop again freeClient.releaseConnection(user); } + } catch (GuacamoleCredentialsException e) { + throw e; } catch (Exception e) { LOGGER.warn("KACKE AM DAMPFEN", e); return null; @@ -91,7 +125,7 @@ public class ConnectionManager { * Fetch fresh client list from satellite server. * Cache for 15 seconds. */ - private static synchronized void updateList() { + public static synchronized void updateList() { long now = System.currentTimeMillis(); if (now < lastUpdate) { lastUpdate = now; @@ -139,23 +173,68 @@ public class ConnectionManager { LOGGER.warn("Not updating local list"); return; } - list = root.clients; - if (list == null) { - LOGGER.info("Client list null"); + + + if (root.locations == null) { + LOGGER.info("Location list null"); } - if (root.locations != null) { - for (JsonLocation l : root.locations) { - LOGGER.info("Location " + l.name + " with pw " + l.password); + synchronized (locationPool) { + for (JsonLocation lnew : root.locations) { + JsonLocation existing = locationPool.get(lnew.id); + boolean redoClientMapping = false; + if (existing == null) { + locationPool.put(lnew.id, lnew); + existing = lnew; + redoClientMapping = true; + } else { + if (existing.locationids != lnew.locationids) redoClientMapping = true; + existing.locationids = lnew.locationids; + existing.name = lnew.name; + existing.password = lnew.password; + } + if (redoClientMapping) { + existing.clientList = new ArrayList(); + for (AvailableClient client : clientPool.values()) { + int locationid = client.getLocationid(); + for (int id : existing.locationids) { + if (id == locationid) { + existing.clientList.add(client); + break; + } + } + } + } + existing.checked = true; + LOGGER.info("Location " + lnew.name + " with pw " + lnew.password); } + for (JsonLocation loc : locationPool.values()) { + if (loc.checked) loc.checked = false; + else locationPool.remove(loc.id); + } + } + + if (root.clients == null) { + LOGGER.info("Client list null"); } synchronized (clientPool) { - for (JsonClient cnew : list) { + for (JsonClient cnew : root.clients) { if (cnew.password == null || cnew.clientip == null) continue; // Invalid AvailableClient existing = clientPool.get(cnew.clientip); if (existing == null) { // New client - clientPool.put(cnew.clientip, new AvailableClient(cnew)); + AvailableClient newClient = new AvailableClient(cnew); + clientPool.put(cnew.clientip, newClient); + + for (JsonLocation loc : locationPool.values()) { + for (int id : loc.locationids) { + if (id == cnew.locationid) { + loc.clientList.add(newClient); + newClient.locationList.add(loc); + break; + } + } + } LOGGER.info("New client " + cnew.clientip); } else { existing.update(cnew); @@ -166,6 +245,9 @@ public class ConnectionManager { AvailableClient c = it.next(); if (c.isTimeout(NOW)) { LOGGER.info("Removing client " + c + " from list"); + for (JsonLocation loc : c.locationList) { + loc.clientList.remove(c); + } it.remove(); } diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java b/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java index 6bf9d1c..e6fc1b6 100644 --- a/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java +++ b/src/main/java/de/bwlehrpool/bwlp_guac/JsonLocation.java @@ -1,5 +1,10 @@ package de.bwlehrpool.bwlp_guac; +import org.codehaus.jackson.annotate.JsonIgnore; +import org.codehaus.jackson.annotate.JsonProperty; + +import java.util.ArrayList; + public class JsonLocation { public int id; @@ -7,7 +12,34 @@ public class JsonLocation { public int[] locationids; public String name; - + public String password; + + @JsonProperty("password") + public Boolean hasPassword() { return password != null; } + + @JsonProperty("freeCount") + public int getFreeCount() { + int count = 0; + for (AvailableClient client : clientList) { + if (client.getState() == JsonClient.State.IDLE) count++; + } + return count; + } + + @JsonProperty("offlineCount") + public int getOfflineCount() { + int count = 0; + for (AvailableClient client : clientList) { + if (client.getState() == JsonClient.State.OFFLINE || client.getState() == JsonClient.State.STANDBY) count++; + } + return count; + } + + @JsonIgnore + public ArrayList clientList = new ArrayList(); + + @JsonIgnore + public boolean checked = false; } diff --git a/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java b/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java new file mode 100644 index 0000000..5e0266d --- /dev/null +++ b/src/main/java/de/bwlehrpool/bwlp_guac/LocationField.java @@ -0,0 +1,26 @@ +package de.bwlehrpool.bwlp_guac; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.form.Field; +import org.codehaus.jackson.annotate.JsonProperty; +import org.codehaus.jackson.map.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collection; + +/** + * Field which lets the user select a location. + */ +public class LocationField extends Field { + + public LocationField() { + super("location", "LOCATION"); + } + + @JsonProperty("locations") + public Collection getLocations() throws GuacamoleException { + return ConnectionManager.getLocationPool().values(); + } +} \ No newline at end of file diff --git a/src/main/resources/bwlpModule.js b/src/main/resources/bwlpModule.js new file mode 100644 index 0000000..c74580a --- /dev/null +++ b/src/main/resources/bwlpModule.js @@ -0,0 +1,5 @@ +angular.module('location', [ + 'form' +]); + +angular.module('index').requires.push('location'); \ No newline at end of file diff --git a/src/main/resources/config/locationConfig.js b/src/main/resources/config/locationConfig.js new file mode 100644 index 0000000..6074752 --- /dev/null +++ b/src/main/resources/config/locationConfig.js @@ -0,0 +1,11 @@ +angular.module('location').config(['formServiceProvider', + function locationConfig(formServiceProvider) { + + // Define field for the TOTP code provided by the user + formServiceProvider.registerFieldType('LOCATION', { + module : 'location', + controller : 'locationFieldController', + templateUrl : 'app/ext/bwlp/templates/locationField.html' + }); + + }]); \ No newline at end of file diff --git a/src/main/resources/controllers/locationFieldController.js b/src/main/resources/controllers/locationFieldController.js new file mode 100644 index 0000000..8117ed7 --- /dev/null +++ b/src/main/resources/controllers/locationFieldController.js @@ -0,0 +1,23 @@ +angular.module('location').controller('locationFieldController', ['$scope', '$window', + function locationFieldController($scope, $window) { + + $scope.data = { id: 0, password: '' } + + $scope.$watch('data', function(newValue) { + $scope.model = JSON.stringify(newValue); + }, true); + + $scope.selectLocation = function selectLocation($event, id) { + if (angular.element($event.target).hasClass('bwlp-password-input')) return; + $scope.data.password = ''; + if ($scope.data.id === id) { + $scope.data.id = 0; + angular.element('.selected-location').removeClass('selected-location'); + return; + } + $scope.data.id = id; + angular.element('.selected-location').removeClass('selected-location'); + angular.element($event.currentTarget).addClass('selected-location'); + }; + +}]); \ No newline at end of file diff --git a/src/main/resources/guac-manifest.json b/src/main/resources/guac-manifest.json index ef3c431..e507183 100644 --- a/src/main/resources/guac-manifest.json +++ b/src/main/resources/guac-manifest.json @@ -3,5 +3,17 @@ "name" : "bwLehrpool virtual pool", "namespace" : "de.bwlehrpool", "authProviders": ["de.bwlehrpool.bwlp_guac.BwlpAuthenticationProvider"], - "html" : [ "disclaimer.html" ] + "html" : [ "disclaimer.html" ], + "translations" : [ + "translations/en.json" + ], + "js" : [ + "bwlp.min.js" + ], + "css" : [ + "bwlp.min.css" + ], + "resources" : { + "templates/locationField.html" : "text/html" + } } diff --git a/src/main/resources/styles/locationField.css b/src/main/resources/styles/locationField.css new file mode 100644 index 0000000..cec1466 --- /dev/null +++ b/src/main/resources/styles/locationField.css @@ -0,0 +1,47 @@ +.bwlp-location-container { + border: 2px solid #3d3d3d; + width: 100%; + border-spacing: 0; +} + +.bwlp-location { + background-color: white; + cursor: pointer; +} + +.bwlp-location > td { + padding: 14px; +} + +.bwlp-location .bwlp-password { + padding: 4px 8px !important; + margin: -10px 0 !important; +} + +.bwlp-location:hover { + filter: brightness(0.9); +} + +.bwlp-location.selected-location { + background-color: #c8ffc8; +} + +.bwlp-location-status { + font-size: 16px; + text-align: center; +} + +.bwlp-password { + text-align: right; +} + +.bwlp-password > span { + font-size: 16px; + opacity: 0.5; +} + +.bwlp-password-input { + margin: 0 !important; + padding: 5px 8px !important; + background-color: white !important; +} \ No newline at end of file diff --git a/src/main/resources/templates/locationField.html b/src/main/resources/templates/locationField.html new file mode 100644 index 0000000..f76259e --- /dev/null +++ b/src/main/resources/templates/locationField.html @@ -0,0 +1,18 @@ + + + + {{ location.name }} + + + {{ location.freeCount }} available ({{ location.offlineCount }} offline) + + + + Password protected + + + + + diff --git a/src/main/resources/translations/en.json b/src/main/resources/translations/en.json new file mode 100644 index 0000000..9ad430b --- /dev/null +++ b/src/main/resources/translations/en.json @@ -0,0 +1,5 @@ +{ + "LOGIN" : { + "FIELD_HEADER_LOCATION" : "" + } +} \ No newline at end of file -- cgit v1.2.3-55-g7522