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