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 =, !=, <, > and ~. The operators < 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 * <property name="dc:format=video">first</property> 066 * <property name="blob:mime-type=video/mp4">second</property> 067 * <property name="blob:xpath~files/*/file">third</property> 068 * <property name="ecm:repositoryName=default,ecm:lifeCycleState=approved">fourth</property> 069 * <property name="default">other</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 (PropertyNotFoundException e2) { 260 allClausesMatch = false; 261 break; 262 } 263 } 264 } 265 boolean match; 266 switch (clause.op) { 267 case EQ: 268 match = String.valueOf(value).equals(clause.value); 269 break; 270 case NEQ: 271 match = !String.valueOf(value).equals(clause.value); 272 break; 273 case LT: 274 if (value == null) { 275 value = Long.valueOf(0); 276 } 277 match = ((Long) value).compareTo((Long) clause.value) < 0; 278 break; 279 case GT: 280 if (value == null) { 281 value = Long.valueOf(0); 282 } 283 match = ((Long) value).compareTo((Long) clause.value) > 0; 284 break; 285 case GLOB: 286 match = ((Pattern) clause.value).matcher(String.valueOf(value)).matches(); 287 break; 288 default: 289 throw new AssertionError("notreached"); 290 } 291 allClausesMatch = allClausesMatch && match; 292 if (!allClausesMatch) { 293 break; 294 } 295 } 296 if (allClausesMatch) { 297 return rule.providerId; 298 } 299 } 300 return defaultProviderId; 301 } 302 303 @Override 304 public String getBlobProvider(String repositoryName) { 305 if (useRepositoryName) { 306 return repositoryName; 307 } 308 // useful for legacy blobs created without prefix before dispatch was configured 309 return defaultProviderId; 310 } 311 312 @Override 313 public BlobDispatch getBlobProvider(Document doc, Blob blob, String xpath) { 314 if (useRepositoryName) { 315 String providerId = doc.getRepositoryName(); 316 return new BlobDispatch(providerId, false); 317 } 318 String providerId = getProviderId(doc, blob, xpath); 319 return new BlobDispatch(providerId, true); 320 } 321 322 @Override 323 public void notifyChanges(Document doc, Set<String> xpaths) { 324 if (useRepositoryName) { 325 return; 326 } 327 for (String xpath : rulesXPaths) { 328 if (xpaths.contains(xpath)) { 329 doc.visitBlobs(accessor -> checkBlob(doc, accessor)); 330 return; 331 } 332 } 333 } 334 335 protected void checkBlob(Document doc, BlobAccessor accessor) { 336 Blob blob = accessor.getBlob(); 337 if (!(blob instanceof ManagedBlob)) { 338 return; 339 } 340 // compare current provider with expected 341 String expectedProviderId = getProviderId(doc, blob, accessor.getXPath()); 342 if (((ManagedBlob) blob).getProviderId().equals(expectedProviderId)) { 343 return; 344 } 345 // re-write blob 346 // TODO add APIs so that blob providers can copy blobs efficiently from each other 347 Blob newBlob; 348 try (InputStream in = blob.getStream()) { 349 newBlob = Blobs.createBlob(in, blob.getMimeType(), blob.getEncoding()); 350 newBlob.setFilename(blob.getFilename()); 351 newBlob.setDigest(blob.getDigest()); 352 } catch (IOException e) { 353 throw new RuntimeException(e); 354 } 355 accessor.setBlob(newBlob); 356 } 357 358}