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