001/*
002 * (C) Copyright 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 *     Antoine Taillefer <ataillefer@nuxeo.com>
018 */
019package org.nuxeo.wopi.lock;
020
021import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_DOC_ID;
022import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_FILE_ID;
023import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_LOCK;
024import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_NAME;
025import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_REPOSITORY;
026import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_SCHEMA_NAME;
027import static org.nuxeo.wopi.Constants.LOCK_DIRECTORY_TIMESTAMP;
028import static org.nuxeo.wopi.Constants.LOCK_TTL;
029
030import java.util.Collections;
031import java.util.HashMap;
032import java.util.List;
033import java.util.Map;
034import java.util.function.Consumer;
035import java.util.function.Function;
036
037import org.apache.logging.log4j.LogManager;
038import org.apache.logging.log4j.Logger;
039import org.nuxeo.ecm.core.api.DocumentModel;
040import org.nuxeo.ecm.core.api.repository.RepositoryManager;
041import org.nuxeo.ecm.core.query.sql.model.Predicates;
042import org.nuxeo.ecm.core.query.sql.model.QueryBuilder;
043import org.nuxeo.ecm.directory.Session;
044import org.nuxeo.ecm.directory.api.DirectoryService;
045import org.nuxeo.runtime.api.Framework;
046import org.nuxeo.wopi.Constants;
047import org.nuxeo.wopi.FileInfo;
048
049/**
050 * @since 10.3
051 */
052public class LockHelper {
053
054    private static final Logger log = LogManager.getLogger(LockHelper.class);
055
056    /**
057     * Flag to know if the request originated from a WOPI client.
058     */
059    protected static ThreadLocal<Boolean> isWOPIRequest = new ThreadLocal<>();
060
061    private LockHelper() {
062        // helper class
063    }
064
065    /**
066     * Stores the given WOPI lock for the given file id with a timestamp for expiration purpose.
067     */
068    public static void addLock(String fileId, String lock) {
069        FileInfo fileInfo = new FileInfo(fileId);
070        addLock(fileId, fileInfo.repositoryName, fileInfo.docId, lock);
071    }
072
073    /**
074     * @see #addLock(String, String)
075     */
076    public static void addLock(String fileId, String repository, String docId, String lock) {
077        log.debug("Locking: fileId={} Adding lock {}", fileId, lock);
078        doPrivilegedOnLockDirectory(session -> {
079            Map<String, Object> entryMap = new HashMap<>();
080            entryMap.put(LOCK_DIRECTORY_FILE_ID, fileId);
081            entryMap.put(LOCK_DIRECTORY_REPOSITORY, repository);
082            entryMap.put(LOCK_DIRECTORY_DOC_ID, docId);
083            entryMap.put(LOCK_DIRECTORY_LOCK, lock);
084            entryMap.put(LOCK_DIRECTORY_TIMESTAMP, System.currentTimeMillis());
085            session.createEntry(entryMap);
086        });
087    }
088
089    /**
090     * Gets the WOPI lock stored for the given file id if it exists, returns {@code null} otherwise.
091     */
092    public static String getLock(String fileId) {
093        return doPrivilegedOnLockDirectory(session -> {
094            DocumentModel entry = session.getEntry(fileId);
095            return entry == null ? null : (String) entry.getProperty(LOCK_DIRECTORY_SCHEMA_NAME, LOCK_DIRECTORY_LOCK);
096        });
097    }
098
099    /**
100     * Checks if a WOPI lock is stored for the given repository and doc id, no matter the xpath.
101     */
102    public static boolean isLocked(String repository, String docId) {
103        QueryBuilder queryBuilder = new QueryBuilder().predicate(Predicates.eq(LOCK_DIRECTORY_REPOSITORY, repository))
104                                                      .and(Predicates.eq(LOCK_DIRECTORY_DOC_ID, docId));
105        return doPrivilegedOnLockDirectory(session -> !session.query(queryBuilder, false).isEmpty());
106    }
107
108    /**
109     * Checks if a WOPI lock is stored for the given file id.
110     */
111    public static boolean isLocked(String fileId) {
112        return doPrivilegedOnLockDirectory(session -> session.getEntry(fileId) != null);
113    }
114
115    /**
116     * Updates the WOPI lock stored for the given file id with the given lock and a fresh timestamp.
117     */
118    public static void updateLock(String fileId, String lock) {
119        log.debug("Locking: fileId={} Updating lock {}", fileId, lock);
120        doPrivilegedOnLockDirectory(session -> {
121            DocumentModel entry = session.getEntry(fileId);
122            entry.setProperty(LOCK_DIRECTORY_SCHEMA_NAME, LOCK_DIRECTORY_LOCK, lock);
123            entry.setProperty(LOCK_DIRECTORY_SCHEMA_NAME, LOCK_DIRECTORY_TIMESTAMP, System.currentTimeMillis());
124            session.updateEntry(entry);
125        });
126    }
127
128    /**
129     * Updates the WOPI lock stored for the given file id with a fresh timestamp.
130     */
131    public static void refreshLock(String fileId) {
132        log.debug("Locking: fileId={} Refreshing lock", fileId);
133        doPrivilegedOnLockDirectory(session -> {
134            DocumentModel entry = session.getEntry(fileId);
135            entry.setProperty(LOCK_DIRECTORY_SCHEMA_NAME, LOCK_DIRECTORY_TIMESTAMP, System.currentTimeMillis());
136            session.updateEntry(entry);
137        });
138    }
139
140    /**
141     * Removes the WOPI lock stored for the given file id.
142     */
143    public static void removeLock(String fileId) {
144        log.debug("Locking: fileId={} Removing lock", fileId);
145        doPrivilegedOnLockDirectory((Session session) -> session.deleteEntry(fileId));
146    }
147
148    /**
149     * Removes all the WOPI locks stored for the given repository and doc id.
150     */
151    public static void removeLocks(String repository, String docId) {
152        log.debug("Locking: repository={} docId={} Document was unlocked in Nuxeo, removing related WOPI locks",
153                repository, docId);
154        QueryBuilder queryBuilder = new QueryBuilder().predicate(Predicates.eq(LOCK_DIRECTORY_REPOSITORY, repository))
155                                                      .and(Predicates.eq(LOCK_DIRECTORY_DOC_ID, docId));
156        doPrivilegedOnLockDirectory(
157                (Session session) -> session.query(queryBuilder, false).forEach(session::deleteEntry));
158    }
159
160    /**
161     * Performs the given consumer with a privileged session on the lock directory.
162     */
163    public static void doPrivilegedOnLockDirectory(Consumer<Session> consumer) {
164        Framework.doPrivileged(() -> {
165            try (Session session = openLockDirectorySession()) {
166                consumer.accept(session);
167            }
168        });
169    }
170
171    /**
172     * Applies the given function with a privileged session on the lock directory.
173     */
174    public static <R> R doPrivilegedOnLockDirectory(Function<Session, R> function) {
175        return Framework.doPrivileged(() -> {
176            try (Session session = openLockDirectorySession()) {
177                return function.apply(session);
178            }
179        });
180    }
181
182    /**
183     * Returns the list of expired stored WOPI locks according to the {@link Constants#LOCK_TTL} for each repository.
184     * <p>
185     * The given session must be privileged.
186     */
187    public static Map<String, List<DocumentModel>> getExpiredLocksByRepository(Session session) {
188        return Framework.getService(RepositoryManager.class)
189                        .getRepositoryNames()
190                        .stream()
191                        .map(repository -> getExpiredLocks(session, repository))
192                        .reduce(new HashMap<String, List<DocumentModel>>(), (a, b) -> {
193                            a.putAll(b);
194                            return a;
195                        });
196    }
197
198    /**
199     * Returns {@code true} if the request originated from a WOPI client.
200     */
201    public static boolean isWOPIRequest() {
202        return Boolean.TRUE.equals(isWOPIRequest.get());
203    }
204
205    /**
206     * Flags the request as originating from a WOPI client.
207     */
208    public static void flagWOPIRequest() {
209        isWOPIRequest.set(true);
210    }
211
212    /**
213     * Unflags the request as originating from a WOPI client.
214     */
215    public static void unflagWOPIRequest() {
216        isWOPIRequest.remove();
217    }
218
219    protected static Map<String, List<DocumentModel>> getExpiredLocks(Session session, String repository) {
220        long expirationTime = System.currentTimeMillis() - LOCK_TTL;
221        QueryBuilder queryBuilder = new QueryBuilder().predicate(Predicates.eq(LOCK_DIRECTORY_REPOSITORY, repository))
222                                                      .and(Predicates.lt(LOCK_DIRECTORY_TIMESTAMP, expirationTime));
223        List<DocumentModel> expiredLocks = session.query(queryBuilder, false);
224        return Collections.singletonMap(repository, expiredLocks);
225    }
226
227    protected static Session openLockDirectorySession() {
228        return Framework.getService(DirectoryService.class).open(LOCK_DIRECTORY_NAME);
229    }
230
231}