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} or
052 * {@code blob:length}. Comma-separated clauses are ANDed together. The special name {@code default} defines the default
053 * provider, and must be present.
054 * <p>
055 * Available operators between property and value are =, !=, &lt, and >.
056 * <p>
057 * For example, to dispatch to the "first" provider if dc:format is "video", to the "second" provider if the blob's MIME
058 * type is "video/mp4", to the "third" provider if the lifecycle state is "approved" and the document is in the default
059 * repository, and otherwise to the "fourth" provider:
060 *
061 * <pre>
062 * &lt;property name="dc:format=video">first&lt;/property>
063 * &lt;property name="blob:mime-type=video/mp4">second&lt;/property>
064 * &lt;property name="ecm:repositoryName=default,ecm:lifeCycleState=approved">third2&lt;/property>
065 * &lt;property name="default">fourth&lt;/property>
066 * </pre>
067 *
068 * @since 7.3
069 */
070public class DefaultBlobDispatcher implements BlobDispatcher {
071
072    private static final Log log = LogFactory.getLog(DefaultBlobDispatcher.class);
073
074    protected static final String NAME_DEFAULT = "default";
075
076    protected static final Pattern NAME_PATTERN = Pattern.compile("(.*)(=|!=|<|>)(.*)");
077
078    /** Pseudo-property for the repository name. */
079    protected static final String REPOSITORY_NAME = "ecm:repositoryName";
080
081    protected static final String BLOB_PREFIX = "blob:";
082
083    protected static final String BLOB_NAME = "name";
084
085    protected static final String BLOB_MIME_TYPE = "mime-type";
086
087    protected static final String BLOB_ENCODING = "encoding";
088
089    protected static final String BLOB_DIGEST = "digest";
090
091    protected static final String BLOB_LENGTH = "length";
092
093    protected enum Op {
094        EQ, NEQ, LT, GT;
095    }
096
097    protected static class Clause {
098        public final String xpath;
099
100        public final Op op;
101
102        public final Object value;
103
104        public Clause(String xpath, Op op, Object value) {
105            this.xpath = xpath;
106            this.op = op;
107            this.value = value;
108        }
109    }
110
111    protected static class Rule {
112        public final List<Clause> clauses;
113
114        public final String providerId;
115
116        public Rule(List<Clause> clauses, String providerId) {
117            this.clauses = clauses;
118            this.providerId = providerId;
119        }
120    }
121
122    // default to true when initialize is not called (default instance)
123    protected boolean useRepositoryName = true;
124
125    protected List<Rule> rules;
126
127    protected Set<String> rulesXPaths;
128
129    protected Set<String> providerIds;
130
131    protected String defaultProviderId;
132
133    @Override
134    public void initialize(Map<String, String> properties) {
135        providerIds = new HashSet<>();
136        rulesXPaths = new HashSet<>();
137        rules = new ArrayList<>();
138        for (Entry<String, String> en : properties.entrySet()) {
139            String clausesString = en.getKey();
140            String providerId = en.getValue();
141            providerIds.add(providerId);
142            if (clausesString.equals(NAME_DEFAULT)) {
143                defaultProviderId = providerId;
144            } else {
145                List<Clause> clauses = new ArrayList<Clause>(2);
146                for (String name : clausesString.split(",")) {
147                    Matcher m = NAME_PATTERN.matcher(name);
148                    if (m.matches()) {
149                        String xpath = m.group(1);
150                        String ops = m.group(2);
151                        Object value = m.group(3);
152                        Op op;
153                        switch (ops) {
154                        case "=":
155                            op = Op.EQ;
156                            break;
157                        case "!=":
158                            op = Op.NEQ;
159                            break;
160                        case "<":
161                            op = Op.LT;
162                            value = Long.valueOf((String) value);
163                            break;
164                        case ">":
165                            op = Op.GT;
166                            value = Long.valueOf((String) value);
167                            break;
168                        default:
169                            log.error("Invalid dispatcher configuration operator: " + ops);
170                            continue;
171                        }
172                        clauses.add(new Clause(xpath, op, value));
173                        rulesXPaths.add(xpath);
174                    } else {
175                        log.error("Invalid dispatcher configuration property name: " + name);
176                    }
177                    rules.add(new Rule(clauses, providerId));
178                }
179            }
180        }
181        useRepositoryName = providerIds.isEmpty();
182        if (!useRepositoryName && defaultProviderId == null) {
183            log.error("Invalid dispatcher configuration, missing default, configuration will be ignored");
184            useRepositoryName = true;
185        }
186    }
187
188    @Override
189    public Collection<String> getBlobProviderIds() {
190        if (useRepositoryName) {
191            return Framework.getService(RepositoryManager.class).getRepositoryNames();
192        }
193        return providerIds;
194    }
195
196    protected String getProviderId(Document doc, Blob blob) {
197        if (useRepositoryName) {
198            return doc.getRepositoryName();
199        }
200        for (Rule rule : rules) {
201            boolean allClausesMatch = true;
202            for (Clause clause : rule.clauses) {
203                String xpath = clause.xpath;
204                Object value;
205                if (xpath.equals(REPOSITORY_NAME)) {
206                    value = doc.getRepositoryName();
207                } else if (xpath.startsWith(BLOB_PREFIX)) {
208                    switch (xpath.substring(BLOB_PREFIX.length())) {
209                    case BLOB_NAME:
210                        value = blob.getFilename();
211                        break;
212                    case BLOB_MIME_TYPE:
213                        value = blob.getMimeType();
214                        break;
215                    case BLOB_ENCODING:
216                        value = blob.getEncoding();
217                        break;
218                    case BLOB_DIGEST:
219                        value = blob.getDigest();
220                        break;
221                    case BLOB_LENGTH:
222                        value = Long.valueOf(blob.getLength());
223                        break;
224                    default:
225                        log.error("Invalid dispatcher configuration property name: " + xpath);
226                        continue;
227                    }
228                } else {
229                    try {
230                        value = doc.getValue(xpath);
231                    } catch (PropertyNotFoundException e) {
232                        try {
233                            value = doc.getPropertyValue(xpath);
234                        } catch (IllegalArgumentException e2) {
235                            continue;
236                        }
237                    }
238                }
239                boolean match;
240                switch (clause.op) {
241                case EQ:
242                    match = String.valueOf(value).equals(clause.value);
243                    break;
244                case NEQ:
245                    match = !String.valueOf(value).equals(clause.value);
246                    break;
247                case LT:
248                    if (value == null) {
249                        value = Long.valueOf(0);
250                    }
251                    match = ((Long) value).compareTo((Long) clause.value) < 0;
252                    break;
253                case GT:
254                    if (value == null) {
255                        value = Long.valueOf(0);
256                    }
257                    match = ((Long) value).compareTo((Long) clause.value) > 0;
258                    break;
259                default:
260                    throw new AssertionError("notreached");
261                }
262                allClausesMatch = allClausesMatch && match;
263                if (!allClausesMatch) {
264                    break;
265                }
266            }
267            if (allClausesMatch) {
268                return rule.providerId;
269            }
270        }
271        return defaultProviderId;
272    }
273
274    @Override
275    public String getBlobProvider(String repositoryName) {
276        if (useRepositoryName) {
277            return repositoryName;
278        }
279        // useful for legacy blobs created without prefix before dispatch was configured
280        return defaultProviderId;
281    }
282
283    @Override
284    public BlobDispatch getBlobProvider(Document doc, Blob blob) {
285        if (useRepositoryName) {
286            String providerId = doc.getRepositoryName();
287            return new BlobDispatch(providerId, false);
288        }
289        String providerId = getProviderId(doc, blob);
290        return new BlobDispatch(providerId, true);
291    }
292
293    @Override
294    public void notifyChanges(Document doc, Set<String> xpaths) {
295        if (useRepositoryName) {
296            return;
297        }
298        for (String xpath : rulesXPaths) {
299            if (xpaths.contains(xpath)) {
300                doc.visitBlobs(accessor -> checkBlob(doc, accessor));
301                return;
302            }
303        }
304    }
305
306    protected void checkBlob(Document doc, BlobAccessor accessor) {
307        Blob blob = accessor.getBlob();
308        if (!(blob instanceof ManagedBlob)) {
309            return;
310        }
311        // compare current provider with expected
312        String expectedProviderId = getProviderId(doc, blob);
313        if (((ManagedBlob) blob).getProviderId().equals(expectedProviderId)) {
314            return;
315        }
316        // re-write blob
317        // TODO add APIs so that blob providers can copy blobs efficiently from each other
318        Blob newBlob;
319        try (InputStream in = blob.getStream()) {
320            newBlob = Blobs.createBlob(in, blob.getMimeType(), blob.getEncoding());
321            newBlob.setFilename(blob.getFilename());
322            newBlob.setDigest(blob.getDigest());
323        } catch (IOException e) {
324            throw new RuntimeException(e);
325        }
326        accessor.setBlob(newBlob);
327    }
328
329}