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.util.Arrays;
050import java.util.Collections;
051import java.util.List;
052import java.util.Locale;
053import java.util.Map;
054import java.util.Set;
055import java.util.concurrent.ConcurrentHashMap;
056import java.util.concurrent.TimeUnit;
057
058import org.apache.commons.lang3.RandomStringUtils;
059import org.apache.commons.logging.Log;
060
061import com.google.common.base.Objects;
062import com.google.common.cache.CacheBuilder;
063import com.google.common.collect.Lists;
064import com.google.common.collect.Sets;
065
066/**
067 * Class which manages import/export of modules from repositories configured in opencms-importexport.xml.<p>
068 */
069public class CmsModuleImportExportRepository {
070
071    /** Export folder path. */
072    public static final String EXPORT_FOLDER_PATH = "packages/_export";
073
074    /** Import folder path. */
075    public static final String IMPORT_FOLDER_PATH = "packages/_import";
076
077    /** Suffix for module zip files. */
078    public static final String SUFFIX = ".zip";
079
080    /** The log instance for this class. */
081    private static final Log LOG = CmsLog.getLog(CmsModuleImportExportRepository.class);
082
083    /** The admin CMS context. */
084    private CmsObject m_adminCms;
085
086    /** Cache for module hashes, used to detect changes in modules. */
087    private Map<CmsModule, String> m_moduleHashCache = new ConcurrentHashMap<CmsModule, String>();
088
089    /** Module log. */
090    private CmsModuleLog m_moduleLog = new CmsModuleLog();
091
092    /** Timed cache for newly calculated module hashes, used to avoid very frequent recalculation. */
093    private Map<CmsModule, String> m_newModuleHashCache = CacheBuilder.newBuilder().expireAfterWrite(
094        3,
095        TimeUnit.SECONDS).<CmsModule, String> build().asMap();
096
097    /**
098     * Creates a new instance.<p>
099     */
100    public CmsModuleImportExportRepository() {
101
102    }
103
104    /**
105     * Deletes the module corresponding to the given virtual module file name.<p>
106     *
107     * @param fileName the file name
108     * @return true if the module could be deleted
109     *
110     * @throws CmsException if something goes wrong
111     */
112    public synchronized boolean deleteModule(String fileName) throws CmsException {
113
114        String moduleName = null;
115        boolean ok = true;
116        try {
117            CmsModule module = getModuleForFileName(fileName);
118            if (module == null) {
119                LOG.error("Deletion request for invalid module file name: " + fileName);
120                ok = false;
121                return false;
122            }
123            I_CmsReport report = createReport();
124            moduleName = module.getName();
125            OpenCms.getModuleManager().deleteModule(m_adminCms, module.getName(), false, report);
126            ok = !(report.hasWarning() || report.hasError());
127            return true;
128        } catch (Exception e) {
129            ok = false;
130            if (e instanceof CmsException) {
131                throw (CmsException)e;
132            }
133            if (e instanceof RuntimeException) {
134                throw (RuntimeException)e;
135            }
136            return true;
137        } finally {
138            m_moduleLog.log(moduleName, Action.deleteModule, ok);
139        }
140
141    }
142
143    /**
144     * Exports a module and returns the export zip file content in a byte array.<p>
145     *
146     * @param virtualModuleFileName the virtual file name for the module
147     * @param project the project from which the module should be exported
148     *
149     * @return the module export data
150     *
151     * @throws CmsException if something goes wrong
152     */
153    @SuppressWarnings("resource")
154    public synchronized byte[] getExportedModuleData(String virtualModuleFileName, CmsProject project)
155    throws CmsException {
156
157        CmsModule module = getModuleForFileName(virtualModuleFileName);
158        if (module == null) {
159            LOG.warn("Invalid module export path requested: " + virtualModuleFileName);
160            return null;
161        }
162        try {
163            String moduleName = module.getName();
164            ensureFoldersExist();
165
166            String moduleFilePath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf(
167                CmsStringUtil.joinPaths(EXPORT_FOLDER_PATH, moduleName + ".zip"));
168            File moduleFile = new File(moduleFilePath);
169
170            boolean needToRunExport = needToExportModule(module, moduleFile, project);
171            if (needToRunExport) {
172                CmsObject exportCms = OpenCms.initCmsObject(m_adminCms);
173                exportCms.getRequestContext().setCurrentProject(project);
174                LOG.info("Module export is needed for " + module.getName());
175                moduleFile.delete();
176                CmsModuleImportExportHandler handler = new CmsModuleImportExportHandler();
177                List<String> moduleResources = CmsModule.calculateModuleResourceNames(exportCms, module);
178                handler.setAdditionalResources(moduleResources.toArray(new String[] {}));
179                // the import/export handler adds the zip extension if it is not there, so we append it here
180                String tempFileName = RandomStringUtils.randomAlphanumeric(8) + ".zip";
181                String tempFilePath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf(
182                    CmsStringUtil.joinPaths(EXPORT_FOLDER_PATH, tempFileName));
183                handler.setFileName(tempFilePath);
184                handler.setModuleName(moduleName);
185                CmsException exportException = null;
186                I_CmsReport report = createReport();
187                try {
188                    handler.exportData(exportCms, report);
189                } catch (CmsException e) {
190                    exportException = e;
191                }
192                boolean failed = ((exportException != null) || report.hasWarning() || report.hasError());
193                m_moduleLog.log(moduleName, Action.exportModule, !failed);
194
195                if (exportException != null) {
196                    new File(tempFilePath).delete();
197                    throw exportException;
198                }
199                new File(tempFilePath).renameTo(new File(moduleFilePath));
200                LOG.info("Created module export " + moduleFilePath);
201            }
202            byte[] result = CmsFileUtil.readFully(new FileInputStream(moduleFilePath));
203            return result;
204        } catch (IOException e) {
205            LOG.error(e.getLocalizedMessage(), e);
206            return null;
207        }
208    }
209
210    /**
211     * Gets the list of modules as file names.<p>
212     *
213     * @return the list of modules as file names
214     */
215    public List<String> getModuleFileNames() {
216
217        List<String> result = Lists.newArrayList();
218        for (CmsModule module : OpenCms.getModuleManager().getAllInstalledModules()) {
219            result.add(getFileNameForModule(module));
220        }
221        return result;
222    }
223
224    /**
225     * Gets the object used to access the module log.<p>
226     *
227     * @return the module log
228     */
229    public CmsModuleLog getModuleLog() {
230
231        return m_moduleLog;
232    }
233
234    /**
235     * Imports module data.<p>
236     *
237     * @param name the module file name
238     * @param content the module ZIP file data
239     * @throws CmsException if something goes wrong
240     */
241    public synchronized void importModule(String name, byte[] content) throws CmsException {
242
243        String moduleName = null;
244        boolean ok = true;
245        try {
246            if (content.length == 0) {
247                // Happens when using CmsResourceWrapperModules with JLAN and createResource is called
248                LOG.debug("Zero-length module import content, ignoring it...");
249            } else {
250                ensureFoldersExist();
251                String targetFilePath = createImportZipPath(name);
252                try {
253                    FileOutputStream out = new FileOutputStream(new File(targetFilePath));
254                    out.write(content);
255                    out.close();
256                } catch (IOException e) {
257                    throw new CmsImportExportException(
258                        Messages.get().container(Messages.ERR_FILE_IO_1, targetFilePath));
259                }
260                CmsModule module = CmsModuleImportExportHandler.readModuleFromImport(targetFilePath);
261                moduleName = module.getName();
262                I_CmsReport report = createReport();
263                OpenCms.getModuleManager().replaceModule(m_adminCms, targetFilePath, report);
264                new File(targetFilePath).delete();
265                if (report.hasError() || report.hasWarning()) {
266                    ok = false;
267                }
268            }
269        } catch (CmsException e) {
270            ok = false;
271            throw e;
272        } catch (RuntimeException e) {
273            ok = false;
274            throw e;
275        } finally {
276            m_moduleLog.log(moduleName, Action.importModule, ok);
277        }
278    }
279
280    /**
281     * Initializes the CMS context.<p>
282     *
283     * @param adminCms the admin CMS context
284     */
285    public void initialize(CmsObject adminCms) {
286
287        m_adminCms = adminCms;
288    }
289
290    /**
291     * Computes a module hash, which should change when a module changes and stay the same when the module doesn't change.<p>
292     *
293     * We only use the modification time of the module resources and their descendants and the modification time of the metadata
294     * for computing it.
295     *
296     * @param module the module for which to compute the module signature
297     * @param project the project in which to compute the module hash
298     * @return the module signature
299     * @throws CmsException if something goes wrong
300     */
301    private String computeModuleHash(CmsModule module, CmsProject project) throws CmsException {
302
303        LOG.info("Getting module hash for " + module.getName());
304        // This method may be called very frequently during a short time, but it is unlikely
305        // that a module changes multiple times in a few seconds, so we use a timed cache here
306        String cachedValue = m_newModuleHashCache.get(module);
307        if (cachedValue != null) {
308            LOG.info("Using cached value for module hash of " + module.getName());
309            return cachedValue;
310        }
311
312        CmsObject cms = OpenCms.initCmsObject(m_adminCms);
313        if (!CmsStringUtil.isEmptyOrWhitespaceOnly(module.getSite())) {
314            cms.getRequestContext().setSiteRoot(module.getSite());
315        }
316        cms.getRequestContext().setCurrentProject(project);
317
318        // We compute a hash code from the paths of all resources belonging to the module and their respective modification dates.
319        List<String> entries = Lists.newArrayList();
320        for (String path : module.getResources()) {
321            try {
322                Set<CmsResource> resources = Sets.newHashSet();
323                CmsResource moduleRes = cms.readResource(path, CmsResourceFilter.IGNORE_EXPIRATION);
324                resources.add(moduleRes);
325                if (moduleRes.isFolder()) {
326                    resources.addAll(cms.readResources(path, CmsResourceFilter.IGNORE_EXPIRATION, true));
327                }
328                for (CmsResource res : resources) {
329                    entries.add(res.getRootPath() + ":" + res.getDateLastModified());
330                }
331            } catch (CmsVfsResourceNotFoundException e) {
332                entries.add(path + ":null");
333            }
334        }
335        Collections.sort(entries);
336        String inputString = CmsStringUtil.listAsString(entries, "\n") + "\nMETA:" + module.getObjectCreateTime();
337        LOG.debug("Computing module hash from base string:\n" + inputString);
338        return "" + inputString.hashCode();
339    }
340
341    /**
342     * Creates a randomized path for the temporary file used to store the import data.<p>
343     *
344     * @param name the module name
345     *
346     * @return the generated path
347     */
348    private String createImportZipPath(String name) {
349
350        String path = "";
351        do {
352            String prefix = RandomStringUtils.randomAlphanumeric(6) + "-";
353            path = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf(
354                CmsStringUtil.joinPaths(IMPORT_FOLDER_PATH, prefix + name));
355        } while (new File(path).exists());
356        return path;
357    }
358
359    /**
360     * Creates a new report for an export/import.<p>
361     *
362     * @return the new report
363     */
364    private I_CmsReport createReport() {
365
366        return new CmsLogReport(Locale.ENGLISH, CmsModuleImportExportRepository.class);
367    }
368
369    /**
370     * Makes sure that the folders used to store the import/export data exist.<p>
371     */
372    private void ensureFoldersExist() {
373
374        for (String path : Arrays.asList(IMPORT_FOLDER_PATH, EXPORT_FOLDER_PATH)) {
375            String folderPath = OpenCms.getSystemInfo().getAbsoluteRfsPathRelativeToWebInf(path);
376            File folder = new File(folderPath);
377            if (!folder.exists()) {
378                folder.mkdirs();
379            }
380        }
381    }
382
383    /**
384     * Gets the virtual file name to use for the given module.<p>
385     *
386     * @param module the module for which to get the file name
387     *
388     * @return the file name
389     */
390    private String getFileNameForModule(CmsModule module) {
391
392        return module.getName() + SUFFIX;
393    }
394
395    /**
396     * Gets the module which corresponds to the given virtual file name.<p>
397     *
398     * @param fileName the file name
399     *
400     * @return the module which corresponds to the given file name
401     */
402    private CmsModule getModuleForFileName(String fileName) {
403
404        String moduleName = fileName;
405        if (fileName.endsWith(SUFFIX)) {
406            moduleName = fileName.substring(0, fileName.length() - SUFFIX.length());
407        }
408        CmsModule result = OpenCms.getModuleManager().getModule(moduleName);
409        return result;
410    }
411
412    /**
413     * Checks if a given module needs to be re-exported.<p>
414     *
415     * @param module the module to check
416     * @param moduleFile the file representing the exported module (doesn't necessarily exist)
417     * @param project the project in which to check
418     *
419     * @return true if the module needs to be exported
420     */
421    private boolean needToExportModule(CmsModule module, File moduleFile, CmsProject project) {
422
423        if (!moduleFile.exists()) {
424            LOG.info("Module export file doesn't exist, export is needed.");
425            try {
426                String moduleSignature = computeModuleHash(module, project);
427                if (moduleSignature != null) {
428                    m_moduleHashCache.put(module, moduleSignature);
429                }
430            } catch (CmsException e) {
431                LOG.error(e.getLocalizedMessage(), e);
432            }
433            return true;
434        } else {
435            if (moduleFile.lastModified() < module.getObjectCreateTime()) {
436                return true;
437            }
438
439            String oldModuleSignature = m_moduleHashCache.get(module);
440            String newModuleSignature = null;
441            try {
442                newModuleSignature = computeModuleHash(module, project);
443            } catch (CmsException e) {
444                LOG.error(e.getLocalizedMessage(), e);
445            }
446
447            LOG.info(
448                "Comparing module hashes for "
449                    + module.getName()
450                    + " to check if export is needed: old = "
451                    + oldModuleSignature
452                    + ",  new="
453                    + newModuleSignature);
454            if ((newModuleSignature == null) || !Objects.equal(oldModuleSignature, newModuleSignature)) {
455                if (newModuleSignature != null) {
456                    m_moduleHashCache.put(module, newModuleSignature);
457                }
458                // if an error occurs or the module signatures don't match
459                return true;
460            } else {
461                return false;
462            }
463        }
464    }
465
466}