001/*
002 * (C) Copyright 2014-2015 Nuxeo SA (http://nuxeo.com/) and contributors.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the GNU Lesser General Public License
006 * (LGPL) version 2.1 which accompanies this distribution, and is available at
007 * http://www.gnu.org/licenses/lgpl-2.1.html
008 *
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
012 * Lesser General Public License for more details.
013 *
014 * Contributors:
015 *     vpasquier <vpasquier@nuxeo.com>
016 */
017package org.nuxeo.binary.metadata.internals;
018
019import java.io.File;
020import java.io.IOException;
021import java.io.InputStream;
022import java.nio.file.Files;
023import java.nio.file.StandardCopyOption;
024import java.text.ParseException;
025import java.text.SimpleDateFormat;
026import java.util.Date;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.regex.Pattern;
031
032import org.apache.commons.io.FilenameUtils;
033import org.apache.commons.lang.StringUtils;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.codehaus.jackson.map.ObjectMapper;
037import org.codehaus.jackson.type.TypeReference;
038import org.nuxeo.binary.metadata.api.BinaryMetadataConstants;
039import org.nuxeo.binary.metadata.api.BinaryMetadataException;
040import org.nuxeo.binary.metadata.api.BinaryMetadataProcessor;
041import org.nuxeo.ecm.core.api.Blob;
042import org.nuxeo.ecm.core.api.CloseableFile;
043import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
044import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
045import org.nuxeo.ecm.platform.commandline.executor.api.CommandAvailability;
046import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
047import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
048import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
049import org.nuxeo.runtime.api.Framework;
050
051/**
052 * @since 7.1
053 */
054public class ExifToolProcessor implements BinaryMetadataProcessor {
055
056    private static final Log log = LogFactory.getLog(ExifToolProcessor.class);
057
058    private static final String META_NON_USED_SOURCE_FILE = "SourceFile";
059
060    private static final String DATE_FORMAT_PATTERN = "yyyy:MM:dd HH:mm:ss";
061
062    private static final String EXIF_IMAGE_DATE_TIME = "EXIF:DateTime";
063
064    private static final String EXIF_PHOTO_DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal";
065
066    private static final String EXIF_PHOTO_DATE_TIME_DIGITIZED = "EXIF:DateTimeDigitized";
067
068    protected final ObjectMapper jacksonMapper;
069
070    protected final CommandLineExecutorService commandLineService;
071
072    public ExifToolProcessor() {
073        jacksonMapper = new ObjectMapper();
074        commandLineService = Framework.getLocalService(CommandLineExecutorService.class);
075    }
076
077    @Override
078    public Blob writeMetadata(Blob blob, Map<String, Object> metadata, boolean ignorePrefix) {
079        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_WRITE_NOPREFIX
080                : BinaryMetadataConstants.EXIFTOOL_WRITE;
081        CommandAvailability ca = commandLineService.getCommandAvailability(command);
082        if (!ca.isAvailable()) {
083            throw new BinaryMetadataException("Command '" + command + "' is not available.");
084        }
085        if (blob == null) {
086            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
087        }
088        try {
089            Blob newBlob = getTemporaryBlob(blob);
090            CmdParameters params = commandLineService.getDefaultCmdParameters();
091            params.addNamedParameter("inFilePath", newBlob.getFile(), true);
092            params.addNamedParameter("tagList", getCommandTags(metadata), false);
093            ExecResult er = commandLineService.execCommand(command, params);
094            boolean success = er.isSuccessful();
095            if (!success) {
096                log.error("There was an error executing " + "the following command: " + er.getCommandLine() + ". \n"
097                        + er.getOutput().get(0));
098                return null;
099            }
100            newBlob.setMimeType(blob.getMimeType());
101            newBlob.setEncoding(blob.getEncoding());
102            newBlob.setFilename(blob.getFilename());
103            return newBlob;
104        } catch (CommandNotAvailable commandNotAvailable) {
105            throw new BinaryMetadataException("Command '" + command + "' is not available.", commandNotAvailable);
106        } catch (IOException ioException) {
107            throw new BinaryMetadataException(ioException);
108        }
109    }
110
111    protected Map<String, Object> readMetadata(String command, Blob blob, List<String> metadata, boolean ignorePrefix) {
112        CommandAvailability ca = commandLineService.getCommandAvailability(command);
113        if (!ca.isAvailable()) {
114            throw new BinaryMetadataException("Command '" + command + "' is not available.");
115        }
116        if (blob == null) {
117            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
118        }
119        try {
120            ExecResult er;
121            try (CloseableFile source = getTemporaryFile(blob)) {
122                CmdParameters params = commandLineService.getDefaultCmdParameters();
123                params.addNamedParameter("inFilePath", source.getFile(), true);
124                if (metadata != null) {
125                    params.addNamedParameter("tagList", getCommandTags(metadata), false);
126                }
127                er = commandLineService.execCommand(command, params);
128            }
129            return returnResultMap(er);
130        } catch (CommandNotAvailable commandNotAvailable) {
131            throw new RuntimeException("Command '" + command + "' is not available.", commandNotAvailable);
132        } catch (IOException ioException) {
133            throw new BinaryMetadataException(ioException);
134        }
135    }
136
137    @Override
138    public Map<String, Object> readMetadata(Blob blob, List<String> metadata, boolean ignorePrefix) {
139        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST_NOPREFIX
140                : BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST;
141        return readMetadata(command, blob, metadata, ignorePrefix);
142    }
143
144    @Override
145    public Map<String, Object> readMetadata(Blob blob, boolean ignorePrefix) {
146        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_NOPREFIX
147                : BinaryMetadataConstants.EXIFTOOL_READ;
148        return readMetadata(command, blob, null, ignorePrefix);
149    }
150
151    /*--------------------------- Utils ------------------------*/
152
153    protected Map<String, Object> returnResultMap(ExecResult er) throws IOException {
154        if (!er.isSuccessful()) {
155            throw new BinaryMetadataException("There was an error executing " + "the following command: "
156                    + er.getCommandLine(), er.getError());
157        }
158        StringBuilder sb = new StringBuilder();
159        for (String line : er.getOutput()) {
160            sb.append(line);
161        }
162        String jsonOutput = sb.toString();
163        List<Map<String, Object>> resultList = jacksonMapper.readValue(jsonOutput,
164                new TypeReference<List<HashMap<String, Object>>>() {
165                });
166        Map<String, Object> resultMap = resultList.get(0);
167        // Remove the SourceFile metadata injected automatically by ExifTool.
168        resultMap.remove(META_NON_USED_SOURCE_FILE);
169        parseDates(resultMap);
170        return resultMap;
171    }
172
173    /**
174     * @since 7.4
175     */
176    protected void parseDates(Map<String, Object> resultMap) {
177        for (String prop : new String[] { EXIF_IMAGE_DATE_TIME, EXIF_PHOTO_DATE_TIME_ORIGINAL,
178                EXIF_PHOTO_DATE_TIME_DIGITIZED }) {
179            if (resultMap.containsKey(prop)) {
180                Object dateObject = resultMap.get(prop);
181                if (dateObject instanceof String) {
182                    SimpleDateFormat f = new SimpleDateFormat(DATE_FORMAT_PATTERN);
183                    try {
184                        Date date = f.parse((String) dateObject);
185                        resultMap.put(prop, date);
186                    } catch (ParseException e) {
187                        log.error("Could not parse property " + prop, e);
188                    }
189                }
190            }
191        }
192    }
193
194    protected String getCommandTags(List<String> metadataList) {
195        StringBuilder sb = new StringBuilder();
196        for (String metadata : metadataList) {
197            sb.append("-" + metadata + " ");
198        }
199        return sb.toString();
200    }
201
202    protected String getCommandTags(Map<String, Object> metadataMap) {
203        StringBuilder sb = new StringBuilder();
204        for (String metadata : metadataMap.keySet()) {
205            Object metadataValue = metadataMap.get(metadata);
206            if (metadataValue == null) {
207                metadataValue = StringUtils.EMPTY;
208            }
209            metadataValue = metadataValue.toString().replace(" ", "\\ ");
210            metadataValue = metadataValue.toString().replaceAll("'", "");
211            sb.append("-" + metadata + "=" + metadataValue + " ");
212        }
213        return sb.toString();
214    }
215
216    protected Pattern VALID_EXT = Pattern.compile("[a-zA-Z0-9]*");
217
218    /**
219     * We don't want to rely on {@link Blob#getCloseableFile} because it may return the original and we always want a
220     * temporary one to be sure we have a clean filename to pass.
221     *
222     * @since 7.4
223     */
224    protected CloseableFile getTemporaryFile(Blob blob) throws IOException {
225        String ext = FilenameUtils.getExtension(blob.getFilename());
226        if (!VALID_EXT.matcher(ext).matches()) {
227            ext = "tmp";
228        }
229        File tmp = File.createTempFile("nxblob-", '.' + ext);
230        File file = blob.getFile();
231        if (file == null) {
232            // if we don't have an underlying File, use a temporary File
233            try (InputStream in = blob.getStream()) {
234                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
235            }
236        } else {
237            // attempt to create a symbolic link, which would be cheaper than a copy
238            tmp.delete();
239            try {
240                Files.createSymbolicLink(tmp.toPath(), file.toPath().toAbsolutePath());
241            } catch (IOException | UnsupportedOperationException e) {
242                // symbolic link not supported, do a copy instead
243                Files.copy(file.toPath(), tmp.toPath());
244            }
245        }
246        return new CloseableFile(tmp, true);
247    }
248
249    /**
250     * Gets a new blob on a temporary file which is a copy of the blob's.
251     *
252     * @since 7.4
253     */
254    protected Blob getTemporaryBlob(Blob blob) throws IOException {
255        String ext = FilenameUtils.getExtension(blob.getFilename());
256        if (!VALID_EXT.matcher(ext).matches()) {
257            ext = "tmp";
258        }
259        Blob newBlob = new FileBlob('.' + ext);
260        File tmp = newBlob.getFile();
261        File file = blob.getFile();
262        if (file == null) {
263            try (InputStream in = blob.getStream()) {
264                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
265            }
266        } else {
267            // do a copy
268            Files.copy(file.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
269        }
270        return newBlob;
271    }
272
273}