/* * (C) Copyright 2006-2016 Nuxeo SA (http://nuxeo.com/) and others. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Contributors: * Tiry * Florent Guillaume * Estelle Giuly <egiuly@nuxeo.com> */ package org.nuxeo.ecm.core.convert.service; import java.io.File; import java.io.IOException; import java.io.Serializable; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.ws.rs.core.MediaType; import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.common.utils.FileUtils; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.blobholder.BlobHolder; import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder; import org.nuxeo.ecm.core.api.impl.blob.StringBlob; import org.nuxeo.ecm.core.convert.api.ConversionException; import org.nuxeo.ecm.core.convert.api.ConversionService; import org.nuxeo.ecm.core.convert.api.ConversionStatus; import org.nuxeo.ecm.core.convert.api.ConverterCheckResult; import org.nuxeo.ecm.core.convert.api.ConverterNotAvailable; import org.nuxeo.ecm.core.convert.api.ConverterNotRegistered; import org.nuxeo.ecm.core.convert.cache.CacheKeyGenerator; import org.nuxeo.ecm.core.convert.cache.ConversionCacheHolder; import org.nuxeo.ecm.core.convert.cache.GCTask; import org.nuxeo.ecm.core.convert.extension.ChainedConverter; import org.nuxeo.ecm.core.convert.extension.Converter; import org.nuxeo.ecm.core.convert.extension.ConverterDescriptor; import org.nuxeo.ecm.core.convert.extension.ExternalConverter; import org.nuxeo.ecm.core.convert.extension.GlobalConfigDescriptor; import org.nuxeo.ecm.core.io.download.DownloadService; import org.nuxeo.ecm.core.transientstore.work.TransientStoreWork; import org.nuxeo.ecm.core.work.api.Work; import org.nuxeo.ecm.core.work.api.WorkManager; import org.nuxeo.ecm.platform.mimetype.interfaces.MimetypeEntry; import org.nuxeo.ecm.platform.mimetype.interfaces.MimetypeRegistry; import org.nuxeo.runtime.api.Framework; import org.nuxeo.runtime.model.ComponentContext; import org.nuxeo.runtime.model.ComponentInstance; import org.nuxeo.runtime.model.DefaultComponent; /** * Runtime Component that also provides the POJO implementation of the {@link ConversionService}. */ public class ConversionServiceImpl extends DefaultComponent implements ConversionService { protected static final Log log = LogFactory.getLog(ConversionServiceImpl.class); public static final String CONVERTER_EP = "converter"; public static final String CONFIG_EP = "configuration"; protected final Map<String, ConverterDescriptor> converterDescriptors = new HashMap<>(); protected final MimeTypeTranslationHelper translationHelper = new MimeTypeTranslationHelper(); protected final GlobalConfigDescriptor config = new GlobalConfigDescriptor(); protected static ConversionServiceImpl self; protected Thread gcThread; protected GCTask gcTask; @Override public void activate(ComponentContext context) { converterDescriptors.clear(); translationHelper.clear(); self = this; config.clearCachingDirectory(); } @Override public void deactivate(ComponentContext context) { if (config.isCacheEnabled()) { ConversionCacheHolder.deleteCache(); } self = null; converterDescriptors.clear(); translationHelper.clear(); } /** * Component implementation. */ @Override public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { if (CONVERTER_EP.equals(extensionPoint)) { ConverterDescriptor desc = (ConverterDescriptor) contribution; registerConverter(desc); } else if (CONFIG_EP.equals(extensionPoint)) { GlobalConfigDescriptor desc = (GlobalConfigDescriptor) contribution; config.update(desc); config.clearCachingDirectory(); } else { log.error("Unable to handle unknown extensionPoint " + extensionPoint); } } @Override public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { } /* Component API */ public static Converter getConverter(String converterName) { ConverterDescriptor desc = self.converterDescriptors.get(converterName); if (desc == null) { return null; } return desc.getConverterInstance(); } public static ConverterDescriptor getConverterDescriptor(String converterName) { return self.converterDescriptors.get(converterName); } public static long getGCIntervalInMinutes() { return self.config.getGCInterval(); } public static void setGCIntervalInMinutes(long interval) { self.config.setGCInterval(interval); } public static void registerConverter(ConverterDescriptor desc) { if (self.converterDescriptors.containsKey(desc.getConverterName())) { ConverterDescriptor existing = self.converterDescriptors.get(desc.getConverterName()); desc = existing.merge(desc); } desc.initConverter(); self.translationHelper.addConverter(desc); self.converterDescriptors.put(desc.getConverterName(), desc); } public static int getMaxCacheSizeInKB() { return self.config.getDiskCacheSize(); } public static void setMaxCacheSizeInKB(int size) { self.config.setDiskCacheSize(size); } public static boolean isCacheEnabled() { return self.config.isCacheEnabled(); } public static String getCacheBasePath() { return self.config.getCachingDirectory(); } /* Service API */ @Override public List<String> getRegistredConverters() { List<String> converterNames = new ArrayList<>(); converterNames.addAll(converterDescriptors.keySet()); return converterNames; } @Override @Deprecated public Blob convertBlobToPDF(Blob blob) throws IOException { return convertThroughHTML(new SimpleBlobHolder(blob), MimetypeRegistry.PDF_MIMETYPE).getBlob(); } protected BlobHolder convertThroughHTML(BlobHolder blobHolder, String destMimeType) { Blob blob = blobHolder.getBlob(); String mimetype = blob.getMimeType(); String filename = blob.getFilename(); if (destMimeType.equals(mimetype)) { return blobHolder; } Path tempDirectory = null; // Convert the blob to HTML if (!MediaType.TEXT_HTML.equals(mimetype)) { blobHolder = convertBlobToMimeType(blobHolder, MediaType.TEXT_HTML); } try { tempDirectory = Framework.createTempDirectory("blobs"); // Replace the image URLs by absolute paths DownloadService downloadService = Framework.getService(DownloadService.class); blobHolder.setBlob( replaceURLsByAbsolutePaths(blob, tempDirectory, downloadService::resolveBlobFromDownloadUrl)); // Convert the blob to the destination mimetype blobHolder = convertBlobToMimeType(blobHolder, destMimeType); adjustBlobName(filename, blobHolder, destMimeType); } catch (IOException e) { throw new ConversionException(e); } finally { if (tempDirectory != null) { org.apache.commons.io.FileUtils.deleteQuietly(tempDirectory.toFile()); } } return blobHolder; } protected BlobHolder convertBlobToMimeType(BlobHolder bh, String destinationMimeType) { return convertToMimeType(destinationMimeType, bh, Collections.emptyMap()); } protected void adjustBlobName(String filename, BlobHolder blobHolder, String mimeType) { Blob blob = blobHolder.getBlob(); adjustBlobName(filename, blob, mimeType); blobHolder.setBlob(blob); } protected void adjustBlobName(String filename, Blob blob, String mimeType) { if (StringUtils.isBlank(filename)) { filename = "file_" + System.currentTimeMillis(); } else { filename = FilenameUtils.removeExtension(FilenameUtils.getName(filename)); } String extension = Framework.getService(MimetypeRegistry.class) .getExtensionsFromMimetypeName(mimeType) .stream() .findFirst() .orElse("bin"); blob.setFilename(filename + "." + extension); blob.setMimeType(mimeType); } /** * Replace the image URLs of an HTML blob by absolute local paths. * * @throws IOException * @since 9.1 */ protected static Blob replaceURLsByAbsolutePaths(Blob blob, Path tempDirectory, Function<String, Blob> blobResolver) throws IOException { String initialBlobContent = blob.getString(); // Find images links in the blob Pattern pattern = Pattern.compile("(src=([\"']))(.*?)(\\2)"); Matcher matcher = pattern.matcher(initialBlobContent); StringBuffer sb = new StringBuffer(); while (matcher.find()) { // Retrieve the image from the URL String url = matcher.group(3); Blob imageBlob = blobResolver.apply(url); if (imageBlob == null) { break; } // Export the image to a temporary directory in File System String safeFilename = FileUtils.getSafeFilename(imageBlob.getFilename()); File imageFile = tempDirectory.resolve(safeFilename).toFile(); imageBlob.transferTo(imageFile); // Replace the image URL by its absolute local path matcher.appendReplacement(sb, "$1" + Matcher.quoteReplacement(imageFile.toPath().toString()) + "$4"); } matcher.appendTail(sb); String blobContentWithAbsolutePaths = sb.toString(); if (blobContentWithAbsolutePaths.equals(initialBlobContent)) { return blob; } // Create a new blob with the new content Blob newBlob = new StringBlob(blobContentWithAbsolutePaths, blob.getMimeType(), blob.getEncoding()); newBlob.setFilename(blob.getFilename()); return newBlob; } @Override public BlobHolder convert(String converterName, BlobHolder blobHolder, Map<String, Serializable> parameters) throws ConversionException { // set parameters if null to avoid NPE in converters if (parameters == null) { parameters = new HashMap<>(); } // exist if not registered ConverterCheckResult check = isConverterAvailable(converterName); if (!check.isAvailable()) { // exist is not installed / configured throw new ConverterNotAvailable(converterName); } ConverterDescriptor desc = converterDescriptors.get(converterName); if (desc == null) { throw new ConversionException("Converter " + converterName + " can not be found"); } String cacheKey = CacheKeyGenerator.computeKey(converterName, blobHolder, parameters); BlobHolder result = ConversionCacheHolder.getFromCache(cacheKey); if (result == null) { Converter converter = desc.getConverterInstance(); result = converter.convert(blobHolder, parameters); if (config.isCacheEnabled()) { ConversionCacheHolder.addToCache(cacheKey, result); } } if (result != null) { updateResultBlobMimeType(result, desc); updateResultBlobFileName(blobHolder, result); } return result; } protected void updateResultBlobMimeType(BlobHolder resultBh, ConverterDescriptor desc) { Blob mainBlob = resultBh.getBlob(); if (mainBlob == null) { return; } String mimeType = mainBlob.getMimeType(); if (StringUtils.isBlank(mimeType) || mimeType.equals("application/octet-stream")) { mainBlob.setMimeType(desc.getDestinationMimeType()); } } protected void updateResultBlobFileName(BlobHolder srcBh, BlobHolder resultBh) { Blob mainBlob = resultBh.getBlob(); if (mainBlob == null) { return; } String filename = mainBlob.getFilename(); if (StringUtils.isBlank(filename) || filename.startsWith("nxblob-")) { Blob srcBlob = srcBh.getBlob(); if (srcBlob != null && StringUtils.isNotBlank(srcBlob.getFilename())) { String baseName = FilenameUtils.getBaseName(srcBlob.getFilename()); MimetypeRegistry mimetypeRegistry = Framework.getLocalService(MimetypeRegistry.class); MimetypeEntry mimeTypeEntry = mimetypeRegistry.getMimetypeEntryByMimeType(mainBlob.getMimeType()); List<String> extensions = mimeTypeEntry.getExtensions(); String extension; if (!extensions.isEmpty()) { extension = extensions.get(0); } else { extension = FilenameUtils.getExtension(filename); if (extension == null) { extension = "bin"; } } mainBlob.setFilename(baseName + "." + extension); } } } @Override public BlobHolder convertToMimeType(String destinationMimeType, BlobHolder blobHolder, Map<String, Serializable> parameters) throws ConversionException { String srcMimeType = blobHolder.getBlob().getMimeType(); String converterName = translationHelper.getConverterName(srcMimeType, destinationMimeType); if (converterName == null) { // Use a chain of 2 converters which will first try to go through HTML, // then HTML to the destination mimetype return convertThroughHTML(blobHolder, destinationMimeType); } else { return convert(converterName, blobHolder, parameters); } } @Override public List<String> getConverterNames(String sourceMimeType, String destinationMimeType) { return translationHelper.getConverterNames(sourceMimeType, destinationMimeType); } @Override public String getConverterName(String sourceMimeType, String destinationMimeType) { List<String> converterNames = getConverterNames(sourceMimeType, destinationMimeType); if (!converterNames.isEmpty()) { return converterNames.get(converterNames.size() - 1); } return null; } @Override public ConverterCheckResult isConverterAvailable(String converterName) throws ConversionException { return isConverterAvailable(converterName, false); } protected final Map<String, ConverterCheckResult> checkResultCache = new HashMap<>(); @Override public ConverterCheckResult isConverterAvailable(String converterName, boolean refresh) throws ConverterNotRegistered { if (!refresh) { if (checkResultCache.containsKey(converterName)) { return checkResultCache.get(converterName); } } ConverterDescriptor descriptor = converterDescriptors.get(converterName); if (descriptor == null) { throw new ConverterNotRegistered(converterName); } Converter converter = descriptor.getConverterInstance(); ConverterCheckResult result; if (converter instanceof ExternalConverter) { ExternalConverter exConverter = (ExternalConverter) converter; result = exConverter.isConverterAvailable(); } else if (converter instanceof ChainedConverter) { ChainedConverter chainedConverter = (ChainedConverter) converter; result = new ConverterCheckResult(); if (chainedConverter.isSubConvertersBased()) { for (String subConverterName : chainedConverter.getSubConverters()) { result = isConverterAvailable(subConverterName, refresh); if (!result.isAvailable()) { break; } } } } else { // return success since there is nothing to test result = new ConverterCheckResult(); } result.setSupportedInputMimeTypes(descriptor.getSourceMimeTypes()); checkResultCache.put(converterName, result); return result; } @Override public boolean isSourceMimeTypeSupported(String converterName, String sourceMimeType) { return getConverterDescriptor(converterName).getSourceMimeTypes().contains(sourceMimeType); } @Override public String scheduleConversion(String converterName, BlobHolder blobHolder, Map<String, Serializable> parameters) { WorkManager workManager = Framework.getService(WorkManager.class); ConversionWork work = new ConversionWork(converterName, null, blobHolder, parameters); workManager.schedule(work); return work.getId(); } @Override public String scheduleConversionToMimeType(String destinationMimeType, BlobHolder blobHolder, Map<String, Serializable> parameters) { WorkManager workManager = Framework.getService(WorkManager.class); ConversionWork work = new ConversionWork(null, destinationMimeType, blobHolder, parameters); workManager.schedule(work); return work.getId(); } @Override public ConversionStatus getConversionStatus(String id) { WorkManager workManager = Framework.getService(WorkManager.class); Work.State workState = workManager.getWorkState(id); if (workState == null) { String entryKey = TransientStoreWork.computeEntryKey(id); if (TransientStoreWork.containsBlobHolder(entryKey)) { return new ConversionStatus(id, ConversionStatus.Status.COMPLETED); } return null; } return new ConversionStatus(id, ConversionStatus.Status.valueOf(workState.name())); } @Override public BlobHolder getConversionResult(String id, boolean cleanTransientStoreEntry) { String entryKey = TransientStoreWork.computeEntryKey(id); BlobHolder bh = TransientStoreWork.getBlobHolder(entryKey); if (cleanTransientStoreEntry) { TransientStoreWork.removeBlobHolder(entryKey); } return bh; } @Override public <T> T getAdapter(Class<T> adapter) { if (adapter.isAssignableFrom(MimeTypeTranslationHelper.class)) { return adapter.cast(translationHelper); } return super.getAdapter(adapter); } @Override public void applicationStarted(ComponentContext context) { startGC(); } @Override public void applicationStopped(ComponentContext context, Instant deadline) { endGC(); } protected void startGC() { log.debug("CasheCGTaskActivator activated starting GC thread"); gcTask = new GCTask(); gcThread = new Thread(gcTask, "Nuxeo-Convert-GC"); gcThread.setDaemon(true); gcThread.start(); log.debug("GC Thread started"); } public void endGC() { if (gcTask == null) { return; } log.debug("Stopping GC Thread"); gcTask.GCEnabled = false; gcTask = null; gcThread.interrupt(); gcThread = null; } }