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