From 8ae9a2e28c30b3b21cd40c929535d6f626db886f Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Sun, 10 May 2015 21:10:56 -0700 Subject: [PATCH] GUAC-587: Define class representing extensions and the extension manifest file. Dynamically add JavaScript and CSS resources to app.js and app.css. --- .../net/basic/extension/Extension.java | 241 ++++++++++++++++++ .../basic/extension/ExtensionManifest.java | 172 +++++++++++++ .../net/basic/extension/ExtensionModule.java | 53 +++- 3 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java create mode 100644 guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java new file mode 100644 index 000000000..e97ae2543 --- /dev/null +++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/Extension.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2015 Glyptodon LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.glyptodon.guacamole.net.basic.extension; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; +import org.codehaus.jackson.JsonParseException; +import org.codehaus.jackson.map.ObjectMapper; +import org.glyptodon.guacamole.GuacamoleException; +import org.glyptodon.guacamole.GuacamoleServerException; +import org.glyptodon.guacamole.net.basic.resource.ClassPathResource; +import org.glyptodon.guacamole.net.basic.resource.Resource; + +/** + * A Guacamole extension, which may provide custom authentication, static + * files, theming/branding, etc. + * + * @author Michael Jumper + */ +public class Extension { + + /** + * The Jackson parser for parsing the language JSON files. + */ + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * The name of the manifest file that describes the contents of a + * Guacamole extension. + */ + private static final String MANIFEST_NAME = "guac-manifest.json"; + + /** + * The parsed manifest file of this extension, describing the location of + * resources within the extension. + */ + private final ExtensionManifest manifest; + + /** + * The classloader to use when reading resources from this extension, + * including classes and static files. + */ + private final ClassLoader classLoader; + + /** + * Loads the given file as an extension, which must be a .jar containing + * a guac-manifest.json file describing its contents. + * + * @param parent + * The classloader to use as the parent for the isolated classloader of + * this extension. + * + * @param file + * The file to load as an extension. + * + * @throws GuacamoleException + * If the provided file is not a .jar file, does not contain the + * guac-manifest.json, or if guac-manifest.json is invalid and cannot + * be parsed. + */ + public Extension(final ClassLoader parent, final File file) throws GuacamoleException { + + try { + + // Open extension + ZipFile extension = new ZipFile(file); + + try { + + // Retrieve extension manifest + ZipEntry manifestEntry = extension.getEntry(MANIFEST_NAME); + if (manifestEntry == null) + throw new GuacamoleServerException("Extension " + file.getName() + " is missing " + MANIFEST_NAME); + + // Parse manifest + manifest = mapper.readValue(extension.getInputStream(manifestEntry), ExtensionManifest.class); + + } + + // Always close zip file, if possible + finally { + extension.close(); + } + + try { + + // Create isolated classloader for this extension + classLoader = AccessController.doPrivileged(new PrivilegedExceptionAction() { + + @Override + public ClassLoader run() throws GuacamoleException { + + try { + + // Classloader must contain only the extension itself + return new URLClassLoader(new URL[]{file.toURI().toURL()}, parent); + + } + catch (MalformedURLException e) { + throw new GuacamoleException(e); + } + + } + + }); + + } + + // Rethrow any GuacamoleException + catch (PrivilegedActionException e) { + throw (GuacamoleException) e.getException(); + } + + } + + // Abort load if not a valid zip file + catch (ZipException e) { + throw new GuacamoleServerException("Extension is not a valid zip file: " + file.getName(), e); + } + + // Abort if manifest cannot be parsed (invalid JSON) + catch (JsonParseException e) { + throw new GuacamoleServerException(MANIFEST_NAME + " is not valid JSON: " + file.getName(), e); + } + + // Abort if zip file cannot be read at all due to I/O errors + catch (IOException e) { + throw new GuacamoleServerException("Unable to read extension: " + file.getName(), e); + } + + } + + /** + * Returns the name of this extension, as declared in the extension's + * manifest. + * + * @return + * The name of this extension. + */ + public String getName() { + return manifest.getName(); + } + + /** + * Returns the namespace of this extension, as declared in the extension's + * manifest. + * + * @return + * The namespace of this extension. + */ + public String getNamespace() { + return manifest.getNamespace(); + } + + /** + * Returns a new collection of resources corresponding to the collection of + * paths provided. Each resource will be associated with the given + * mimetype. + * + * @param mimetype + * The mimetype to associate with each resource. + * + * @param paths + * The paths corresponding to the resources desired. + * + * @return + * A new, unmodifiable collection of resources corresponding to the + * collection of paths provided. + */ + private Collection getClassPathResources(String mimetype, Collection paths) { + + // If no paths are provided, just return an empty list + if (paths == null) + return Collections.emptyList(); + + // Add classpath resource for each path provided + Collection resources = new ArrayList(paths.size()); + for (String path : paths) + resources.add(new ClassPathResource(classLoader, mimetype, path)); + + // Callers should not rely on modifying the result + return Collections.unmodifiableCollection(resources); + + } + + /** + * Returns all declared JavaScript resources associated with this + * extension. JavaScript resources are declared within the extension + * manifest. + * + * @return + * All declared JavaScript resources associated with this extension. + */ + public Collection getJavaScriptResources() { + return getClassPathResources("text/javascript", manifest.getJavaScriptPaths()); + } + + /** + * Returns all declared CSS resources associated with this extension. CSS + * resources are declared within the extension manifest. + * + * @return + * All declared CSS resources associated with this extension. + */ + public Collection getCSSResources() { + return getClassPathResources("text/css", manifest.getCSSPaths()); + } + +} diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java new file mode 100644 index 000000000..2e29b3d48 --- /dev/null +++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionManifest.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 Glyptodon LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.glyptodon.guacamole.net.basic.extension; + +import java.util.Collection; +import org.codehaus.jackson.annotate.JsonProperty; + +/** + * Java representation of the JSON manifest contained within every Guacamole + * extension, identifying an extension and describing its contents. + * + * @author Michael Jumper + */ +public class ExtensionManifest { + + /** + * The name of the extension associated with this manifest. The extension + * name is human-readable, and used for display purposes only. + */ + private String name; + + /** + * The namespace of the extension associated with this manifest. The + * extension namespace is required for internal use, and is used wherever + * extension-specific files or resources need to be isolated from those of + * other extensions. + */ + private String namespace; + + /** + * The paths of all JavaScript resources within the .jar of the extension + * associated with this manifest. + */ + private Collection javaScriptPaths; + + /** + * The paths of all CSS resources within the .jar of the extension + * associated with this manifest. + */ + private Collection cssPaths; + + /** + * Returns the name of the extension associated with this manifest. The + * name is human-readable, for display purposes only, and is defined within + * the manifest by the "name" property. + * + * @return + * The name of the extension associated with this manifest. + */ + public String getName() { + return name; + } + + /** + * Sets the name of the extension associated with this manifest. The name + * is human-readable, for display purposes only, and is defined within the + * manifest by the "name" property. + * + * @param name + * The name of the extension associated with this manifest. + */ + public void setName(String name) { + this.name = name; + } + + /** + * Returns the namespace of the extension associated with this manifest. + * The namespace is required for internal use, and is used wherever + * extension-specific files or resources need to be isolated from those of + * other extensions. It is defined within the manifest by the "namespace" + * property. + * + * @return + * The namespace of the extension associated with this manifest. + */ + public String getNamespace() { + return namespace; + } + + /** + * Sets the namespace of the extension associated with this manifest. The + * namespace is required for internal use, and is used wherever extension- + * specific files or resources need to be isolated from those of other + * extensions. It is defined within the manifest by the "namespace" + * property. + * + * @param namespace + * The namespace of the extension associated with this manifest. + */ + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + /** + * Returns the paths to all JavaScript resources within the extension. + * These paths are defined within the manifest by the "js" property as an + * array of strings, where each string is a path relative to the root of + * the extension .jar. + * + * @return + * A collection of paths to all JavaScript resources within the + * extension. + */ + @JsonProperty("js") + public Collection getJavaScriptPaths() { + return javaScriptPaths; + } + + /** + * Sets the paths to all JavaScript resources within the extension. These + * paths are defined within the manifest by the "js" property as an array + * of strings, where each string is a path relative to the root of the + * extension .jar. + * + * @param javaScriptPaths + * A collection of paths to all JavaScript resources within the + * extension. + */ + @JsonProperty("js") + public void setJavaScriptPaths(Collection javaScriptPaths) { + this.javaScriptPaths = javaScriptPaths; + } + + /** + * Returns the paths to all CSS resources within the extension. These paths + * are defined within the manifest by the "js" property as an array of + * strings, where each string is a path relative to the root of the + * extension .jar. + * + * @return + * A collection of paths to all CSS resources within the extension. + */ + @JsonProperty("css") + public Collection getCSSPaths() { + return cssPaths; + } + + /** + * Sets the paths to all CSS resources within the extension. These paths + * are defined within the manifest by the "js" property as an array of + * strings, where each string is a path relative to the root of the + * extension .jar. + * + * @param cssPaths + * A collection of paths to all CSS resources within the extension. + */ + @JsonProperty("css") + public void setCSSPaths(Collection cssPaths) { + this.cssPaths = cssPaths; + } + +} diff --git a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java index 60502c542..f6b2b011e 100644 --- a/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java +++ b/guacamole/src/main/java/org/glyptodon/guacamole/net/basic/extension/ExtensionModule.java @@ -25,8 +25,13 @@ package org.glyptodon.guacamole.net.basic.extension; import com.google.inject.servlet.ServletModule; import java.io.File; import java.io.FileFilter; +import java.util.ArrayList; +import java.util.Collection; +import org.glyptodon.guacamole.GuacamoleException; import org.glyptodon.guacamole.environment.Environment; +import org.glyptodon.guacamole.net.basic.resource.Resource; import org.glyptodon.guacamole.net.basic.resource.ResourceServlet; +import org.glyptodon.guacamole.net.basic.resource.SequenceResource; import org.glyptodon.guacamole.net.basic.resource.WebApplicationResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,7 +60,7 @@ public class ExtensionModule extends ServletModule { * recognized as extensions. */ private static final String EXTENSION_SUFFIX = ".jar"; - + /** * The Guacamole server environment. */ @@ -81,7 +86,7 @@ public class ExtensionModule extends ServletModule { return; // Retrieve list of all extension files within extensions directory - File[] extensions = extensionsDir.listFiles(new FileFilter() { + File[] extensionFiles = extensionsDir.listFiles(new FileFilter() { @Override public boolean accept(File file) { @@ -90,15 +95,43 @@ public class ExtensionModule extends ServletModule { }); - // Load each extension - for (File extension : extensions) { - // TODO: Actually load extension - logger.info("Loading extension: \"{}\"", extension.getName()); + // Init JavaScript resources with base guacamole.min.js + Collection javaScriptResources = new ArrayList(); + javaScriptResources.add(new WebApplicationResource(getServletContext(), "/guacamole.min.js")); + + // Init CSS resources with base guacamole.min.css + Collection cssResources = new ArrayList(); + cssResources.add(new WebApplicationResource(getServletContext(), "/guacamole.min.css")); + + // Load each extension within the extension directory + for (File extensionFile : extensionFiles) { + + logger.debug("Loading extension: \"{}\"", extensionFile.getName()); + + try { + + // FIXME: Use class loader which reads from the lib directory + // Load extension from file + Extension extension = new Extension(ExtensionModule.class.getClassLoader(), extensionFile); + + // Add any JavaScript / CSS resources + javaScriptResources.addAll(extension.getJavaScriptResources()); + cssResources.addAll(extension.getCSSResources()); + + // Log successful loading of extension by name + logger.info("Extension \"{}\" loaded.", extension.getName()); + + } + catch (GuacamoleException e) { + logger.error("Extension \"{}\" could not be loaded: {}", extensionFile.getName(), e.getMessage()); + logger.debug("Unable to load extension.", e); + } + } - - // TODO: Pull these from extensions, dynamically concatenated - serve("/app.js").with(new ResourceServlet(new WebApplicationResource(getServletContext(), "/guacamole.min.js"))); - serve("/app.css").with(new ResourceServlet(new WebApplicationResource(getServletContext(), "/guacamole.min.css"))); + + // Dynamically generate app.js and app.css from extensions + serve("/app.js").with(new ResourceServlet(new SequenceResource(javaScriptResources))); + serve("/app.css").with(new ResourceServlet(new SequenceResource(cssResources))); }