001/*
002 * (C) Copyright 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 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.blob;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.HashSet;
026import java.util.List;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.Set;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032
033import org.apache.commons.logging.Log;
034import org.apache.commons.logging.LogFactory;
035import org.nuxeo.ecm.core.api.Blob;
036import org.nuxeo.ecm.core.api.Blobs;
037import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
038import org.nuxeo.ecm.core.api.repository.RepositoryManager;
039import org.nuxeo.ecm.core.model.Document;
040import org.nuxeo.ecm.core.model.Document.BlobAccessor;
041import org.nuxeo.runtime.api.Framework;
042
043/**
044 * Default blob dispatcher, that uses the repository name as the blob provider.
045 * <p>
046 * Alternatively, it can be configured through properties to dispatch to a blob provider based on document properties
047 * instead of the repository name.
048 * <p>
049 * The property name is a list of comma-separated clauses, with each clause consisting of a property, an operator and a
050 * value. The property can be a {@link Document} xpath, {@code ecm:repositoryName}, or, to match the current blob being
051 * dispatched, {@code blob:name}, {@code blob:mime-type}, {@code blob:encoding}, {@code blob:digest},
052 * {@code blob:length} or {@code blob:xpath}. Comma-separated clauses are ANDed together. The special name
053 * {@code default} defines the default provider, and must be present.
054 * <p>
055 * Available operators between property and value are =, !=, &lt, > and ~. The operators &lt; and > work with integer
056 * values. The operator ~ does glob matching using {@code ?} to match a single arbitrary character, and {@code *} to
057 * match any number of characters (including none).
058 * <p>
059 * For example, to dispatch to the "first" provider if dc:format is "video", to the "second" provider if the blob's MIME
060 * type is "video/mp4", to the "third" provider if the blob is stored as a secondary attached file, to the "fourth"
061 * provider if the lifecycle state is "approved" and the document is in the default repository, and otherwise to the
062 * "other" provider:
063 *
064 * <pre>
065 * &lt;property name="dc:format=video">first&lt;/property>
066 * &lt;property name="blob:mime-type=video/mp4">second&lt;/property>
067 * &lt;property name="blob:xpath~files/*&#47;file">third&lt;/property>
068 * &lt;property name="ecm:repositoryName=default,ecm:lifeCycleState=approved">fourth&lt;/property>
069 * &lt;property name="default">other&lt;/property>
070 * </pre>
071 *
072 * @since 7.3
073 */
074public class DefaultBlobDispatcher implements BlobDispatcher {
075
076    private static final Log log = LogFactory.getLog(DefaultBlobDispatcher.class);
077
078    protected static final String NAME_DEFAULT = "default";
079
080    protected static final Pattern NAME_PATTERN = Pattern.compile("(.*)(=|!=|<|>|~)(.*)");
081
082    /** Pseudo-property for the repository name. */
083    protected static final String REPOSITORY_NAME = "ecm:repositoryName";
084
085    protected static final String BLOB_PREFIX = "blob:";
086
087    protected static final String BLOB_NAME = "name";
088
089    protected static final String BLOB_MIME_TYPE = "mime-type";
090
091    protected static final String BLOB_ENCODING = "encoding";
092
093    protected static final String BLOB_DIGEST = "digest";
094
095    protected static final String BLOB_LENGTH = "length";
096
097    protected static final String BLOB_XPATH = "xpath";
098
099    protected enum Op {
100        EQ, NEQ, LT, GT, GLOB;
101    }
102
103    protected static class Clause {
104        public final String xpath;
105
106        public final Op op;
107
108        public final Object value;
109
110        public Clause(String xpath, Op op, Object value) {
111            this.xpath = xpath;
112            this.op = op;
113            this.value = value;
114        }
115    }
116
117    protected static class Rule {
118        public final List<Clause> clauses;
119
120        public final String providerId;
121
122        public Rule(List<Clause> clauses, String providerId) {
123            this.clauses = clauses;
124            this.providerId = providerId;
125        }
126    }
127
128    // default to true when initialize is not called (default instance)
129    protected boolean useRepositoryName = true;
130
131    protected List<Rule> rules;
132
133    protected Set<String> rulesXPaths;
134
135    protected Set<String> providerIds;
136
137    protected List<String> repositoryNames;
138
139    protected String defaultProviderId;
140
141    @Override
142    public void initialize(Map<String, String> properties) {
143        providerIds = new HashSet<>();
144        rulesXPaths = new HashSet<>();
145        rules = new ArrayList<>();
146        for (Entry<String, String> en : properties.entrySet()) {
147            String clausesString = en.getKey();
148            String providerId = en.getValue();
149            providerIds.add(providerId);
150            if (clausesString.equals(NAME_DEFAULT)) {
151                defaultProviderId = providerId;
152            } else {
153                List<Clause> clauses = new ArrayList<Clause>(2);
154                for (String name : clausesString.split(",")) {
155                    Matcher m = NAME_PATTERN.matcher(name);
156                    if (m.matches()) {
157                        String xpath = m.group(1);
158                        String ops = m.group(2);
159                        Object value = m.group(3);
160                        Op op;
161                        switch (ops) {
162                        case "=":
163                            op = Op.EQ;
164                            break;
165                        case "!=":
166                            op = Op.NEQ;
167                            break;
168                        case "<":
169                            op = Op.LT;
170                            value = Long.valueOf((String) value);
171                            break;
172                        case ">":
173                            op = Op.GT;
174                            value = Long.valueOf((String) value);
175                            break;
176                        case "~":
177                            op = Op.GLOB;
178                            value = getPatternFromGlob((String) value);
179                            break;
180                        default:
181                            log.error("Invalid dispatcher configuration operator: " + ops);
182                            continue;
183                        }
184                        clauses.add(new Clause(xpath, op, value));
185                        rulesXPaths.add(xpath);
186                    } else {
187                        log.error("Invalid dispatcher configuration property name: " + name);
188                    }
189                    rules.add(new Rule(clauses, providerId));
190                }
191            }
192        }
193        useRepositoryName = providerIds.isEmpty();
194        if (!useRepositoryName && defaultProviderId == null) {
195            log.error("Invalid dispatcher configuration, missing default, configuration will be ignored");
196            useRepositoryName = true;
197        }
198    }
199
200    protected Pattern getPatternFromGlob(String glob) {
201        // this relies on the fact that Pattern.quote wraps everything between \Q and \E
202        // so we "open" the quoting to insert the corresponding regex for * and ?
203        String regex = Pattern.quote(glob).replace("?", "\\E.\\Q").replace("*", "\\E.*\\Q");
204        return Pattern.compile(regex);
205    }
206
207    @Override
208    public Collection<String> getBlobProviderIds() {
209        if (useRepositoryName) {
210            if (repositoryNames == null) {
211                repositoryNames = Framework.getService(RepositoryManager.class).getRepositoryNames();
212            }
213            return repositoryNames;
214        }
215        return providerIds;
216    }
217
218    protected String getProviderId(Document doc, Blob blob, String blobXPath) {
219        if (useRepositoryName) {
220            return doc.getRepositoryName();
221        }
222        for (Rule rule : rules) {
223            boolean allClausesMatch = true;
224            for (Clause clause : rule.clauses) {
225                String xpath = clause.xpath;
226                Object value;
227                if (xpath.equals(REPOSITORY_NAME)) {
228                    value = doc.getRepositoryName();
229                } else if (xpath.startsWith(BLOB_PREFIX)) {
230                    switch (xpath.substring(BLOB_PREFIX.length())) {
231                    case BLOB_NAME:
232                        value = blob.getFilename();
233                        break;
234                    case BLOB_MIME_TYPE:
235                        value = blob.getMimeType();
236                        break;
237                    case BLOB_ENCODING:
238                        value = blob.getEncoding();
239                        break;
240                    case BLOB_DIGEST:
241                        value = blob.getDigest();
242                        break;
243                    case BLOB_LENGTH:
244                        value = Long.valueOf(blob.getLength());
245                        break;
246                    case BLOB_XPATH:
247                        value = blobXPath;
248                        break;
249                    default:
250                        log.error("Invalid dispatcher configuration property name: " + xpath);
251                        continue;
252                    }
253                } else {
254                    try {
255                        value = doc.getValue(xpath);
256                    } catch (PropertyNotFoundException e) {
257                        try {
258                            value = doc.getPropertyValue(xpath);
259                        } catch (IllegalArgumentException e2) {
260                            continue;
261                        }
262                    }
263                }
264                boolean match;
265                switch (clause.op) {
266                case EQ:
267                    match = String.valueOf(value).equals(clause.value);
268                    break;
269                case NEQ:
270                    match = !String.valueOf(value).equals(clause.value);
271                    break;
272                case LT:
273                    if (value == null) {
274                        value = Long.valueOf(0);
275                    }
276                    match = ((Long) value).compareTo((Long) clause.value) < 0;
277                    break;
278                case GT:
279                    if (value == null) {
280                        value = Long.valueOf(0);
281                    }
282                    match = ((Long) value).compareTo((Long) clause.value) > 0;
283                    break;
284                case GLOB:
285                    match = ((Pattern) clause.value).matcher(String.valueOf(value)).matches();
286                    break;
287                default:
288                    throw new AssertionError("notreached");
289                }
290                allClausesMatch = allClausesMatch && match;
291                if (!allClausesMatch) {
292                    break;
293                }
294            }
295            if (allClausesMatch) {
296                return rule.providerId;
297            }
298        }
299        return defaultProviderId;
300    }
301
302    @Override
303    public String getBlobProvider(String repositoryName) {
304        if (useRepositoryName) {
305            return repositoryName;
306        }
307        // useful for legacy blobs created without prefix before dispatch was configured
308        return defaultProviderId;
309    }
310
311    @Override
312    public BlobDispatch getBlobProvider(Document doc, Blob blob, String xpath) {
313        if (useRepositoryName) {
314            String providerId = doc.getRepositoryName();
315            return new BlobDispatch(providerId, false);
316        }
317        String providerId = getProviderId(doc, blob, xpath);
318        return new BlobDispatch(providerId, true);
319    }
320
321    @Override
322    public void notifyChanges(Document doc, Set<String> xpaths) {
323        if (useRepositoryName) {
324            return;
325        }
326        for (String xpath : rulesXPaths) {
327            if (xpaths.contains(xpath)) {
328                doc.visitBlobs(accessor -> checkBlob(doc, accessor));
329                return;
330            }
331        }
332    }
333
334    protected void checkBlob(Document doc, BlobAccessor accessor) {
335        Blob blob = accessor.getBlob();
336        if (!(blob instanceof ManagedBlob)) {
337            return;
338        }
339        // compare current provider with expected
340        String expectedProviderId = getProviderId(doc, blob, accessor.getXPath());
341        if (((ManagedBlob) blob).getProviderId().equals(expectedProviderId)) {
342            return;
343        }
344        // re-write blob
345        // TODO add APIs so that blob providers can copy blobs efficiently from each other
346        Blob newBlob;
347        try (InputStream in = blob.getStream()) {
348            newBlob = Blobs.createBlob(in, blob.getMimeType(), blob.getEncoding());
349            newBlob.setFilename(blob.getFilename());
350            newBlob.setDigest(blob.getDigest());
351        } catch (IOException e) {
352            throw new RuntimeException(e);
353        }
354        accessor.setBlob(newBlob);
355    }
356
357}