001package co.codewizards.cloudstore.core.updater;
002
003import static co.codewizards.cloudstore.core.io.StreamUtil.*;
004import static co.codewizards.cloudstore.core.oio.OioFileFactory.*;
005import static co.codewizards.cloudstore.core.util.UrlUtil.*;
006import static java.util.Objects.*;
007
008import java.io.BufferedReader;
009import java.io.FileFilter;
010import java.io.IOException;
011import java.io.InputStream;
012import java.io.InputStreamReader;
013import java.io.OutputStream;
014import java.net.URL;
015import java.util.Date;
016import java.util.HashMap;
017import java.util.Map;
018import java.util.Properties;
019import java.util.concurrent.locks.Lock;
020
021import org.slf4j.Logger;
022import org.slf4j.LoggerFactory;
023
024import co.codewizards.cloudstore.core.DevMode;
025import co.codewizards.cloudstore.core.appid.AppIdRegistry;
026import co.codewizards.cloudstore.core.config.Config;
027import co.codewizards.cloudstore.core.config.ConfigDir;
028import co.codewizards.cloudstore.core.config.ConfigImpl;
029import co.codewizards.cloudstore.core.dto.DateTime;
030import co.codewizards.cloudstore.core.io.LockFile;
031import co.codewizards.cloudstore.core.io.LockFileFactory;
032import co.codewizards.cloudstore.core.oio.File;
033import co.codewizards.cloudstore.core.util.IOUtil;
034import co.codewizards.cloudstore.core.util.PropertiesUtil;
035import co.codewizards.cloudstore.core.version.LocalVersionInIdeHelper;
036import co.codewizards.cloudstore.core.version.Version;
037
038public class CloudStoreUpdaterCore {
039        private static final Logger logger = LoggerFactory.getLogger(CloudStoreUpdaterCore.class);
040
041        public static final String INSTALLATION_PROPERTIES_FILE_NAME = "installation.properties";
042        public static final String INSTALLATION_PROPERTIES_ARTIFACT_ID = "artifactId";
043        public static final String INSTALLATION_PROPERTIES_VERSION = "version";
044        public static final String remoteVersionURL = // "http://cloudstore.codewizards.co/update/co.codewizards.cloudstore.aggregator/version";
045                        AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/co.codewizards.cloudstore.aggregator/version";
046        public static final String remoteUpdatePropertiesURL = // "http://cloudstore.codewizards.co/update/co.codewizards.cloudstore.aggregator/update.0.properties";
047                        AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/co.codewizards.cloudstore.aggregator/update.0.properties";
048
049        /**
050         * Configuration property key controlling whether we do a downgrade. By default, only an upgrade is done. If this
051         * configuration property is set to <code>true</code> and the local version is newer than the version on the server
052         * a downgrade is done, too.
053         * <p>
054         * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
055         */
056        public static final String CONFIG_KEY_DOWNGRADE = "updater.downgrade";
057
058        /**
059         * Configuration property key controlling whether the updater is enabled.
060         * <p>
061         * If it is enabled, it {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD periodically checks} whether an update is
062         * available, and if so, performs the update. Note, that {@link #CONFIG_KEY_FORCE} (or its
063         * corresponding system property "cloudstore.updater.force") have no effect, if the updater is disabled!
064         * <p>
065         * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
066         */
067        public static final String CONFIG_KEY_ENABLED = "updater.enabled";
068
069        /**
070         * Configuration property key controlling whether to force the update. If this property is set, an update is
071         * done even if the versions locally and remotely are already the same.
072         * <p>
073         * This is only designed as configuration key for consistency reasons - usually, you likely don't want to write
074         * this into a configuration file! Instead, you probably want to pass this as a system property - see
075         * {@link Config#SYSTEM_PROPERTY_PREFIX} (and the example below).
076         * <p>
077         * Note, that forcing an update has no effect, if the updater is {@linkplain #CONFIG_KEY_ENABLED disabled}!
078         * Thus, if you want to force an update under all circumstances (whether the updater is enabled or not),
079         * you should pass both. As system properties, this looks as follows:
080         * <pre>-D<b>cloudstore.updater.force=true</b> -D<b>cloudstore.updater.enabled=true</b></pre>
081         */
082        public static final String CONFIG_KEY_FORCE = "updater.force";
083
084        /**
085         * Default value for {@link #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD} (6 hours in milliseconds).
086         */
087        public static final long DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = 6 * 60 * 60 * 1000;
088
089        /**
090         * Configuration property key controlling how long a queried remote version is cached (and thus how
091         * often the server is asked for it).
092         * <p>
093         * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
094         */
095        public static final String CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = "updater.remoteVersionCache.validityPeriod";
096
097        private Version localVersion;
098        private Version remoteVersion;
099        private Properties installationProperties;
100        private File installationDir;
101        private File updaterDir;
102        private File backupDir;
103
104        public CloudStoreUpdaterCore() { }
105
106        public Version getRemoteVersion() {
107                Version remoteVersion = this.remoteVersion;
108                if (remoteVersion == null) {
109                        final RemoteVersionCache remoteVersionCache = readRemoteVersionCacheFromProperties();
110                        final long cachePeriod = getRemoteVersionCacheValidityPeriod();
111                        if (remoteVersionCache != null && System.currentTimeMillis() - remoteVersionCache.remoteVersionTimestamp.getMillis() <= cachePeriod) {
112                                logger.debug("getRemoteVersion: Cached value '{}' is from {} and still valid (it expires {}). Using this value (not asking server).",
113                                                remoteVersionCache.remoteVersion,
114                                                remoteVersionCache.remoteVersionTimestamp.toDate(),
115                                                new Date(remoteVersionCache.remoteVersionTimestamp.getMillis() + cachePeriod));
116                                this.remoteVersion = remoteVersion = remoteVersionCache.remoteVersion;
117                        }
118                        else {
119                                final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID);
120                                // cannot use resolve(...), because it invokes this method ;-)
121                                requireNonNull(artifactId, "artifactId");
122                                final Map<String, Object> variables = new HashMap<>(1);
123                                variables.put("artifactId", artifactId);
124                                final String resolvedRemoteVersionURL = IOUtil.replaceTemplateVariables(remoteVersionURL, variables);
125                                try {
126                                        final URL url = new URL(resolvedRemoteVersionURL);
127                                        final InputStream in = url.openStream();
128                                        try {
129                                                final BufferedReader r = new BufferedReader(new InputStreamReader(in, "UTF-8"));
130                                                final String line = r.readLine();
131                                                if (line == null || line.isEmpty())
132                                                        throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL);
133
134                                                final String trimmed = line.trim();
135                                                if (trimmed.isEmpty())
136                                                        throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL);
137
138                                                this.remoteVersion = remoteVersion = new Version(trimmed);
139                                                r.close();
140                                        } finally {
141                                                in.close();
142                                        }
143                                        writeRemoteVersionCacheToProperties(new RemoteVersionCache(remoteVersion, new DateTime(new Date())));
144                                } catch (final IOException e) {
145                                        throw new RuntimeException(e);
146                                }
147                        }
148                }
149                return remoteVersion;
150        }
151
152        public Version getLocalVersion() {
153                if (localVersion == null) {
154                        Properties installationProperties = null;
155                        try {
156                                installationProperties = getInstallationProperties();
157                        } catch (UnsupportedOperationException x) {
158                                // running inside IDE => read pom.xml (or other IDE resource) instead
159                                localVersion = LocalVersionInIdeHelper.getInstance().getLocalVersionInIde();
160                        }
161                        if (localVersion == null) {
162                                final String value = installationProperties.getProperty(INSTALLATION_PROPERTIES_VERSION);
163                                if (value == null || value.isEmpty())
164                                        throw new IllegalStateException("Failed to read local version from installation-properties-file!");
165
166                                final String trimmed = value.trim();
167                                if (trimmed.isEmpty())
168                                        throw new IllegalStateException("Failed to read local version from installation-properties-file!");
169
170                                localVersion = new Version(trimmed);
171                        }
172                }
173                return localVersion;
174        }
175
176        protected Properties getInstallationProperties() {
177                if (installationProperties == null) {
178                        final File installationPropertiesFile = createFile(getInstallationDir(), INSTALLATION_PROPERTIES_FILE_NAME);
179                        if (!installationPropertiesFile.exists())
180                                throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' does not exist!", installationPropertiesFile.getAbsolutePath()));
181
182                        if (!installationPropertiesFile.isFile())
183                                throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' is not a file!", installationPropertiesFile.getAbsolutePath()));
184
185                        try {
186                                final Properties properties = PropertiesUtil.load(installationPropertiesFile);
187                                installationProperties = properties;
188                        } catch (final IOException x) {
189                                throw new RuntimeException(x);
190                        }
191                }
192                return installationProperties;
193        }
194
195        /**
196         * Resolves the given {@code template} by replacing all its variables with their actual values.
197         * <p>
198         * Variables are written as "${variable}" similarly to Ant and Maven. See
199         * {@link IOUtil#replaceTemplateVariables(String, Map)} for further details.
200         * <p>
201         * The variable values are obtained from the {@link #getInstallationProperties() installationProperties}.
202         * @param template the template to be resolved. Must not be <code>null</code>.
203         * @return
204         */
205        protected String resolve(final String template) {
206                requireNonNull(template, "template");
207                final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID);
208                requireNonNull(artifactId, "artifactId");
209
210                final Version remoteVersion = getRemoteVersion();
211
212                final Map<String, Object> variables = new HashMap<>(4);
213                variables.put("artifactId", artifactId);
214                variables.put("version", remoteVersion);
215                variables.put("remoteVersion", remoteVersion);
216                variables.put("localVersion", getLocalVersion());
217                return IOUtil.replaceTemplateVariables(template, variables);
218        }
219
220        /**
221         * Gets the installation directory.
222         * <p>
223         * The implementation in {@link CloudStoreUpdaterCore} assumes that this class is located in a library
224         * (i.e. a JAR file) inside the installation directory.
225         * @return the installation directory. Never <code>null</code>.
226         * @throws IllegalStateException if the installation directory cannot be determined.
227         */
228        protected File getInstallationDir() throws IllegalStateException {
229                if (installationDir == null)
230                        installationDir = determineInstallationDirFromClass();
231
232                return installationDir;
233        }
234
235        private File determineInstallationDirFromClass() {
236                if (DevMode.isDevModeEnabled())
237                        throw new UnsupportedOperationException("There is no installationDir in DevMode!");
238
239                final URL resource = CloudStoreUpdaterCore.class.getResource("");
240                logger.debug("determineInstallationDirFromClass: resource={}", resource);
241                if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_JAR)) {
242                        final File file = getFileFromJarUrl(resource);
243                        logger.debug("determineInstallationDirFromClass: file={}", file);
244
245                        File dir = file;
246                        if (!dir.isDirectory())
247                                dir = dir.getParentFile();
248
249                        while (dir != null) {
250                                final File installationPropertiesFile = createFile(dir, INSTALLATION_PROPERTIES_FILE_NAME);
251                                if (installationPropertiesFile.exists()) {
252                                        logger.debug("determineInstallationDirFromClass: Found installationPropertiesFile in this directory: {}", dir);
253                                        return dir;
254                                }
255                                logger.debug("determineInstallationDirFromClass: installationPropertiesFile not found in this directory: {}", dir);
256                                dir = dir.getParentFile();
257                        }
258                        throw new IllegalStateException(String.format("File '%s' was not found in any expected location!", INSTALLATION_PROPERTIES_FILE_NAME));
259                } else if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_FILE)) {
260                        throw new IllegalStateException("CloudStoreUpdaterCore was loaded inside the IDE! Load it from a real installation!");
261                } else
262                        throw new IllegalStateException("Class 'CloudStoreUpdaterCore' was not loaded from a local JAR or class file!");
263        }
264
265        /**
266         * Is the configuration property {@link #CONFIG_KEY_DOWNGRADE} set to "true"?
267         * @return the value of the configuration property {@link #CONFIG_KEY_DOWNGRADE}.
268         */
269        private boolean isDowngrade() {
270                return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_DOWNGRADE, Boolean.FALSE);
271        }
272
273        private boolean isForce() {
274                return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_FORCE, Boolean.FALSE);
275        }
276
277        private boolean isEnabled() {
278                return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_ENABLED, Boolean.TRUE);
279        }
280
281        private long getRemoteVersionCacheValidityPeriod() {
282                return ConfigImpl.getInstance().getPropertyAsPositiveOrZeroLong(CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD, DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD);
283        }
284
285        private File getUpdaterPropertiesFile() {
286                return createFile(ConfigDir.getInstance().getFile(), "updater.properties");
287        }
288
289        private static final String PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP = "remoteVersionTimestamp";
290        private static final String PROPERTY_KEY_REMOTE_VERSION = "remoteVersion";
291
292        private static class RemoteVersionCache {
293                public final Version remoteVersion;
294                public final DateTime remoteVersionTimestamp;
295
296                public RemoteVersionCache(final Version remoteVersion, final DateTime remoteVersionTimestamp) {
297                        this.remoteVersion = requireNonNull(remoteVersion, "remoteVersion");
298                        this.remoteVersionTimestamp = requireNonNull(remoteVersionTimestamp, "remoteVersionTimestamp");
299                }
300        }
301
302        private RemoteVersionCache readRemoteVersionCacheFromProperties() {
303                try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) {
304                        final Properties properties = new Properties();
305                        try {
306                                final InputStream in = castStream(lockFile.createInputStream());
307                                try {
308                                        properties.load(in);
309                                } finally {
310                                        in.close();
311                                }
312
313                                final String versionStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION);
314                                if (versionStr == null || versionStr.trim().isEmpty())
315                                        return null;
316
317                                final String timestampStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP);
318                                if (timestampStr == null || timestampStr.trim().isEmpty())
319                                        return null;
320
321                                final Version remoteVersion;
322                                try {
323                                        remoteVersion = new Version(versionStr.trim());
324                                } catch (final Exception x) {
325                                        logger.warn("readRemoteVersionFromProperties: Version-String '{}' could not be parsed into a Version! Returning null!", versionStr.trim());
326                                        return null;
327                                }
328
329                                final DateTime remoteVersionTimestamp;
330                                try {
331                                        remoteVersionTimestamp = new DateTime(timestampStr.trim());
332                                } catch (final Exception x) {
333                                        logger.warn("readRemoteVersionFromProperties: Timestamp-String '{}' could not be parsed into a DateTime! Returning null!", timestampStr.trim());
334                                        return null;
335                                }
336
337                                return new RemoteVersionCache(remoteVersion, remoteVersionTimestamp);
338                        } catch (final IOException e) {
339                                throw new RuntimeException(e);
340                        }
341                }
342        }
343
344        private void writeRemoteVersionCacheToProperties(final RemoteVersionCache remoteVersionCache) {
345                try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) {
346                        final Lock lock = lockFile.getLock();
347                        lock.lock();
348                        try {
349                                final Properties properties = new Properties();
350                                try {
351                                        final InputStream in = castStream(lockFile.createInputStream());
352                                        try {
353                                                properties.load(in);
354                                        } finally {
355                                                in.close();
356                                        }
357
358                                        if (remoteVersionCache == null) {
359                                                properties.remove(PROPERTY_KEY_REMOTE_VERSION);
360                                                properties.remove(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP);
361                                        }
362                                        else {
363                                                properties.setProperty(PROPERTY_KEY_REMOTE_VERSION, remoteVersionCache.remoteVersion.toString());
364                                                properties.setProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP, remoteVersionCache.remoteVersionTimestamp.toString());
365                                        }
366
367                                        final OutputStream out = castStream(lockFile.createOutputStream());
368                                        try {
369                                                properties.store(out, null);
370                                        } finally {
371                                                out.close();
372                                        }
373                                } catch (final IOException e) {
374                                        throw new RuntimeException(e);
375                                }
376                        } finally {
377                                lock.unlock();
378                        }
379                }
380        }
381
382        /**
383         * Creates the {@link #getUpdaterDir() updaterDir}, if an update is necessary.
384         * <p>
385         * If an update is not necessary, this method returns silently without doing anything.
386         * <p>
387         * The check, whether an update is needed, is not done every time. The result is cached for
388         * {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD a certain time period} to reduce HTTP queries.
389         * <p>
390         * This method does not throw any exception. In case of an exception, it is only logged and this
391         * method returns normally.
392         * @return <code>true</code>, if an update is needed and about to be done; <code>false</code> otherwise.
393         * Note: If an update is needed, but cannot be done for any reason (e.g. because the directory is not writable),
394         * <code>false</code> is returned.
395         */
396        public boolean createUpdaterDirIfUpdateNeeded() {
397                File updaterDir = null;
398                try {
399                        if (!isEnabled()) {
400                                if (isForce())
401                                        logger.warn("createUpdaterDirIfUpdateNeeded: The configuration key '{}' (or its corresponding system property) is set to force an update, but the updater is *not* enabled! You must set the configuration key '{}' (or its corresponding system property) additionally! Skipping!", CONFIG_KEY_FORCE, CONFIG_KEY_ENABLED);
402                                else
403                                        logger.info("createUpdaterDirIfUpdateNeeded: Updater is *not* enabled! Skipping! See configuration key '{}'.", CONFIG_KEY_ENABLED);
404
405                                return false;
406                        }
407
408                        updaterDir = getUpdaterDir();
409                        IOUtil.deleteDirectoryRecursively(updaterDir);
410
411                        if (isUpdateNeeded()) {
412                                if (!canWriteAll(getInstallationDir())) {
413                                        logger.error("Installation directory '{}' is not writable or contains sub-directories/files that are not writable! Cannot perform auto-update to new version {}! Please update manually! Your local version is {}.",
414                                                        getInstallationDir(), getRemoteVersion(), getLocalVersion());
415                                        return false;
416                                }
417
418                                copyInstallationDirectoryForUpdater();
419                                logger.debug("createUpdaterDirIfUpdateNeeded: updaterDir='{}'", updaterDir);
420                                return true;
421                        }
422                } catch (final Exception x) {
423                        logger.error("createUpdaterDirIfUpdateNeeded: " + x, x);
424                        if (updaterDir != null) {
425                                try {
426                                        IOUtil.deleteDirectoryRecursively(updaterDir);
427                                } catch (final Exception y) {
428                                        logger.error("createUpdaterDirIfUpdateNeeded: " + y, y);
429                                }
430                        }
431                }
432                return false;
433        }
434
435        private boolean canWriteAll(final File fileOrDir) {
436                if (!fileOrDir.canWrite())
437                        return false;
438
439                final File[] children = fileOrDir.listFiles(fileFilterIgnoringBackupDir);
440                if (children != null) {
441                        for (final File child : children) {
442                                if (!canWriteAll(child))
443                                        return false;
444                        }
445                }
446                return true;
447        }
448
449        public File getUpdaterDir() {
450                if (updaterDir == null)
451                        updaterDir = createFile(getInstallationDir(), "updater");
452
453                return updaterDir;
454        }
455
456        protected File getBackupDir() {
457                if (backupDir == null)
458                        backupDir = createFile(getInstallationDir(), "backup");
459
460                return backupDir;
461        }
462
463        protected final FileFilter fileFilterIgnoringBackupDir = new FileFilter() {
464                @Override
465                public boolean accept(final java.io.File file) {
466                        return !getBackupDir().getIoFile().equals(file);
467                }
468        };
469
470        protected final FileFilter fileFilterIgnoringBackupAndUpdaterDir = new FileFilter() {
471                @Override
472                public boolean accept(final java.io.File file) {
473                        return !(getBackupDir().getIoFile().equals(file) || getUpdaterDir().getIoFile().equals(file));
474                }
475        };
476
477        private File copyInstallationDirectoryForUpdater() {
478                try {
479                        final File updaterDir = getUpdaterDir();
480                        IOUtil.deleteDirectoryRecursively(updaterDir);
481                        IOUtil.copyDirectory(getInstallationDir(), updaterDir, fileFilterIgnoringBackupAndUpdaterDir);
482                        return updaterDir;
483                } catch (final IOException e) {
484                        throw new RuntimeException(e);
485                }
486        }
487
488        private boolean isUpdateNeeded() {
489                final Version localVersion = getLocalVersion();
490                final Version remoteVersion = getRemoteVersion();
491                if (isForce()) {
492                        logger.warn("isUpdateNeeded: Update forced via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
493                        return true;
494                }
495
496                if (localVersion.equals(remoteVersion)) {
497                        logger.debug("isUpdateNeeded: No update, because localVersion equals remoteVersion='{}'", remoteVersion);
498                        return false;
499                }
500
501                if (localVersion.compareTo(remoteVersion) > 0) {
502                        if (isDowngrade()) {
503                                logger.warn("isUpdateNeeded: Downgrading enabled via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
504                                return true;
505                        }
506
507                        logger.info("isUpdateNeeded: No update, because localVersion='{}' is newer than remoteVersion='{}'", localVersion, remoteVersion);
508                        return false;
509                }
510
511                logger.warn("isUpdateNeeded: Update needed! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
512                return true;
513        }
514}