001/* 002 * Copyright 2016 Anyware Services 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 */ 016package org.ametys.plugins.contentio.synchronize.impl; 017 018import java.time.LocalDate; 019import java.time.ZonedDateTime; 020import java.time.format.DateTimeFormatter; 021import java.time.format.DateTimeFormatterBuilder; 022import java.time.temporal.ChronoField; 023import java.util.ArrayList; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.LinkedHashMap; 027import java.util.List; 028import java.util.Map; 029import java.util.Optional; 030import java.util.Set; 031import java.util.function.Function; 032import java.util.stream.Collectors; 033import java.util.stream.Stream; 034 035import javax.naming.NamingEnumeration; 036import javax.naming.NamingException; 037import javax.naming.directory.Attribute; 038import javax.naming.directory.SearchControls; 039import javax.naming.directory.SearchResult; 040 041import org.apache.avalon.framework.component.Component; 042import org.apache.avalon.framework.service.ServiceException; 043import org.apache.avalon.framework.service.ServiceManager; 044import org.apache.cocoon.ProcessingException; 045import org.slf4j.Logger; 046 047import org.ametys.cms.contenttype.ContentType; 048import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 049import org.ametys.core.util.ldap.AbstractLDAPConnector; 050import org.ametys.core.util.ldap.IncompleteLDAPResultException; 051import org.ametys.core.util.ldap.ScopeEnumerator; 052import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollection; 053import org.ametys.runtime.model.type.ModelItemTypeConstants; 054 055/** 056 * Helper component for {@link SynchronizableContentsCollection}s which need to access a LDAP 057 */ 058public class LDAPCollectionHelper extends AbstractLDAPConnector implements Component 059{ 060 /** Avalon Role */ 061 public static final String ROLE = LDAPCollectionHelper.class.getName(); 062 063 private static final DateTimeFormatter __BASIC_DATE_TIME = new DateTimeFormatterBuilder() 064 .append(DateTimeFormatter.BASIC_ISO_DATE) 065 .appendValue(ChronoField.HOUR_OF_DAY, 2) 066 .appendValue(ChronoField.MINUTE_OF_HOUR, 2) 067 .appendValue(ChronoField.SECOND_OF_MINUTE, 2) 068 .appendFraction(ChronoField.MILLI_OF_SECOND, 1, 3, true) 069 .appendOffsetId() 070 .toFormatter(); 071 072 /** The content type extension point */ 073 protected ContentTypeExtensionPoint _contentTypeEP; 074 075 @Override 076 public void service(ServiceManager serviceManager) throws ServiceException 077 { 078 _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 079 super.service(serviceManager); 080 } 081 082 /** 083 * Search over the LDAP the data from the filter. 084 * @param collectionId The id of the collection being synchronized 085 * @param relativeDN the name of the context or object to search 086 * @param filter the filter expression to use for the search 087 * @param searchScope The search scope 088 * @param offset Begin of the search 089 * @param limit Number of results 090 * @param mapping The mapping for retrieving the remote values (keys are metadata paths) 091 * @param idKey The key where to search the id value of the content 092 * @param logger The logger 093 * @param dataSourceId the datasource id 094 * @return A map containing the content ids (keys) to import with their remote values (key is attribute, value is the remote value). 095 */ 096 public synchronized LDAPCollectionHelperSearchResult search(String collectionId, String relativeDN, String filter, String searchScope, int offset, int limit, Map<String, List<String>> mapping, String idKey, Logger logger, String dataSourceId) 097 { 098 Map<String, Map<String, Object>> searchResults = new LinkedHashMap<>(); 099 int nbErrors = 0; 100 boolean hasGlobalError = false; 101 102 try 103 { 104 _delayedInitialize(dataSourceId); 105 List<SearchResult> ldapSearchResults; 106 try 107 { 108 ldapSearchResults = _search(relativeDN, filter, null, _getSearchControls(mapping, searchScope), offset, limit, false); 109 } 110 catch (IncompleteLDAPResultException e) 111 { 112 ldapSearchResults = e.getPartialResults(); 113 logger.warn("LDAP refused to return more than " + ldapSearchResults.size() + " results"); 114 nbErrors++; 115 } 116 117 for (SearchResult searchResult : ldapSearchResults) 118 { 119 String idValue = (String) _getIdValue(idKey, searchResult, logger); 120 if (idValue == null) 121 { 122 nbErrors++; 123 logger.warn("The id value '{}' for '{}' was null ", idKey, searchResult.getName()); 124 } 125 else if (!searchResults.keySet().contains(idValue)) 126 { 127 try 128 { 129 Map<String, Object> values = new HashMap<>(); 130 NamingEnumeration<? extends Attribute> attributes = searchResult.getAttributes().getAll(); 131 while (attributes.hasMore()) 132 { 133 Attribute attribute = attributes.next(); 134 values.put(attribute.getID(), _getLDAPValues(attribute)); 135 } 136 searchResults.put(idValue, values); 137 } 138 catch (Exception e) 139 { 140 nbErrors++; 141 logger.warn("Failed to import the content '{}'", idValue, e); 142 } 143 } 144 else 145 { 146 logger.warn("Cannot import '{}' because its id value '{}={}' is already an id value for another content", searchResult.getName(), idKey, idValue); 147 } 148 } 149 } 150 catch (Exception e) 151 { 152 hasGlobalError = true; 153 nbErrors++; 154 logger.error("Failed to populate contents from synchronizable collection of id '{}'", collectionId, e); 155 } 156 157 return new LDAPCollectionHelperSearchResult(searchResults, hasGlobalError, nbErrors); 158 } 159 160 /** 161 * Results of {@link LDAPCollectionHelper#search(String, String, String, String, int, int, Map, String, Logger, String)} 162 * @param searchResults the search result 163 * @param hasGlobalError the number of errors which occurred during search 164 * @param nbErrors true if the a global error occurred during search 165 */ 166 public record LDAPCollectionHelperSearchResult(Map<String, Map<String, Object>> searchResults, boolean hasGlobalError, int nbErrors) { /* empty */ } 167 168 private List<Object> _getLDAPValues(Attribute attribute) throws NamingException 169 { 170 List<Object> ldapValues = new ArrayList<>(); 171 172 NamingEnumeration< ? > values = attribute.getAll(); 173 while (values.hasMore()) 174 { 175 ldapValues.add(values.next()); 176 } 177 178 return ldapValues; 179 } 180 181 /** 182 * Get the LDAP search controls. 183 * @param mapping The mapping 184 * @param searchScope The search scope 185 * @return the search controls. 186 * @throws ProcessingException if the scope is not valid 187 */ 188 protected SearchControls _getSearchControls(Map<String, List<String>> mapping, String searchScope) throws ProcessingException 189 { 190 SearchControls controls = new SearchControls(); 191 192 List<String> attributes = new ArrayList<>(); 193 for (List<String> attribute : mapping.values()) 194 { 195 attributes.addAll(attribute); 196 } 197 String[] attrArray = attributes.toArray(new String[attributes.size()]); 198 199 controls.setReturningAttributes(attrArray); 200 201 controls.setSearchScope(_getScope(searchScope)); 202 203 return controls; 204 } 205 206 /** 207 * Get the scope as an integer (handlable by the SearchControls) from the scope string. 208 * @param scopeStr the scope string. 209 * @return the scope as an integer. 210 * @throws ProcessingException if the given scope is not valid 211 */ 212 protected int _getScope(String scopeStr) throws ProcessingException 213 { 214 try 215 { 216 return ScopeEnumerator.parseScope(scopeStr); 217 } 218 catch (IllegalArgumentException e) 219 { 220 throw new ProcessingException("Unable to parse scope", e); 221 } 222 } 223 224 /** 225 * Gets id value from a ldap entry 226 * @param idKey The key where to search the id value 227 * @param entry The ldap entry 228 * @param logger The logger 229 * @return The attribute value 230 * @throws NamingException if a ldap query error occurred 231 */ 232 protected Object _getIdValue(String idKey, SearchResult entry, Logger logger) throws NamingException 233 { 234 Attribute ldapAttr = entry.getAttributes().get(idKey); 235 236 if (ldapAttr == null) 237 { 238 logger.warn("LDAP attribute not found: '{}'", idKey); 239 } 240 return ldapAttr != null ? ldapAttr.get() : null; 241 } 242 243 /** 244 * Transform date and datetime attributes on each result line from timestamp to LocalDate (date) or ZonedDateTime (datetime) 245 * @param results The results from LDAP source 246 * @param contentTypeId Content type ID from which attributes come from 247 * @param allAttributes All mapped attributes 248 */ 249 public void transformTypedAttributes(Map<String, Map<String, List<Object>>> results, String contentTypeId, Set<String> allAttributes) 250 { 251 // Define date and datetime attributes 252 Set<String> dateAttributes = new HashSet<>(); 253 Set<String> datetimeAttributes = new HashSet<>(); 254 255 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 256 for (String attributePath : allAttributes) 257 { 258 // Some synchronized attributes are not necessarily defined in the model 259 if (contentType.hasModelItem(attributePath)) 260 { 261 String attributeType = contentType.getModelItem(attributePath).getType().getId(); 262 switch (attributeType) 263 { 264 case ModelItemTypeConstants.DATE_TYPE_ID: 265 dateAttributes.add(attributePath); 266 break; 267 case ModelItemTypeConstants.DATETIME_TYPE_ID: 268 datetimeAttributes.add(attributePath); 269 break; 270 default: 271 // Nothing to do 272 break; 273 } 274 } 275 } 276 277 // Transform values to typed values 278 if (!dateAttributes.isEmpty() || !datetimeAttributes.isEmpty()) 279 { 280 for (Map<String, List<Object>> resultLine : results.values()) 281 { 282 _transformValuesAsTypedValues(resultLine, dateAttributes, s -> LocalDate.parse(s, __BASIC_DATE_TIME)); 283 _transformValuesAsTypedValues(resultLine, datetimeAttributes, s -> ZonedDateTime.parse(s, __BASIC_DATE_TIME)); 284 } 285 } 286 } 287 288 private <R> void _transformValuesAsTypedValues(Map<String, List<Object>> resultLine, Set<String> attributeNames, Function<String, R> typedFunction) 289 { 290 for (String attributeName : attributeNames) 291 { 292 _transformValueAsTypedValue(resultLine, attributeName, typedFunction); 293 } 294 } 295 296 private <R> void _transformValueAsTypedValue(Map<String, List<Object>> resultLine, String attributeName, Function<String, R> typedFunction) 297 { 298 List<Object> newValues = Optional.of(attributeName) 299 // Get the date attribute for the current result line 300 .map(resultLine::get) 301 // Stream the list 302 .map(List::stream) 303 .orElseGet(Stream::empty) 304 // Transform each element of the list to a String 305 .map(Object::toString) 306 // Transform it to LocalDate 307 .map(typedFunction) 308 // Collect 309 .collect(Collectors.toList()); 310 311 // If there are values, update the line 312 if (!newValues.isEmpty()) 313 { 314 resultLine.put(attributeName, newValues); 315 } 316 } 317}