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