001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software, please see the 018 * company website: http://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: http://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.module; 029 030import org.opencms.file.CmsObject; 031import org.opencms.file.CmsProject; 032import org.opencms.file.CmsResource; 033import org.opencms.file.CmsResourceFilter; 034import org.opencms.file.CmsVfsResourceNotFoundException; 035import org.opencms.importexport.CmsImportExportException; 036import org.opencms.main.CmsException; 037import org.opencms.main.CmsLog; 038import org.opencms.main.OpenCms; 039import org.opencms.module.CmsModuleLog.Action; 040import org.opencms.report.CmsLogReport; 041import org.opencms.report.I_CmsReport; 042import org.opencms.util.CmsFileUtil; 043import org.opencms.util.CmsStringUtil; 044 045import java.io.File; 046import java.io.FileInputStream; 047import java.io.FileOutputStream; 048import java.io.IOException; 049import java.io.UnsupportedEncodingException; 050import java.security.MessageDigest; 051import java.security.NoSuchAlgorithmException; 052import java.util.Arrays; 053import java.util.Collections; 054import java.util.List; 055import java.util.Locale; 056import java.util.Map; 057import java.util.Set; 058import java.util.concurrent.ConcurrentHashMap; 059import java.util.concurrent.TimeUnit; 060 061import org.apache.commons.codec.binary.Hex; 062import org.apache.commons.lang3.RandomStringUtils; 063import org.apache.commons.logging.Log; 064 065import com.google.common.base.Objects; 066import com.google.common.cache.CacheBuilder; 067import com.google.common.collect.Lists; 068import com.google.common.collect.Sets; 069 070/** 071 * Class which manages import/export of modules from repositories configured in opencms-importexport.xml.<p> 072 */ 073public class CmsModuleImportExportRepository { 074 075 /** 076 * Holds exported module data and a modification date. 077 */ 078 public static class ModuleExportData { 079 080 /** The file content. */ 081 private byte[] m_content; 082 083 /** The modification date. */ 084 private long m_dateLastModified; 085 086 /** 087 * Creates a new instance. 088 * 089 * @param content the exported data 090 * @param dateLastModified the modification date 091 */ 092 public ModuleExportData(byte[] content, long dateLastModified) { 093 094 m_content = content; 095 m_dateLastModified = dateLastModified; 096 } 097 098 /** 099 * Gets the exported data. 100 * 101 * @return the exported data 102 */ 103 public byte[] getContent() { 104 105 return m_content; 106 } 107 108 /** 109 * Gets the last modification date. 110 * 111 * @return the last modification date 112 */ 113 public long getDateLastModified() { 114 115 return m_dateLastModified; 116 } 117 } 118 119 /** Export folder path. */ 120 public static final String EXPORT_FOLDER_PATH = "packages/_export"; 121 122 /** Import folder path. */ 123 public static final String IMPORT_FOLDER_PATH = "packages/_import"; 124 125 /** Suffix for module zip files. */ 126 public static final String SUFFIX = ".zip"; 127 128 /** The log instance for this class. */ 129 private static final Log LOG = CmsLog.getLog(CmsModuleImportExportRepository.class); 130 131 /** The admin CMS context. */ 132 private CmsObject m_adminCms; 133 134 /** Cache for module hashes, used to detect changes in modules. */ 135 private Map<CmsModule, String> m_moduleHashCache = new ConcurrentHashMap<CmsModule, String>(); 136 137 /** Module log. */ 138 private CmsModuleLog m_moduleLog = new CmsModuleLog(); 139 140 /** Timed cache for newly calculated module hashes, used to avoid very frequent recalculation. */ 141 private Map<CmsModule, String> m_newModuleHashCache = CacheBuilder.newBuilder().expireAfterWrite( 142 3, 143 TimeUnit.SECONDS).<CmsModule, String> build().asMap(); 144 145 /** 146 * Creates a new instance.<p> 147 */ 148 public CmsModuleImportExportRepository() { 149 150 } 151 152 /** 153 * Deletes the module corresponding to the given virtual module file name.<p> 154 * 155 * @param fileName the file name 156 * @return true if the module could be deleted 157 * 158 * @throws CmsException if something goes wrong 159 */ 160 public synchronized boolean deleteModule(String fileName) throws CmsException { 161 162 String moduleName = null; 163 boolean ok = true; 164 try { 165 CmsModule module = getModuleForFileName(fileName); 166 if (module == null) { 167 LOG.error("Deletion request for invalid module file name: " + fileName); 168 ok = false; 169 return false; 170 } 171 I_CmsReport report = createReport(); 172 moduleName = module.getName(); 173 OpenCms.getModuleManager().deleteModule(m_adminCms, module.getName(), false, report); 174 ok = !(report.hasWarning() || report.hasError()); 175 return true; 176 } catch (Exception e) { 177 ok = false; 178 if (e instanceof CmsException) { 179 throw (CmsException)e; 180 } 181 if (e instanceof RuntimeException) { 182 throw (RuntimeException)e; 183 } 184 return true; 185 } finally { 186 m_moduleLog.log(moduleName, Action.deleteModule, ok); 187 } 188 } 189 190 /** 191 * Exports a module and returns the export zip file content in a byte array.<p> 192 * 193 * @param virtualModuleFileName the virtual file name for the module 194 * @param project the project from which the module should be exported 195 * 196 * @return the module export data 197 * 198 * @throws CmsException if something goes wrong 199 */ 200 @SuppressWarnings("resource") 201 public synchronized ModuleExportData getExportedModuleData(String virtualModuleFileName, CmsProject project) 202 throws CmsException { 203 204 CmsModule module = getModuleForFileName(virtualModuleFileName); 205 if (module == null) { 206 LOG.warn("Invalid module export path requested: " + virtualModuleFileName); 207 return null; 208 } 209 try { 210 String moduleName = module.getName(); 211 ensureFoldersExist(); 212 213 String moduleFilePath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf( 214 CmsStringUtil.joinPaths(EXPORT_FOLDER_PATH, moduleName + ".zip")); 215 File moduleFile = new File(moduleFilePath); 216 217 boolean needToRunExport = needToExportModule(module, moduleFile, project); 218 if (needToRunExport) { 219 CmsObject exportCms = OpenCms.initCmsObject(m_adminCms); 220 exportCms.getRequestContext().setCurrentProject(project); 221 LOG.info("Module export is needed for " + module.getName()); 222 moduleFile.delete(); 223 CmsModuleImportExportHandler handler = new CmsModuleImportExportHandler(); 224 List<String> moduleResources = CmsModule.calculateModuleResourceNames(exportCms, module); 225 handler.setAdditionalResources(moduleResources.toArray(new String[] {})); 226 // the import/export handler adds the zip extension if it is not there, so we append it here 227 String tempFileName = RandomStringUtils.randomAlphanumeric(8) + ".zip"; 228 String tempFilePath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf( 229 CmsStringUtil.joinPaths(EXPORT_FOLDER_PATH, tempFileName)); 230 handler.setFileName(tempFilePath); 231 handler.setModuleName(moduleName); 232 CmsException exportException = null; 233 I_CmsReport report = createReport(); 234 try { 235 handler.exportData(exportCms, report); 236 } catch (CmsException e) { 237 exportException = e; 238 } 239 boolean failed = ((exportException != null) || report.hasWarning() || report.hasError()); 240 m_moduleLog.log(moduleName, Action.exportModule, !failed); 241 242 if (exportException != null) { 243 new File(tempFilePath).delete(); 244 throw exportException; 245 } 246 new File(tempFilePath).renameTo(new File(moduleFilePath)); 247 LOG.info("Created module export " + moduleFilePath); 248 } 249 byte[] result = CmsFileUtil.readFully(new FileInputStream(moduleFilePath)); 250 return new ModuleExportData(result, new File(moduleFilePath).lastModified()); 251 } catch (IOException e) { 252 LOG.error(e.getLocalizedMessage(), e); 253 return null; 254 } 255 } 256 257 /** 258 * Gets the list of modules as file names.<p> 259 * 260 * @return the list of modules as file names 261 */ 262 public List<String> getModuleFileNames() { 263 264 List<String> result = Lists.newArrayList(); 265 for (CmsModule module : OpenCms.getModuleManager().getAllInstalledModules()) { 266 result.add(getFileNameForModule(module)); 267 } 268 return result; 269 } 270 271 /** 272 * Gets the object used to access the module log.<p> 273 * 274 * @return the module log 275 */ 276 public CmsModuleLog getModuleLog() { 277 278 return m_moduleLog; 279 } 280 281 /** 282 * Imports module data.<p> 283 * 284 * @param name the module file name 285 * @param content the module ZIP file data 286 * @throws CmsException if something goes wrong 287 */ 288 public synchronized void importModule(String name, byte[] content) throws CmsException { 289 290 String moduleName = null; 291 boolean ok = true; 292 try { 293 if (content.length == 0) { 294 // Happens when using CmsResourceWrapperModules with JLAN and createResource is called 295 LOG.debug("Zero-length module import content, ignoring it..."); 296 } else { 297 ensureFoldersExist(); 298 String targetFilePath = createImportZipPath(name); 299 try { 300 FileOutputStream out = new FileOutputStream(new File(targetFilePath)); 301 out.write(content); 302 out.close(); 303 } catch (IOException e) { 304 throw new CmsImportExportException( 305 Messages.get().container(Messages.ERR_FILE_IO_1, targetFilePath)); 306 } 307 CmsModule module = CmsModuleImportExportHandler.readModuleFromImport(targetFilePath); 308 moduleName = module.getName(); 309 I_CmsReport report = createReport(); 310 OpenCms.getModuleManager().replaceModule(m_adminCms, targetFilePath, report); 311 new File(targetFilePath).delete(); 312 if (report.hasError() || report.hasWarning()) { 313 ok = false; 314 } 315 } 316 } catch (CmsException e) { 317 ok = false; 318 throw e; 319 } catch (RuntimeException e) { 320 ok = false; 321 throw e; 322 } finally { 323 m_moduleLog.log(moduleName, Action.importModule, ok); 324 } 325 } 326 327 /** 328 * Initializes the CMS context.<p> 329 * 330 * @param adminCms the admin CMS context 331 */ 332 public void initialize(CmsObject adminCms) { 333 334 m_adminCms = adminCms; 335 } 336 337 /** 338 * Computes a module hash, which should change when a module changes and stay the same when the module doesn't change.<p> 339 * 340 * We only use the modification time of the module resources and their descendants and the modification time of the metadata 341 * for computing it. 342 * 343 * @param module the module for which to compute the module signature 344 * @param project the project in which to compute the module hash 345 * @return the module signature 346 * @throws CmsException if something goes wrong 347 */ 348 private String computeModuleHash(CmsModule module, CmsProject project) throws CmsException { 349 350 LOG.info("Getting module hash for " + module.getName()); 351 // This method may be called very frequently during a short time, but it is unlikely 352 // that a module changes multiple times in a few seconds, so we use a timed cache here 353 String cachedValue = m_newModuleHashCache.get(module); 354 if (cachedValue != null) { 355 LOG.info("Using cached value for module hash of " + module.getName()); 356 return cachedValue; 357 } 358 359 CmsObject cms = OpenCms.initCmsObject(m_adminCms); 360 if (!CmsStringUtil.isEmptyOrWhitespaceOnly(module.getSite())) { 361 cms.getRequestContext().setSiteRoot(module.getSite()); 362 } 363 cms.getRequestContext().setCurrentProject(project); 364 365 // We compute a hash code from the paths of all resources belonging to the module and their respective modification dates. 366 List<String> entries = Lists.newArrayList(); 367 for (String path : module.getResources()) { 368 try { 369 Set<CmsResource> resources = Sets.newHashSet(); 370 CmsResource moduleRes = cms.readResource(path, CmsResourceFilter.IGNORE_EXPIRATION); 371 resources.add(moduleRes); 372 if (moduleRes.isFolder()) { 373 resources.addAll(cms.readResources(path, CmsResourceFilter.IGNORE_EXPIRATION, true)); 374 } 375 for (CmsResource res : resources) { 376 entries.add(res.getRootPath() + ":" + res.getDateLastModified()); 377 } 378 } catch (CmsVfsResourceNotFoundException e) { 379 entries.add(path + ":null"); 380 } 381 } 382 Collections.sort(entries); 383 String inputString = CmsStringUtil.listAsString(entries, "\n") + "\nMETA:" + module.getObjectCreateTime(); 384 LOG.debug("Computing module hash from base string:\n" + inputString); 385 try { 386 MessageDigest md5 = MessageDigest.getInstance("MD5"); 387 md5.update(inputString.getBytes("UTF-8")); 388 String result = Hex.encodeHexString(md5.digest()); 389 return result; 390 } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { 391 // This shouldn't happen 392 LOG.error(e.getLocalizedMessage(), e); 393 return RandomStringUtils.randomAlphanumeric(8); 394 } 395 396 } 397 398 /** 399 * Creates a randomized path for the temporary file used to store the import data.<p> 400 * 401 * @param name the module name 402 * 403 * @return the generated path 404 */ 405 private String createImportZipPath(String name) { 406 407 String path = ""; 408 do { 409 String prefix = RandomStringUtils.randomAlphanumeric(6) + "-"; 410 path = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf( 411 CmsStringUtil.joinPaths(IMPORT_FOLDER_PATH, prefix + name)); 412 } while (new File(path).exists()); 413 return path; 414 } 415 416 /** 417 * Creates a new report for an export/import.<p> 418 * 419 * @return the new report 420 */ 421 private I_CmsReport createReport() { 422 423 return new CmsLogReport(Locale.ENGLISH, CmsModuleImportExportRepository.class); 424 } 425 426 /** 427 * Makes sure that the folders used to store the import/export data exist.<p> 428 */ 429 private void ensureFoldersExist() { 430 431 for (String path : Arrays.asList(IMPORT_FOLDER_PATH, EXPORT_FOLDER_PATH)) { 432 String folderPath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf(path); 433 File folder = new File(folderPath); 434 if (!folder.exists()) { 435 folder.mkdirs(); 436 } 437 } 438 } 439 440 /** 441 * Gets the virtual file name to use for the given module.<p> 442 * 443 * @param module the module for which to get the file name 444 * 445 * @return the file name 446 */ 447 private String getFileNameForModule(CmsModule module) { 448 449 return module.getName() + SUFFIX; 450 } 451 452 /** 453 * Gets the module which corresponds to the given virtual file name.<p> 454 * 455 * @param fileName the file name 456 * 457 * @return the module which corresponds to the given file name 458 */ 459 private CmsModule getModuleForFileName(String fileName) { 460 461 String moduleName = fileName; 462 if (fileName.endsWith(SUFFIX)) { 463 moduleName = fileName.substring(0, fileName.length() - SUFFIX.length()); 464 } 465 CmsModule result = OpenCms.getModuleManager().getModule(moduleName); 466 return result; 467 } 468 469 /** 470 * Checks if a given module needs to be re-exported.<p> 471 * 472 * @param module the module to check 473 * @param moduleFile the file representing the exported module (doesn't necessarily exist) 474 * @param project the project in which to check 475 * 476 * @return true if the module needs to be exported 477 */ 478 private boolean needToExportModule(CmsModule module, File moduleFile, CmsProject project) { 479 480 if (!moduleFile.exists()) { 481 LOG.info("Module export file doesn't exist, export is needed."); 482 try { 483 String moduleSignature = computeModuleHash(module, project); 484 if (moduleSignature != null) { 485 m_moduleHashCache.put(module, moduleSignature); 486 } 487 } catch (CmsException e) { 488 LOG.error(e.getLocalizedMessage(), e); 489 } 490 return true; 491 } else { 492 if (moduleFile.lastModified() < module.getObjectCreateTime()) { 493 return true; 494 } 495 496 String oldModuleSignature = m_moduleHashCache.get(module); 497 String newModuleSignature = null; 498 try { 499 newModuleSignature = computeModuleHash(module, project); 500 } catch (CmsException e) { 501 LOG.error(e.getLocalizedMessage(), e); 502 } 503 504 LOG.info( 505 "Comparing module hashes for " 506 + module.getName() 507 + " to check if export is needed: old = " 508 + oldModuleSignature 509 + ", new=" 510 + newModuleSignature); 511 if ((newModuleSignature == null) || !Objects.equal(oldModuleSignature, newModuleSignature)) { 512 if (newModuleSignature != null) { 513 m_moduleHashCache.put(module, newModuleSignature); 514 } 515 // if an error occurs or the module signatures don't match 516 return true; 517 } else { 518 return false; 519 } 520 } 521 } 522 523}