001/*
002 * (C) Copyright 2017-2018 Nuxeo (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 *     Funsho David
018 *
019 */
020
021package org.nuxeo.directory.mongodb;
022
023import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_ID;
024import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_SEQ;
025
026import java.io.Serializable;
027import java.util.ArrayList;
028import java.util.Calendar;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.LinkedList;
032import java.util.List;
033import java.util.Map;
034import java.util.Set;
035import java.util.regex.Pattern;
036import java.util.stream.Collectors;
037
038import org.apache.commons.lang3.StringUtils;
039import org.bson.Document;
040import org.bson.conversions.Bson;
041import org.nuxeo.ecm.core.api.DocumentModel;
042import org.nuxeo.ecm.core.api.DocumentModelList;
043import org.nuxeo.ecm.core.api.PropertyException;
044import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
045import org.nuxeo.ecm.core.api.model.Property;
046import org.nuxeo.ecm.core.api.security.SecurityConstants;
047import org.nuxeo.ecm.core.schema.types.Field;
048import org.nuxeo.ecm.core.schema.types.Type;
049import org.nuxeo.ecm.core.schema.types.primitives.IntegerType;
050import org.nuxeo.ecm.core.schema.types.primitives.LongType;
051import org.nuxeo.ecm.core.schema.types.primitives.StringType;
052import org.nuxeo.ecm.directory.BaseSession;
053import org.nuxeo.ecm.directory.DirectoryException;
054import org.nuxeo.ecm.directory.PasswordHelper;
055import org.nuxeo.ecm.directory.Reference;
056import org.nuxeo.ecm.directory.Session;
057import org.nuxeo.runtime.api.Framework;
058import org.nuxeo.runtime.mongodb.MongoDBConnectionHelper;
059import org.nuxeo.runtime.mongodb.MongoDBConnectionService;
060
061import com.mongodb.MongoWriteException;
062import com.mongodb.client.FindIterable;
063import com.mongodb.client.MongoCollection;
064import com.mongodb.client.MongoDatabase;
065import com.mongodb.client.model.FindOneAndUpdateOptions;
066import com.mongodb.client.model.ReturnDocument;
067import com.mongodb.client.model.Updates;
068import com.mongodb.client.result.DeleteResult;
069import com.mongodb.client.result.UpdateResult;
070
071/**
072 * MongoDB implementation of a {@link Session}
073 *
074 * @since 9.1
075 */
076public class MongoDBSession extends BaseSession {
077
078    /**
079     * Prefix used to retrieve a MongoDB connection from {@link MongoDBConnectionService}.
080     * <p />
081     * The connection id will be {@code directory/[DIRECTORY_NAME]}.
082     */
083    public static final String DIRECTORY_CONNECTION_PREFIX = "directory/";
084
085    protected final MongoDatabase database;
086
087    protected String countersCollectionName;
088
089    public MongoDBSession(MongoDBDirectory directory) {
090        super(directory, MongoDBReference.class);
091        MongoDBConnectionService mongoService = Framework.getService(MongoDBConnectionService.class);
092        database = mongoService.getDatabase(DIRECTORY_CONNECTION_PREFIX + directory.getDescriptor().name);
093        countersCollectionName = directory.getCountersCollectionName();
094    }
095
096    @Override
097    public MongoDBDirectory getDirectory() {
098        return (MongoDBDirectory) directory;
099    }
100
101    @Override
102    protected DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) {
103        // Make a copy of fieldMap to avoid modifying it
104        fieldMap = new HashMap<>(fieldMap);
105
106        // Filter out reference fields for creation as we keep it in a different collection
107        Map<String, Object> newDocMap = fieldMap.entrySet()
108                                                .stream()
109                                                .filter(entry -> getDirectory().getReferences(entry.getKey()) == null)
110                                                .collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue()),
111                                                        HashMap::putAll);
112        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
113        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
114        String id;
115        if (autoincrementId) {
116            Document filter = MongoDBSerializationHelper.fieldMapToBson(MONGODB_ID, directoryName);
117            Bson update = Updates.inc(MONGODB_SEQ, 1L);
118            FindOneAndUpdateOptions options = new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER);
119            Long longId = getCollection(countersCollectionName).findOneAndUpdate(filter, update, options)
120                                                               .getLong(MONGODB_SEQ);
121            fieldMap.put(idFieldName, longId);
122            newDocMap.put(idFieldName, longId);
123            id = String.valueOf(longId);
124        } else {
125            id = String.valueOf(fieldMap.get(idFieldName));
126            if (hasEntry(id)) {
127                throw new DirectoryException(String.format("Entry with id %s already exists", id));
128            }
129        }
130        try {
131
132            for (Map.Entry<String, Field> entry : schemaFieldMap.entrySet()) {
133                Field field = entry.getValue();
134                if (field != null) {
135                    String fieldName = field.getName().getPrefixedName();
136                    Object value = newDocMap.get(fieldName);
137                    Type type = field.getType();
138                    if (value instanceof String) {
139                        String v = (String) value;
140                        if (type instanceof IntegerType) {
141                            newDocMap.put(fieldName, Integer.valueOf(v));
142                        } else if (type instanceof LongType) {
143                            newDocMap.put(fieldName, Long.valueOf(v));
144                        }
145                    } else if (value instanceof Number) {
146                        if (type instanceof LongType && value instanceof Integer) {
147                            newDocMap.put(fieldName, Long.valueOf((Integer) value));
148                        } else if (type instanceof StringType) {
149                            newDocMap.put(fieldName, value.toString());
150                        }
151                    }
152                    // Load default values if defined and not present in the map
153                    if (!newDocMap.containsKey(fieldName)) {
154                        Object defaultValue = field.getDefaultValue();
155                        if (defaultValue != null) {
156                            newDocMap.put(fieldName, defaultValue);
157                        }
158                    }
159                }
160            }
161            Document bson = MongoDBSerializationHelper.fieldMapToBson(newDocMap);
162            String password = (String) newDocMap.get(getPasswordField());
163            if (password != null && !PasswordHelper.isHashed(password)) {
164                password = PasswordHelper.hashPassword(password, passwordHashAlgorithm);
165                bson.append(getPasswordField(), password);
166            }
167            getCollection().insertOne(bson);
168        } catch (MongoWriteException e) {
169            throw new DirectoryException(e);
170        }
171        return createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
172    }
173
174    @Override
175    protected List<String> updateEntryWithoutReferences(DocumentModel docModel) throws DirectoryException {
176        Map<String, Object> fieldMap = new HashMap<>();
177        List<String> referenceFieldList = new LinkedList<>();
178        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
179        for (String fieldName : schemaFieldMap.keySet()) {
180            if (fieldName.equals(getIdField())) {
181                continue;
182            }
183            Property prop = docModel.getPropertyObject(schemaName, fieldName);
184            if (prop == null || !prop.isDirty()
185                    || (fieldName.equals(getPasswordField()) && StringUtils.isEmpty((String) prop.getValue()))) {
186                continue;
187            }
188            if (getDirectory().isReference(fieldName)) {
189                referenceFieldList.add(fieldName);
190            } else {
191                Serializable value = prop.getValue();
192                if (fieldName.equals(getPasswordField())) {
193                    value = PasswordHelper.hashPassword((String) value, passwordHashAlgorithm);
194                }
195                if (value instanceof Calendar) {
196                    value = ((Calendar) value).getTime();
197                }
198                fieldMap.put(prop.getName(), value);
199            }
200        }
201
202        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
203        String id = docModel.getId();
204        Document bson = MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id);
205
206        List<Bson> updates = fieldMap.entrySet().stream().map(e -> Updates.set(e.getKey(), e.getValue())).collect(
207                Collectors.toList());
208
209        if (!updates.isEmpty()) {
210            try {
211                UpdateResult result = getCollection().updateOne(bson, Updates.combine(updates));
212                // Throw an error if no document matched the update
213                if (!result.wasAcknowledged()) {
214                    throw new DirectoryException(
215                            "Error while updating the entry, the request has not been acknowledged by the server");
216                }
217                if (result.getMatchedCount() == 0) {
218                    throw new DirectoryException(
219                            String.format("Error while updating the entry, no document was found with the id %s", id));
220                }
221            } catch (MongoWriteException e) {
222                throw new DirectoryException(e);
223            }
224        }
225        return referenceFieldList;
226    }
227
228    @Override
229    public void deleteEntryWithoutReferences(String id) throws DirectoryException {
230        try {
231            String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
232            DeleteResult result = getCollection().deleteOne(
233                    MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id));
234            if (!result.wasAcknowledged()) {
235                throw new DirectoryException(
236                        "Error while deleting the entry, the request has not been acknowledged by the server");
237            }
238        } catch (MongoWriteException e) {
239            throw new DirectoryException(e);
240        }
241    }
242
243    @Override
244    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
245            boolean fetchReferences, int limit, int offset) throws DirectoryException {
246
247        if (!hasPermission(SecurityConstants.READ)) {
248            return new DocumentModelListImpl();
249        }
250
251        // Remove password as it is not possible to do queries with it
252        filter.remove(getPasswordField());
253        Document bson = buildQuery(filter, fulltext);
254
255        DocumentModelList entries = new DocumentModelListImpl();
256
257        FindIterable<Document> results = getCollection().find(bson).skip(offset);
258        if (limit > 0) {
259            results.limit(limit);
260        }
261        for (Document resultDoc : results) {
262
263            // Cast object to document model
264            Map<String, Object> fieldMap = MongoDBSerializationHelper.bsonToFieldMap(resultDoc);
265            // Remove password from results
266            if (!readAllColumns) {
267                fieldMap.remove(getPasswordField());
268            }
269            DocumentModel doc = fieldMapToDocumentModel(fieldMap);
270
271            if (fetchReferences) {
272                Map<String, List<String>> targetIdsMap = new HashMap<>();
273                for (Reference reference : directory.getReferences()) {
274                    List<String> targetIds;
275                    if (reference instanceof MongoDBReference) {
276                        MongoDBReference mongoReference = (MongoDBReference) reference;
277                        targetIds = mongoReference.getTargetIdsForSource(doc.getId(), this);
278                    } else {
279                        targetIds = reference.getTargetIdsForSource(doc.getId());
280                    }
281                    targetIds = new ArrayList<>(targetIds);
282                    Collections.sort(targetIds);
283                    String fieldName = reference.getFieldName();
284                    targetIdsMap.computeIfAbsent(fieldName, key -> new ArrayList<>()).addAll(targetIds);
285                }
286                for (Map.Entry<String, List<String>> entry : targetIdsMap.entrySet()) {
287                    String fieldName = entry.getKey();
288                    List<String> targetIds = entry.getValue();
289                    try {
290                        doc.setProperty(schemaName, fieldName, targetIds);
291                    } catch (PropertyException e) {
292                        throw new DirectoryException(e);
293                    }
294                }
295            }
296            entries.add(doc);
297        }
298
299        if (orderBy != null && !orderBy.isEmpty()) {
300            getDirectory().orderEntries(entries, orderBy);
301        }
302
303        return entries;
304    }
305
306    protected Document buildQuery(Map<String, Serializable> fieldMap, Set<String> fulltext) {
307        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
308        Document bson = new Document();
309        for (Map.Entry<String, Serializable> entry : fieldMap.entrySet()) {
310            Field field = schemaFieldMap.entrySet()
311                                        .stream()
312                                        .filter(e -> e.getValue().getName().getPrefixedName().equals(entry.getKey()))
313                                        .map(Map.Entry::getValue)
314                                        .findFirst()
315                                        .orElse(null);
316
317            Serializable v = entry.getValue();
318            Object value = (field != null) ? MongoDBSerializationHelper.valueToBson(v, field.getType())
319                    : MongoDBSerializationHelper.valueToBson(v);
320            String key = entry.getKey();
321            if (fulltext != null && fulltext.contains(key)) {
322                String val = String.valueOf(value);
323                if (val != null) {
324                    val = val.replaceAll("%+", ".*");
325                }
326                switch (substringMatchType) {
327                case subany:
328                    addField(bson, key, Pattern.compile(val, Pattern.CASE_INSENSITIVE));
329                    break;
330                case subinitial:
331                    addField(bson, key, Pattern.compile('^' + val, Pattern.CASE_INSENSITIVE));
332                    break;
333                case subfinal:
334                    addField(bson, key, Pattern.compile(val + '$', Pattern.CASE_INSENSITIVE));
335                    break;
336                }
337            } else {
338                addField(bson, key, value);
339            }
340        }
341        return bson;
342    }
343
344    protected void addField(Document bson, String key, Object value) {
345        String keyFieldName = key;
346        Field field = directory.getSchemaFieldMap().get(key);
347        if (field != null) {
348            keyFieldName = field.getName().getPrefixedName();
349        }
350        bson.put(keyFieldName, value);
351    }
352
353    @Override
354    public void close() throws DirectoryException {
355        getDirectory().removeSession(this);
356    }
357
358    @Override
359    public boolean authenticate(String username, String password) throws DirectoryException {
360        Document user = getCollection().find(MongoDBSerializationHelper.fieldMapToBson(getIdField(), username)).first();
361        if (user == null) {
362            return false;
363        }
364        String storedPassword = user.getString(getPasswordField());
365        return PasswordHelper.verifyPassword(password, storedPassword);
366    }
367
368    @Override
369    public boolean isAuthenticating() {
370        return directory.getSchemaFieldMap().containsKey(getPasswordField());
371    }
372
373    @Override
374    public boolean hasEntry(String id) {
375        String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
376        return getCollection().count(MongoDBSerializationHelper.fieldMapToBson(idFieldName, id)) > 0;
377    }
378
379    /**
380     * Retrieve a collection
381     *
382     * @param collection the collection name
383     * @return the MongoDB collection
384     */
385    public MongoCollection<Document> getCollection(String collection) {
386        return database.getCollection(collection);
387    }
388
389    /**
390     * Retrieve the collection associated to this directory
391     *
392     * @return the MongoDB collection
393     */
394    public MongoCollection<Document> getCollection() {
395        return getCollection(directoryName);
396    }
397
398    /**
399     * Check if the MongoDB server has the collection
400     *
401     * @param collection the collection name
402     * @return true if the server has the collection, false otherwise
403     */
404    public boolean hasCollection(String collection) {
405        return MongoDBConnectionHelper.hasCollection(database, collection);
406    }
407
408    protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) {
409        String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
410        if (!fieldMap.containsKey(idFieldName)) {
411            idFieldName = getIdField();
412        }
413        String id = String.valueOf(fieldMap.get(idFieldName));
414        return createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
415    }
416
417}