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}