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