/*
* (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.blob;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.ecm.core.api.Blob;
import org.nuxeo.ecm.core.api.Blobs;
import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
import org.nuxeo.ecm.core.api.repository.RepositoryManager;
import org.nuxeo.ecm.core.model.Document;
import org.nuxeo.ecm.core.model.Document.BlobAccessor;
import org.nuxeo.runtime.api.Framework;
/**
* Default blob dispatcher, that uses the repository name as the blob provider.
* <p>
* Alternatively, it can be configured through properties to dispatch to a blob provider based on document properties
* instead of the repository name.
* <p>
* The property name is a list of comma-separated clauses, with each clause consisting of a property, an operator and a
* value. The property can be a {@link Document} xpath, {@code ecm:repositoryName}, or, to match the current blob being
* dispatched, {@code blob:name}, {@code blob:mime-type}, {@code blob:encoding}, {@code blob:digest},
* {@code blob:length} or {@code blob:xpath}. Comma-separated clauses are ANDed together. The special name
* {@code default} defines the default provider, and must be present.
* <p>
* Available operators between property and value are =, !=, <, > and ~. The operators < and > work with integer
* values. The operator ~ does glob matching using {@code ?} to match a single arbitrary character, and {@code *} to
* match any number of characters (including none).
* <p>
* For example, to dispatch to the "first" provider if dc:format is "video", to the "second" provider if the blob's MIME
* type is "video/mp4", to the "third" provider if the blob is stored as a secondary attached file, to the "fourth"
* provider if the lifecycle state is "approved" and the document is in the default repository, and otherwise to the
* "other" provider:
*
* <pre>
* <property name="dc:format=video">first</property>
* <property name="blob:mime-type=video/mp4">second</property>
* <property name="blob:xpath~files/*/file">third</property>
* <property name="ecm:repositoryName=default,ecm:lifeCycleState=approved">fourth</property>
* <property name="default">other</property>
* </pre>
*
* @since 7.3
*/
public class DefaultBlobDispatcher implements BlobDispatcher {
private static final Log log = LogFactory.getLog(DefaultBlobDispatcher.class);
protected static final String NAME_DEFAULT = "default";
protected static final Pattern NAME_PATTERN = Pattern.compile("(.*)(=|!=|<|>|~)(.*)");
/** Pseudo-property for the repository name. */
protected static final String REPOSITORY_NAME = "ecm:repositoryName";
protected static final String BLOB_PREFIX = "blob:";
protected static final String BLOB_NAME = "name";
protected static final String BLOB_MIME_TYPE = "mime-type";
protected static final String BLOB_ENCODING = "encoding";
protected static final String BLOB_DIGEST = "digest";
protected static final String BLOB_LENGTH = "length";
protected static final String BLOB_XPATH = "xpath";
protected enum Op {
EQ, NEQ, LT, GT, GLOB;
}
protected static class Clause {
public final String xpath;
public final Op op;
public final Object value;
public Clause(String xpath, Op op, Object value) {
this.xpath = xpath;
this.op = op;
this.value = value;
}
}
protected static class Rule {
public final List<Clause> clauses;
public final String providerId;
public Rule(List<Clause> clauses, String providerId) {
this.clauses = clauses;
this.providerId = providerId;
}
}
// default to true when initialize is not called (default instance)
protected boolean useRepositoryName = true;
protected List<Rule> rules;
protected Set<String> rulesXPaths;
protected Set<String> providerIds;
protected List<String> repositoryNames;
protected String defaultProviderId;
@Override
public void initialize(Map<String, String> properties) {
providerIds = new HashSet<>();
rulesXPaths = new HashSet<>();
rules = new ArrayList<>();
for (Entry<String, String> en : properties.entrySet()) {
String clausesString = en.getKey();
String providerId = en.getValue();
providerIds.add(providerId);
if (clausesString.equals(NAME_DEFAULT)) {
defaultProviderId = providerId;
} else {
List<Clause> clauses = new ArrayList<Clause>(2);
for (String name : clausesString.split(",")) {
Matcher m = NAME_PATTERN.matcher(name);
if (m.matches()) {
String xpath = m.group(1);
String ops = m.group(2);
Object value = m.group(3);
Op op;
switch (ops) {
case "=":
op = Op.EQ;
break;
case "!=":
op = Op.NEQ;
break;
case "<":
op = Op.LT;
value = Long.valueOf((String) value);
break;
case ">":
op = Op.GT;
value = Long.valueOf((String) value);
break;
case "~":
op = Op.GLOB;
value = getPatternFromGlob((String) value);
break;
default:
log.error("Invalid dispatcher configuration operator: " + ops);
continue;
}
clauses.add(new Clause(xpath, op, value));
rulesXPaths.add(xpath);
} else {
log.error("Invalid dispatcher configuration property name: " + name);
}
rules.add(new Rule(clauses, providerId));
}
}
}
useRepositoryName = providerIds.isEmpty();
if (!useRepositoryName && defaultProviderId == null) {
log.error("Invalid dispatcher configuration, missing default, configuration will be ignored");
useRepositoryName = true;
}
}
protected Pattern getPatternFromGlob(String glob) {
// this relies on the fact that Pattern.quote wraps everything between \Q and \E
// so we "open" the quoting to insert the corresponding regex for * and ?
String regex = Pattern.quote(glob).replace("?", "\\E.\\Q").replace("*", "\\E.*\\Q");
return Pattern.compile(regex);
}
@Override
public Collection<String> getBlobProviderIds() {
if (useRepositoryName) {
if (repositoryNames == null) {
repositoryNames = Framework.getService(RepositoryManager.class).getRepositoryNames();
}
return repositoryNames;
}
return providerIds;
}
protected String getProviderId(Document doc, Blob blob, String blobXPath) {
if (useRepositoryName) {
return doc.getRepositoryName();
}
for (Rule rule : rules) {
boolean allClausesMatch = true;
for (Clause clause : rule.clauses) {
String xpath = clause.xpath;
Object value;
if (xpath.equals(REPOSITORY_NAME)) {
value = doc.getRepositoryName();
} else if (xpath.startsWith(BLOB_PREFIX)) {
switch (xpath.substring(BLOB_PREFIX.length())) {
case BLOB_NAME:
value = blob.getFilename();
break;
case BLOB_MIME_TYPE:
value = blob.getMimeType();
break;
case BLOB_ENCODING:
value = blob.getEncoding();
break;
case BLOB_DIGEST:
value = blob.getDigest();
break;
case BLOB_LENGTH:
value = Long.valueOf(blob.getLength());
break;
case BLOB_XPATH:
value = blobXPath;
break;
default:
log.error("Invalid dispatcher configuration property name: " + xpath);
continue;
}
} else {
try {
value = doc.getValue(xpath);
} catch (PropertyNotFoundException e) {
try {
value = doc.getPropertyValue(xpath);
} catch (IllegalArgumentException e2) {
continue;
}
}
}
boolean match;
switch (clause.op) {
case EQ:
match = String.valueOf(value).equals(clause.value);
break;
case NEQ:
match = !String.valueOf(value).equals(clause.value);
break;
case LT:
if (value == null) {
value = Long.valueOf(0);
}
match = ((Long) value).compareTo((Long) clause.value) < 0;
break;
case GT:
if (value == null) {
value = Long.valueOf(0);
}
match = ((Long) value).compareTo((Long) clause.value) > 0;
break;
case GLOB:
match = ((Pattern) clause.value).matcher(String.valueOf(value)).matches();
break;
default:
throw new AssertionError("notreached");
}
allClausesMatch = allClausesMatch && match;
if (!allClausesMatch) {
break;
}
}
if (allClausesMatch) {
return rule.providerId;
}
}
return defaultProviderId;
}
@Override
public String getBlobProvider(String repositoryName) {
if (useRepositoryName) {
return repositoryName;
}
// useful for legacy blobs created without prefix before dispatch was configured
return defaultProviderId;
}
@Override
public BlobDispatch getBlobProvider(Document doc, Blob blob, String xpath) {
if (useRepositoryName) {
String providerId = doc.getRepositoryName();
return new BlobDispatch(providerId, false);
}
String providerId = getProviderId(doc, blob, xpath);
return new BlobDispatch(providerId, true);
}
@Override
public void notifyChanges(Document doc, Set<String> xpaths) {
if (useRepositoryName) {
return;
}
for (String xpath : rulesXPaths) {
if (xpaths.contains(xpath)) {
doc.visitBlobs(accessor -> checkBlob(doc, accessor));
return;
}
}
}
protected void checkBlob(Document doc, BlobAccessor accessor) {
Blob blob = accessor.getBlob();
if (!(blob instanceof ManagedBlob)) {
return;
}
// compare current provider with expected
String expectedProviderId = getProviderId(doc, blob, accessor.getXPath());
if (((ManagedBlob) blob).getProviderId().equals(expectedProviderId)) {
return;
}
// re-write blob
// TODO add APIs so that blob providers can copy blobs efficiently from each other
Blob newBlob;
try (InputStream in = blob.getStream()) {
newBlob = Blobs.createBlob(in, blob.getMimeType(), blob.getEncoding());
newBlob.setFilename(blob.getFilename());
newBlob.setDigest(blob.getDigest());
} catch (IOException e) {
throw new RuntimeException(e);
}
accessor.setBlob(newBlob);
}
}