All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.codehaus.mojo.webstart.AbstractBaseJnlpMojo Maven / Gradle / Ivy

package org.codehaus.mojo.webstart;

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.codehaus.mojo.webstart.dependency.filenaming.DependencyFilenameStrategy;
import org.codehaus.mojo.webstart.pack200.Pack200Config;
import org.codehaus.mojo.webstart.pack200.Pack200Tool;
import org.codehaus.mojo.webstart.sign.SignConfig;
import org.codehaus.mojo.webstart.sign.SignTool;
import org.codehaus.mojo.webstart.util.ArtifactUtil;
import org.codehaus.mojo.webstart.util.IOUtil;
import org.codehaus.mojo.webstart.util.JarUtil;
import org.sonatype.plexus.components.sec.dispatcher.SecDispatcher;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * The superclass for all JNLP generating MOJOs.
 *
 * @author Kevin Stembridge
 * @author $LastChangedBy$
 * @version $Revision$
 * @since 28 May 2007
 */
public abstract class AbstractBaseJnlpMojo
    extends AbstractMojo
{
    // ----------------------------------------------------------------------
    // Constants
    // ----------------------------------------------------------------------

    private static final String DEFAULT_RESOURCES_DIR = "src/main/jnlp/resources";

    /**
     * unprocessed files (that will be signed) are prefixed with this
     */
    private static final String UNPROCESSED_PREFIX = "unprocessed_";

    /**
     * Suffix extension of a jar file.
     */
    public static final String JAR_SUFFIX = ".jar";

    // ----------------------------------------------------------------------
    // Mojo Parameters
    // ----------------------------------------------------------------------

    /**
     * Local repository.
     */
    @Parameter( defaultValue = "${localRepository}", required = true, readonly = true )
    private ArtifactRepository localRepository;

    /**
     * The collection of remote artifact repositories.
     */
    @Parameter( defaultValue = "${project.remoteArtifactRepositories}", required = true, readonly = true )
    private List remoteRepositories;

    /**
     * The directory in which files will be stored prior to processing.
     */
    @Parameter( property = "jnlp.workDirectory", defaultValue = "${project.build.directory}/jnlp", required = true )
    private File workDirectory;

    /**
     * The path where the libraries are placed within the jnlp structure.
     */
    @Parameter( property = "jnlp.libPath", defaultValue = "" )
    protected String libPath;

    /**
     * The location of the directory (relative or absolute) containing non-jar resources that
     * are to be included in the JNLP bundle.
     */
    @Parameter( property = "jnlp.resourcesDirectory" )
    private File resourcesDirectory;

    /**
     * The location where the JNLP Velocity template files are stored.
     */
    @Parameter( property = "jnlp.templateDirectory", defaultValue = "${project.basedir}/src/main/jnlp", required = true )
    private File templateDirectory;

    /**
     * The Pack200 Config.
     *
     * @since 1.0-beta-4
     */
    @Parameter
    private Pack200Config pack200;

    /**
     * The Sign Config.
     */
    @Parameter
    private SignConfig sign;

    /**
     * Indicates whether or not gzip archives will be created for each of the jar
     * files included in the webstart bundle.
     */
    @Parameter( property = "jnlp.gzip", defaultValue = "false" )
    private boolean gzip;

    /**
     * Enable verbose output.
     */
    @Parameter( property = "webstart.verbose", alias = "verbose", defaultValue = "false" )
    private boolean verbose;

    /**
     * Set to true to exclude all transitive dependencies.
     *
     * @parameter
     */
    @Parameter( property = "jnlp.excludeTransitive" )
    private boolean excludeTransitive;

    /**
     * The code base to use on the generated jnlp files.
     *
     * @since 1.0-beta-2
     */
    @Parameter( property = "jnlp.codebase", defaultValue = "${project.url}/jnlp" )
    private String codebase;

    /**
     * Encoding used to read and write jnlp files.
     * 

* Note: If this property is not defined, then will use a default value {@code utf-8}. * * @since 1.0-beta-2 */ @Parameter( property = "jnlp.encoding", defaultValue = "${project.build.sourceEncoding}" ) private String encoding; /** * Define whether to remove existing signatures. */ @Parameter( property = "jnlp.unsign", alias = "unsign", defaultValue = "false" ) private boolean unsignAlreadySignedJars; /** * To authorize or not to unsign some already signed jar. *

* If set to false and the {@code unsign} parameter is set to {@code true} then the build will fail if there is * a jar to unsign, to avoid this use then the extension jnlp component. * * @since 1.0-beta-2 */ @Parameter( property = "jnlp.canUnsign", defaultValue = "true" ) private boolean canUnsign; /** * To update manifest entries of all jar resources. *

* Since jdk 1.7u45, you need to add some entries to be able to open jnlp files in High security level. * See http://www.oracle.com/technetwork/java/javase/7u45-relnotes-2016950.html *

* Note: Won't affect any already signed jar resources if you configuration does not authorize it. *

* See parameters {@link #unsignAlreadySignedJars} and {@link #canUnsign}. * * @since 1.0-beta-4 */ @Parameter private Map updateManifestEntries; /** * Compile class-path elements used to search for the keystore * (if kestore location was prefixed by {@code classpath:}). * * @since 1.0-beta-4 */ @Parameter( defaultValue = "${project.compileClasspathElements}", required = true, readonly = true ) private List compileClassPath; /** * Naming strategy for dependencies of a jnlp application. * * The strategy purpose is to transform the name of the dependency file. * * The actual authorized values are: *

    *
  • simple: artifactId[-classifier]-version.jar
  • *
  • full: groupId-artifactId[-classifier]-version.jar
  • *
* * Default value is {@code full} which avoid any colision of naming. * * @since 1.0-beta-5 */ @Parameter( property = "jnlp.filenameMapping", defaultValue = "simple", required = true) private String filenameMapping; /** * Use unique version for any snapshot dependency, or just use the {@code -SNAPSHOT} version suffix. * * @since 1.0-beta-7 */ @Parameter( property = "jnlp.useUniqueVersions", defaultValue = "false" ) private boolean useUniqueVersions; // ---------------------------------------------------------------------- // Components // ---------------------------------------------------------------------- /** * Sign tool. */ @Component private SignTool signTool; /** * All available pack200 tools. *

* We use a plexus list injection instead of a direct component injection since for a jre 1.4, we will at the * moment have no implementation of this tool. *

* Later in the execute of mojo, we will check if at least one implementation is available if required. * * @since 1.0-beta-2 */ @Component( role = Pack200Tool.class ) private Pack200Tool pack200Tool; /** * Artifact helper. * * @since 1.0-beta-4 */ @Component private ArtifactUtil artifactUtil; /** * io helper. * * @since 1.0-beta-4 */ @Component private IOUtil ioUtil; /** * Jar util. * * @since 1.0-beta-4 */ @Component( hint = "default" ) private JarUtil jarUtil; /** * All dependency filename strategy indexed by theire role-hint. * * @since 1.0-beta-5 */ @Component( role = DependencyFilenameStrategy.class ) private Map dependencyFilenameStrategyMap; /** * @since 1.0-beta-7 */ @Component(hint = "mng-4384") private SecDispatcher securityDispatcher; // ---------------------------------------------------------------------- // Fields // ---------------------------------------------------------------------- /** * List of detected modified artifacts (will then re-apply stuff on them). */ private final List modifiedJnlpArtifacts = new ArrayList(); // the jars to sign and pack are selected if they are prefixed by UNPROCESSED_PREFIX. // as the plugin copies the new versions locally before signing/packing them // we just need to see if the plugin copied a new version // We achieve that by only filtering files modified after the plugin was started // Note: if other files (the pom, the keystore config) have changed, one needs to clean private final FileFilter unprocessedJarFileFilter; /** * Filter of processed jar files. */ private final FileFilter processedJarFileFilter; /** * Filter of jar files that need to be pack200. */ private final FileFilter unprocessedPack200FileFilter; /** * The dependency filename strategy. */ private DependencyFilenameStrategy dependencyFilenameStrategy; /** * Creates a new {@code AbstractBaseJnlpMojo}. */ public AbstractBaseJnlpMojo() { processedJarFileFilter = new FileFilter() { /** * {@inheritDoc} */ public boolean accept( File pathname ) { return pathname.isFile() && pathname.getName().endsWith( JAR_SUFFIX ) && !pathname.getName().startsWith( UNPROCESSED_PREFIX ); } }; unprocessedJarFileFilter = new FileFilter() { /** * {@inheritDoc} */ public boolean accept( File pathname ) { return pathname.isFile() && pathname.getName().startsWith( UNPROCESSED_PREFIX ) && pathname.getName().endsWith( JAR_SUFFIX ); } }; unprocessedPack200FileFilter = new FileFilter() { /** * {@inheritDoc} */ public boolean accept( File pathname ) { return pathname.isFile() && pathname.getName().startsWith( UNPROCESSED_PREFIX ) && ( pathname.getName().endsWith( JAR_SUFFIX + Pack200Tool.PACK_GZ_EXTENSION ) || pathname.getName().endsWith( JAR_SUFFIX + Pack200Tool.PACK_EXTENSION ) ); } }; } // ---------------------------------------------------------------------- // Public Methods // ---------------------------------------------------------------------- public abstract MavenProject getProject(); /** * Returns the library path. This is ths subpath within the working directory, where the libraries are placed. * If the path is not configured it is null. * * @return the library path or null if not configured. */ public String getLibPath() { if ( StringUtils.isBlank( libPath ) ) { return null; } return libPath; } /** * Returns the flag that indicates whether or not jar resources * will be compressed using pack200. * * @return Returns the value of the pack200.enabled field. */ public boolean isPack200() { return pack200 != null && pack200.isEnabled(); } /** * Returns the files to be passed without pack200 compression. * * @return Returns the list value of the pack200.passFiles. */ public List getPack200PassFiles() { return pack200 == null ? null : pack200.getPassFiles(); } // ---------------------------------------------------------------------- // Protected Methods // ---------------------------------------------------------------------- /** * Returns the working directory. This is the directory in which files and resources * will be placed in order to be processed prior to packaging. * * @return Returns the value of the workDirectory field. */ protected File getWorkDirectory() { return workDirectory; } /** * Returns the library directory. If not libPath is configured, the working directory is returned. * * @return Returns the value of the libraryDirectory field. */ protected File getLibDirectory() { if ( getLibPath() != null ) { return new File( getWorkDirectory(), getLibPath() ); } return getWorkDirectory(); } /** * Returns the location of the directory containing * non-jar resources that are to be included in the JNLP bundle. * * @return Returns the value of the resourcesDirectory field, never null. */ protected File getResourcesDirectory() { if ( resourcesDirectory == null ) { resourcesDirectory = new File( getProject().getBasedir(), DEFAULT_RESOURCES_DIR ); } return resourcesDirectory; } /** * Returns the file handle to the directory containing the Velocity templates for the JNLP * files to be generated. * * @return Returns the value of the templateDirectory field. */ protected File getTemplateDirectory() { return templateDirectory; } /** * Returns the local artifact repository. * * @return Returns the value of the localRepository field. */ protected ArtifactRepository getLocalRepository() { return localRepository; } /** * Returns the collection of remote artifact repositories for the current * Maven project. * * @return Returns the value of the remoteRepositories field. */ protected List getRemoteRepositories() { return remoteRepositories; } /** * Returns jar signing configuration element. * * @return Returns the value of the sign field. */ protected SignConfig getSign() { return sign; } /** * Returns the code base to inject in the generated jnlp. * * @return Returns the value of codebase field. */ protected String getCodebase() { return codebase; } /** * Returns the flag that indicates whether or not a gzip should be * created for each jar resource. * * @return Returns the value of the gzip field. */ protected boolean isGzip() { return gzip; } /** * Returns the flag that indicates whether or not to provide verbose output. * * @return Returns the value of the verbose field. */ protected boolean isVerbose() { return verbose; } /** * Returns the flag that indicates whether or not all transitive dependencies will be excluded * from the generated JNLP bundle. * * @return Returns the value of the excludeTransitive field. */ protected boolean isExcludeTransitive() { return this.excludeTransitive; } /** * Returns the collection of artifacts that have been modified * since the last time this mojo was run. * * @return Returns the value of the modifiedJnlpArtifacts field. */ protected List getModifiedJnlpArtifacts() { return modifiedJnlpArtifacts; } /** * @return the mojo encoding to use to write files. */ protected String getEncoding() { if ( StringUtils.isEmpty( encoding ) ) { encoding = "utf-8"; getLog().warn( "No encoding defined, will use the default one : " + encoding ); } return encoding; } protected DependencyFilenameStrategy getDependencyFilenameStrategy() { if ( dependencyFilenameStrategy == null ) { dependencyFilenameStrategy = dependencyFilenameStrategyMap.get(filenameMapping ); } return dependencyFilenameStrategy; } protected boolean isUseUniqueVersions() { return useUniqueVersions; } protected void checkDependencyFilenameStrategy() throws MojoExecutionException { if ( getDependencyFilenameStrategy() == null ) { dependencyFilenameStrategy = dependencyFilenameStrategyMap.get(filenameMapping ); if (dependencyFilenameStrategy==null) { throw new MojoExecutionException( "Could not find filenameMapping named '"+filenameMapping+"', use one of the following one: "+ dependencyFilenameStrategyMap.keySet()); } } } /** * Conditionally copy the jar file into the target directory. * The operation is not performed when a signed target file exists and is up to date. * The signed target file name is taken from the sourceFile name.E * The unsigned target file name is taken from the sourceFile name prefixed with UNPROCESSED_PREFIX. * TODO this is confusing if the sourceFile is already signed. By unsigned we really mean 'unsignedbyus' * * @param sourceFile source file to copy * @param targetDirectory location of the target directory where to copy file * @param targetFilename [optional] to change the target filename to use (if {@code null} will * use the sourceFile name). * @return true when the file was copied, false otherwise. * @throws IllegalArgumentException if sourceFile is null or * sourceFile.getName() is null * @throws MojoExecutionException if an error occurs attempting to copy the file. */ protected boolean copyJarAsUnprocessedToDirectoryIfNecessary( File sourceFile, File targetDirectory, String targetFilename ) throws MojoExecutionException { if ( sourceFile == null ) { throw new IllegalArgumentException( "sourceFile is null" ); } if ( targetFilename == null ) { targetFilename = sourceFile.getName(); } File signedTargetFile = new File( targetDirectory, targetFilename ); File unsignedTargetFile = toUnprocessFile( targetDirectory, targetFilename ); boolean shouldCopy = !signedTargetFile.exists() || ( signedTargetFile.lastModified() < sourceFile.lastModified() ); shouldCopy &= ( !unsignedTargetFile.exists() || ( unsignedTargetFile.lastModified() < sourceFile.lastModified() ) ); if ( shouldCopy ) { getIoUtil().copyFile( sourceFile, unsignedTargetFile ); } else { getLog().debug( "Source file hasn't changed. Do not reprocess " + signedTargetFile + " with " + sourceFile + "." ); } return shouldCopy; } /** * If sign is enabled, sign the jars, otherwise rename them into final jars * * @throws MojoExecutionException if can not sign or rename jars */ protected void signOrRenameJars() throws MojoExecutionException { if ( sign != null ) { try { ClassLoader loader = getCompileClassLoader(); sign.init( getWorkDirectory(), getLog().isDebugEnabled(), signTool, securityDispatcher, loader ); } catch ( MalformedURLException e ) { throw new MojoExecutionException( "Could not create classloader", e ); } if ( unsignAlreadySignedJars ) { removeExistingSignatures( getLibDirectory() ); } if ( isPack200() ) { //TODO tchemit use a temporary directory to pack-unpack // http://java.sun.com/j2se/1.5.0/docs/guide/deployment/deployment-guide/pack200.html // we need to pack then unpack the files before signing them unpackJars( getLibDirectory() ); // As out current Pack200 ant tasks don't give us the ability to use a temporary area for // creating those temporary packing, we have to delete the temporary files. ioUtil.deleteFiles( getLibDirectory(), unprocessedPack200FileFilter ); // specs says that one should do it twice when there are unsigned jars?? // Pack200.unpackJars( applicationDirectory, updatedPack200FileFilter ); } if ( MapUtils.isNotEmpty( updateManifestEntries ) ) { updateManifestEntries( getLibDirectory() ); } int signedJars = signJars( getLibDirectory() ); if ( signedJars != getModifiedJnlpArtifacts().size() ) { throw new IllegalStateException( "The number of signed artifacts (" + signedJars + ") differ from the number of modified " + "artifacts (" + getModifiedJnlpArtifacts().size() + "). Implementation error" ); } } else { makeUnprocessedFilesFinal( getLibDirectory() ); } if ( isPack200() ) { verboseLog( "-- Pack jars" ); pack200Jars( getLibDirectory(), processedJarFileFilter ); } } protected void pack200Jars( File directory, FileFilter filter ) throws MojoExecutionException { try { getPack200Tool().packJars( directory, filter, isGzip(), getPack200PassFiles() ); } catch ( IOException e ) { throw new MojoExecutionException( "Could not pack200 jars: ", e ); } } protected URL findDefaultTemplateURL(JnlpFileType fileType) { return getClass().getClassLoader().getResource( fileType.getDefaultTemplateName() ); } /** * @return something of the form jar:file:..../webstart-maven-plugin-.....jar!/ */ protected String getWebstartJarURLForVelocity() { String url = findDefaultTemplateURL(JnlpFileType.application).toString(); return url.substring( 0, url.indexOf( "!" ) + 2 ); } protected boolean isJarSigned( File jarFile ) throws MojoExecutionException { return signTool.isJarSigned( jarFile ); } protected ArtifactUtil getArtifactUtil() { return artifactUtil; } protected IOUtil getIoUtil() { return ioUtil; } protected Pack200Tool getPack200Tool() { return pack200Tool; } /** * Log as info when verbose or info is enabled, as debug otherwise. * * @param msg the message to display */ protected void verboseLog( String msg ) { if ( isVerbose() ) { getLog().info( msg ); } else { getLog().debug( msg ); } } // ---------------------------------------------------------------------- // Private Methods // ---------------------------------------------------------------------- private void unpackJars( File directory ) throws MojoExecutionException { getLog().info( "-- Unpack jars before sign operation " ); verboseLog( "see http://docs.oracle.com/javase/7/docs/technotes/guides/deployment/deployment-guide/pack200.html" ); // pack pack200Jars( directory, unprocessedJarFileFilter ); // then unpack try { getPack200Tool().unpackJars( directory, unprocessedPack200FileFilter ); } catch ( IOException e ) { throw new MojoExecutionException( "Could not unpack200 jars: ", e ); } } private int makeUnprocessedFilesFinal( File directory ) throws MojoExecutionException { File[] jarFiles = directory.listFiles( unprocessedJarFileFilter ); getLog().debug( "makeUnprocessedFilesFinal in " + directory + " found " + jarFiles.length + " file(s) to rename" ); if ( jarFiles.length == 0 ) { return 0; } for ( File unprocessedJarFile : jarFiles ) { File finalJar = toProcessFile( unprocessedJarFile ); ioUtil.deleteFile( finalJar ); ioUtil.renameTo( unprocessedJarFile, finalJar ); } return jarFiles.length; } /** * @param directory location of directory where to update manifest entries jars * @throws MojoExecutionException if can not update manifest entries jars */ private void updateManifestEntries( File directory ) throws MojoExecutionException { File[] jarFiles = directory.listFiles( unprocessedJarFileFilter ); getLog().info( "-- Update manifest entries" ); getLog().debug( "updateManifestEntries in " + directory + " found " + jarFiles.length + " jar(s) to treat" ); if ( jarFiles.length == 0 ) { return; } for ( File unprocessedJarFile : jarFiles ) { verboseLog( "Update manifest " + toProcessFile( unprocessedJarFile ).getName() ); jarUtil.updateManifestEntries( unprocessedJarFile, updateManifestEntries ); } } /** * @param directory location of directory where to sign jars * @return the number of signed jars * @throws MojoExecutionException if can not sign jars */ private int signJars( File directory ) throws MojoExecutionException { File[] jarFiles = directory.listFiles( unprocessedJarFileFilter ); getLog().info( "-- Sign jars" ); getLog().debug( "signJars in " + directory + " found " + jarFiles.length + " jar(s) to sign" ); if ( jarFiles.length == 0 ) { return 0; } boolean signVerify = sign.isVerify(); for ( File unprocessedJarFile : jarFiles ) { File signedJar = toProcessFile( unprocessedJarFile ); ioUtil.deleteFile( signedJar ); verboseLog( "Sign " + signedJar.getName() ); signTool.sign( sign, unprocessedJarFile, signedJar ); getLog().debug( "lastModified signedJar:" + signedJar.lastModified() + " unprocessed signed Jar:" + unprocessedJarFile.lastModified() ); if ( signVerify ) { verboseLog( "Verify signature of " + signedJar.getName() ); signTool.verify( sign, signedJar, isVerbose() ); } // remove unprocessed files // TODO wouldn't have to do that if we copied the unprocessed jar files in a temporary area ioUtil.deleteFile( unprocessedJarFile ); } return jarFiles.length; } /** * Removes the signature of the files in the specified directory which satisfy the * specified filter. * * @param workDirectory working directory used to unsign jars * @return the number of unsigned jars * @throws MojoExecutionException if could not remove signatures */ private int removeExistingSignatures( File workDirectory ) throws MojoExecutionException { getLog().info( "-- Remove existing signatures" ); // cleanup tempDir if exists File tempDir = new File( workDirectory, "temp_extracted_jars" ); ioUtil.removeDirectory( tempDir ); // recreate temp dir ioUtil.makeDirectoryIfNecessary( tempDir ); // process jars File[] jarFiles = workDirectory.listFiles( unprocessedJarFileFilter ); for ( File jarFile : jarFiles ) { if ( isJarSigned( jarFile ) ) { if ( !canUnsign ) { throw new MojoExecutionException( "neverUnsignAlreadySignedJar is set to true and a jar file [" + jarFile + " was asked to be unsign,\n please prefer use in this case an extension for " + "signed jars or not set to true the neverUnsignAlreadySignedJar parameter, Make " + "your choice:)" ); } verboseLog( "Remove signature " + toProcessFile( jarFile ).getName() ); signTool.unsign( jarFile, isVerbose() ); } else { verboseLog( "Skip not signed " + toProcessFile( jarFile ).getName() ); } } // cleanup tempDir ioUtil.removeDirectory( tempDir ); return jarFiles.length; // FIXME this is wrong. Not all jars are signed. } private ClassLoader getCompileClassLoader() throws MalformedURLException { URL[] urls = new URL[compileClassPath.size()]; for ( int i = 0; i < urls.length; i++ ) { String spec = compileClassPath.get( i ).toString(); URL url = new File( spec ).toURI().toURL(); urls[i] = url; } return new URLClassLoader( urls ); } private File toUnprocessFile( File targetDirectory, String sourceName ) { if ( sourceName.startsWith( UNPROCESSED_PREFIX ) ) { throw new IllegalStateException( sourceName + " does start with " + UNPROCESSED_PREFIX ); } String targetFilename = UNPROCESSED_PREFIX + sourceName; return new File( targetDirectory, targetFilename ); } private File toProcessFile( File source ) { if ( !source.getName().startsWith( UNPROCESSED_PREFIX ) ) { throw new IllegalStateException( source.getName() + " does not start with " + UNPROCESSED_PREFIX ); } String targetFilename = source.getName().substring( UNPROCESSED_PREFIX.length() ); return new File( source.getParentFile(), targetFilename ); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy