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}