001/*
002 * (C) Copyright 2014-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     vpasquier <vpasquier@nuxeo.com>
018 */
019package org.nuxeo.binary.metadata.internals;
020
021import java.io.File;
022import java.io.IOException;
023import java.io.InputStream;
024import java.nio.file.Files;
025import java.nio.file.StandardCopyOption;
026import java.text.ParseException;
027import java.text.SimpleDateFormat;
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.Calendar;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.Date;
034import java.util.HashMap;
035import java.util.List;
036import java.util.Map;
037import java.util.Objects;
038import java.util.regex.Pattern;
039import java.util.stream.Collectors;
040
041import org.apache.commons.io.FilenameUtils;
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.nuxeo.binary.metadata.api.BinaryMetadataConstants;
045import org.nuxeo.binary.metadata.api.BinaryMetadataException;
046import org.nuxeo.binary.metadata.api.BinaryMetadataProcessor;
047import org.nuxeo.ecm.core.api.Blob;
048import org.nuxeo.ecm.core.api.CloseableFile;
049import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
050import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
051import org.nuxeo.ecm.platform.commandline.executor.api.CommandAvailability;
052import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
053import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
054import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
055import org.nuxeo.runtime.api.Framework;
056
057import com.fasterxml.jackson.core.type.TypeReference;
058import com.fasterxml.jackson.databind.ObjectMapper;
059
060/**
061 * @since 7.1
062 */
063public class ExifToolProcessor implements BinaryMetadataProcessor {
064
065    private static final Log log = LogFactory.getLog(ExifToolProcessor.class);
066
067    private static final String META_NON_USED_SOURCE_FILE = "SourceFile";
068
069    private static final String DATE_FORMAT_PATTERN = "yyyy:MM:dd HH:mm:ss";
070
071    private static final String EXIF_IMAGE_DATE_TIME = "EXIF:DateTime";
072
073    private static final String EXIF_PHOTO_DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal";
074
075    private static final String EXIF_PHOTO_DATE_TIME_DIGITIZED = "EXIF:DateTimeDigitized";
076
077    protected final ObjectMapper jacksonMapper;
078
079    protected final CommandLineExecutorService commandLineService;
080
081    public ExifToolProcessor() {
082        jacksonMapper = new ObjectMapper();
083        commandLineService = Framework.getService(CommandLineExecutorService.class);
084    }
085
086    @Override
087    public Blob writeMetadata(Blob blob, Map<String, Object> metadata, boolean ignorePrefix) {
088        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_WRITE_NOPREFIX
089                : BinaryMetadataConstants.EXIFTOOL_WRITE;
090        CommandAvailability ca = commandLineService.getCommandAvailability(command);
091        if (!ca.isAvailable()) {
092            throw new BinaryMetadataException("Command '" + command + "' is not available.");
093        }
094        if (blob == null) {
095            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
096        }
097        try {
098            Blob newBlob = getTemporaryBlob(blob);
099            CmdParameters params = commandLineService.getDefaultCmdParameters();
100            params.addNamedParameter("inFilePath", newBlob.getFile());
101            params.addNamedParameter("tagList", getCommandTags(metadata));
102            ExecResult er = commandLineService.execCommand(command, params);
103            boolean success = er.isSuccessful();
104            if (!success) {
105                log.error("There was an error executing " + "the following command: " + er.getCommandLine() + ". \n"
106                        + er.getOutput());
107                return null;
108            }
109            newBlob.setMimeType(blob.getMimeType());
110            newBlob.setEncoding(blob.getEncoding());
111            newBlob.setFilename(blob.getFilename());
112            return newBlob;
113        } catch (CommandNotAvailable commandNotAvailable) {
114            throw new BinaryMetadataException("Command '" + command + "' is not available.", commandNotAvailable);
115        } catch (IOException ioException) {
116            throw new BinaryMetadataException(ioException);
117        }
118    }
119
120    protected Map<String, Object> readMetadata(String command, Blob blob, List<String> metadata, boolean ignorePrefix) {
121        CommandAvailability ca = commandLineService.getCommandAvailability(command);
122        if (!ca.isAvailable()) {
123            throw new BinaryMetadataException("Command '" + command + "' is not available.");
124        }
125        if (blob == null) {
126            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
127        }
128        try {
129            ExecResult er;
130            try (CloseableFile source = getTemporaryFile(blob)) {
131                CmdParameters params = commandLineService.getDefaultCmdParameters();
132                params.addNamedParameter("inFilePath", source.getFile());
133                if (metadata != null) {
134                    params.addNamedParameter("tagList", getCommandTags(metadata));
135                }
136                er = commandLineService.execCommand(command, params);
137            }
138            return returnResultMap(er);
139        } catch (CommandNotAvailable commandNotAvailable) {
140            throw new RuntimeException("Command '" + command + "' is not available.", commandNotAvailable);
141        } catch (IOException ioException) {
142            throw new BinaryMetadataException(ioException);
143        }
144    }
145
146    @Override
147    public Map<String, Object> readMetadata(Blob blob, List<String> metadata, boolean ignorePrefix) {
148        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST_NOPREFIX
149                : BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST;
150        return readMetadata(command, blob, metadata, ignorePrefix);
151    }
152
153    @Override
154    public Map<String, Object> readMetadata(Blob blob, boolean ignorePrefix) {
155        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_NOPREFIX
156                : BinaryMetadataConstants.EXIFTOOL_READ;
157        return readMetadata(command, blob, null, ignorePrefix);
158    }
159
160    /*--------------------------- Utils ------------------------*/
161
162    protected Map<String, Object> returnResultMap(ExecResult er) throws IOException {
163        if (!er.isSuccessful()) {
164            throw new BinaryMetadataException(
165                    "There was an error executing " + "the following command: " + er.getCommandLine(), er.getError());
166        }
167        StringBuilder sb = new StringBuilder();
168        for (String line : er.getOutput()) {
169            sb.append(line);
170        }
171        String jsonOutput = sb.toString();
172        List<Map<String, Object>> resultList = jacksonMapper.readValue(jsonOutput,
173                new TypeReference<List<Map<String, Object>>>() {
174                });
175        Map<String, Object> resultMap = resultList.get(0);
176        // Remove the SourceFile metadata injected automatically by ExifTool.
177        resultMap.remove(META_NON_USED_SOURCE_FILE);
178        parseDates(resultMap);
179        return resultMap;
180    }
181
182    /**
183     * @since 7.4
184     */
185    protected void parseDates(Map<String, Object> resultMap) {
186        for (String prop : new String[] { EXIF_IMAGE_DATE_TIME, EXIF_PHOTO_DATE_TIME_ORIGINAL,
187                EXIF_PHOTO_DATE_TIME_DIGITIZED }) {
188            if (resultMap.containsKey(prop)) {
189                Object dateObject = resultMap.get(prop);
190                if (dateObject instanceof String) {
191                    SimpleDateFormat f = new SimpleDateFormat(DATE_FORMAT_PATTERN);
192                    try {
193                        Date date = f.parse((String) dateObject);
194                        resultMap.put(prop, date);
195                    } catch (ParseException e) {
196                        log.error("Could not parse property " + prop, e);
197                    }
198                }
199            }
200        }
201    }
202
203    protected List<String> getCommandTags(List<String> metadataList) {
204        return metadataList.stream().map(tag -> "-" + tag).collect(Collectors.toList());
205    }
206
207    @SuppressWarnings("unchecked")
208    protected List<String> getCommandTags(Map<String, Object> metadataMap) {
209        List<String> commandTags = new ArrayList<>();
210        for (String tag : metadataMap.keySet()) {
211            Object metadataValue = metadataMap.get(tag);
212            if (metadataValue instanceof Collection) {
213                commandTags.addAll(buildCommandTagsFromCollection(tag, (Collection<Object>) metadataValue));
214            } else if (metadataValue instanceof Object[]) {
215                commandTags.addAll(buildCommandTagsFromCollection(tag, Arrays.asList((Object[]) metadataValue)));
216            } else if (metadataValue instanceof Calendar) {
217                commandTags.add(buildCommandTagFromDate(tag, ((Calendar) metadataValue).getTime()));
218            } else {
219                commandTags.add(buildCommandTag(tag, metadataValue));
220            }
221        }
222        return commandTags;
223    }
224
225    /**
226     * @since 8.3
227     */
228    private String buildCommandTag(String tag, Object value) {
229        return "-" + tag + "=" + Objects.toString(value);
230    }
231
232    /**
233     * @since 8.3
234     */
235    private List<String> buildCommandTagsFromCollection(String tag, Collection<Object> values) {
236        return values.isEmpty() ? Collections.singletonList("-" + tag + "=")
237                : values.stream().map(val -> buildCommandTag(tag, val)).collect(Collectors.toList());
238    }
239
240    /**
241     * @since 8.4
242     */
243    private String buildCommandTagFromDate(String tag, Date date) {
244        SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT_PATTERN);
245        return "-" + tag + "=" + formatter.format(date);
246    }
247
248    protected Pattern VALID_EXT = Pattern.compile("[a-zA-Z0-9]*");
249
250    /**
251     * We don't want to rely on {@link Blob#getCloseableFile} because it may return the original and we always want a
252     * temporary one to be sure we have a clean filename to pass.
253     *
254     * @since 7.4
255     */
256    protected CloseableFile getTemporaryFile(Blob blob) throws IOException {
257        String ext = FilenameUtils.getExtension(blob.getFilename());
258        if (!VALID_EXT.matcher(ext).matches()) {
259            ext = "tmp";
260        }
261        File tmp = Framework.createTempFile("nxblob-", '.' + ext);
262        File file = blob.getFile();
263        if (file == null) {
264            // if we don't have an underlying File, use a temporary File
265            try (InputStream in = blob.getStream()) {
266                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
267            }
268        } else {
269            // attempt to create a symbolic link, which would be cheaper than a copy
270            tmp.delete();
271            try {
272                Files.createSymbolicLink(tmp.toPath(), file.toPath().toAbsolutePath());
273            } catch (IOException | UnsupportedOperationException e) {
274                // symbolic link not supported, do a copy instead
275                Files.copy(file.toPath(), tmp.toPath());
276            }
277        }
278        return new CloseableFile(tmp, true);
279    }
280
281    /**
282     * Gets a new blob on a temporary file which is a copy of the blob's.
283     *
284     * @since 7.4
285     */
286    protected Blob getTemporaryBlob(Blob blob) throws IOException {
287        String ext = FilenameUtils.getExtension(blob.getFilename());
288        if (!VALID_EXT.matcher(ext).matches()) {
289            ext = "tmp";
290        }
291        Blob newBlob = new FileBlob('.' + ext);
292        File tmp = newBlob.getFile();
293        File file = blob.getFile();
294        if (file == null) {
295            try (InputStream in = blob.getStream()) {
296                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
297            }
298        } else {
299            // do a copy
300            Files.copy(file.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
301        }
302        return newBlob;
303    }
304
305}