001/*
002 *  Copyright 2010 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 */
016
017package org.ametys.plugins.repository.provider;
018
019import java.io.File;
020import java.io.IOException;
021import java.io.InputStream;
022import java.time.Instant;
023import java.time.ZoneId;
024import java.time.format.DateTimeFormatter;
025import java.util.HashMap;
026import java.util.Map;
027
028import javax.jcr.LoginException;
029import javax.jcr.NoSuchWorkspaceException;
030import javax.jcr.RepositoryException;
031import javax.jcr.Session;
032import javax.jcr.SimpleCredentials;
033
034import org.apache.avalon.framework.CascadingRuntimeException;
035import org.apache.avalon.framework.activity.Disposable;
036import org.apache.avalon.framework.activity.Initializable;
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.context.ContextException;
039import org.apache.avalon.framework.context.Contextualizable;
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.cocoon.Constants;
043import org.apache.cocoon.components.ContextHelper;
044import org.apache.cocoon.environment.Context;
045import org.apache.cocoon.environment.Request;
046import org.apache.commons.io.IOUtils;
047import org.apache.excalibur.source.ModifiableSource;
048import org.apache.excalibur.source.ModifiableTraversableSource;
049import org.apache.excalibur.source.MoveableSource;
050import org.apache.excalibur.source.Source;
051import org.apache.excalibur.source.SourceResolver;
052import org.apache.excalibur.source.TraversableSource;
053import org.apache.jackrabbit.core.cache.CacheManager;
054import org.apache.jackrabbit.core.config.ConfigurationException;
055import org.apache.jackrabbit.core.config.RepositoryConfig;
056
057import org.ametys.core.datasource.ConnectionHelper;
058import org.ametys.plugins.repository.RepositoryConstants;
059import org.ametys.runtime.config.Config;
060import org.ametys.runtime.util.AmetysHomeHelper;
061
062/**
063 * JackrabbitRepository is a JCR repository component based on Jackrabbit
064 */
065public class JackrabbitRepository extends AbstractRepository implements LogoutManager, Initializable, Contextualizable, Disposable, Component
066{
067    private static final String __REPOSITORY_NODETYPES_PATH = "ametys-home://data/repository/repository/nodetypes";
068
069    private org.apache.avalon.framework.context.Context _avalonContext;
070    private Context _context;
071    
072    private Session _adminSession;
073
074    private SourceResolver _resolver;
075    
076    /**
077     * Must implements ModifiableTraversableSource, MoveableSource
078     */
079    private Source _customNodeTypesBackupSource;
080
081    @Override
082    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
083    {
084        _avalonContext = context;
085        _context = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
086    }
087    
088    @Override
089    public void service(ServiceManager smanager) throws ServiceException
090    {
091        super.service(smanager);
092        // Make sure the ConnectionHelper is initialized first, so the SQLDataSourceManager can be used in AmetysPersistenceManager
093        smanager.lookup(ConnectionHelper.ROLE);
094        _resolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE);
095    }
096    
097    
098    public void initialize() throws Exception
099    {
100        _backupCustomNodetypes();
101        RepositoryConfig repositoryConfig = createRepositoryConfig();
102        AmetysRepository repo = new AmetysRepository(repositoryConfig);
103        
104        _delegate = repo;
105        ((AmetysRepository) _delegate).setLogoutManager(this);
106        
107        Long cacheSize = Config.getInstance().getValue("org.ametys.plugins.repository.cache");  
108        if (cacheSize == null || cacheSize <= 0)
109        {
110            cacheSize = 16777216L; // default value;
111        }
112        
113        CacheManager cacheManager = repo.getCacheManager();
114        cacheManager.setMaxMemory(cacheSize);
115        cacheManager.setMaxMemoryPerCache(cacheSize / 4);
116        cacheManager.setMinMemoryPerCache(cacheSize / 128);
117        
118        // Register the delegate repository in the context for the repository workspace.
119        _context.setAttribute(CONTEXT_REPOSITORY_KEY, _delegate);
120        _context.setAttribute(CONTEXT_CREDENTIALS_KEY, new SimpleCredentials("ametys", new char[]{}));
121        _context.setAttribute(CONTEXT_IS_JNDI_KEY, false);
122        
123        _adminSession = login("default");
124        
125        if (getLogger().isDebugEnabled())
126        {
127            getLogger().debug("JCR Repository running");
128        }
129    }
130
131    public void dispose()
132    {
133        AmetysRepository repo = (AmetysRepository) _delegate;
134        repo.setLogoutManager(null);
135        repo.shutdown();
136    }    
137    
138    /**
139     * Returns the admin session
140     * @return the admin session
141     * @throws RepositoryException if an error occurs.
142     */
143    public Session getAdminSession() throws RepositoryException
144    {
145        _adminSession.refresh(false);
146        return _adminSession;
147    }
148    
149    
150    /**
151     * Returns the all JCR workspaces
152     * @return the available workspaces
153     * @throws RepositoryException if an error occurred
154     */
155    public String[] getWorkspaces() throws RepositoryException
156    {
157        return _adminSession.getWorkspace().getAccessibleWorkspaceNames();
158    }
159
160
161    /**
162     * Create the repository configuration from the configuration file
163     * @return The repository configuration
164     * @throws ConfigurationException if an error occurred
165     */
166    RepositoryConfig createRepositoryConfig() throws ConfigurationException
167    {
168        String config = _context.getRealPath("/WEB-INF/param/repository.xml");
169        
170        File homeFile = new File(AmetysHomeHelper.getAmetysHomeData(), "repository");
171        
172        if (getLogger().isDebugEnabled())
173        {
174            getLogger().debug("Creating JCR Repository config at: " + homeFile.getAbsolutePath());
175        }
176        
177        return RepositoryConfig.create(config, homeFile.getAbsolutePath());
178    }
179
180    int getSessionCount()
181    {
182        return ((AmetysRepository) _delegate).getSessionCount();
183    }
184    
185    public Session login(String workspace) throws LoginException, NoSuchWorkspaceException, RepositoryException
186    {
187        try
188        {
189            if (getLogger().isDebugEnabled())
190            {
191                getLogger().debug("Getting JCR Session");
192            }
193            
194            try
195            {
196                Request request = ContextHelper.getRequest(_avalonContext);
197                
198                @SuppressWarnings("unchecked")
199                Map<String, Session> sessions = (Map<String, Session>) request.getAttribute(RepositoryConstants.JCR_SESSION_REQUEST_ATTRIBUTE);
200                
201                if (sessions == null)
202                {
203                    sessions = new HashMap<>();
204                    request.setAttribute(RepositoryConstants.JCR_SESSION_REQUEST_ATTRIBUTE, sessions);
205                }
206                
207                Session session = sessions.get(workspace);
208                
209                if (session == null || !session.isLive())
210                {
211                    session = _delegate.login(new SimpleCredentials("ametys", new char[]{}), workspace);
212                    sessions.put(workspace, session);
213                }
214                
215                return session;
216            }
217            catch (CascadingRuntimeException e)
218            {
219                if (e.getCause() instanceof ContextException)
220                {
221                    // Unable to get request. Must be in another thread or at init time.
222                    Session session = _delegate.login(new SimpleCredentials("ametys", new char[]{}), workspace);
223                    return session;
224                }
225                else
226                {
227                    throw e;
228                }
229            }
230        }
231        catch (RepositoryException e)
232        {
233            throw e;
234        }
235        catch (Exception e)
236        {
237            throw new RuntimeException("Unable to get Session", e);
238        }
239    }
240    
241    public void logout(Session session)
242    {
243        if (!(session instanceof AmetysSession))
244        {
245            throw new IllegalArgumentException("JCR Session should be an instance of AmetysSession");
246        }
247        
248        AmetysSession ametysSession = (AmetysSession) session;
249        
250        if (getLogger().isDebugEnabled())
251        {
252            getLogger().debug("Logging out AmetysSession");
253        }
254        
255        try
256        {
257            // the following statement will fail if we are not processing a request.
258            ContextHelper.getRequest(_avalonContext);
259            
260            // does nothing as the session will be actually logged out at the end of the request.
261            if (getLogger().isDebugEnabled())
262            {
263                getLogger().debug("AmetysSession logout delayed until the end of the HTTP request.");
264            }
265            
266        }
267        catch (Exception e)
268        {
269            // unable to get request. Must be in another thread or at init time.
270            if (getLogger().isDebugEnabled())
271            {
272                getLogger().debug("Not in a request. AmetysSession will be actually logged out.");
273            }
274            
275            ametysSession.forceLogout();
276        }
277    }
278
279    /**
280     * Create a backup of custom_nodetypes.xml and make a backup with the curent time in the filename
281     * The backup file name is stored to be compared ONCE with the re-created custom_nodetypes.xml by calling {@link JackrabbitRepository#compareCustomNodetypes()}
282     */
283    protected void _backupCustomNodetypes()
284    {
285        Source source = null;
286        Source destination = null;
287        try
288        {
289            source = _resolver.resolveURI(__REPOSITORY_NODETYPES_PATH + "/custom_nodetypes.xml");
290            if (source.exists() && source instanceof ModifiableSource)
291            {
292                ModifiableSource fsource = (ModifiableSource) source;
293                
294                DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneId.systemDefault());
295                String timestamp = formatter.format(Instant.now());
296                
297                destination = _resolver.resolveURI(__REPOSITORY_NODETYPES_PATH + "/custom_nodetypes-" + timestamp + ".xml");
298                if (destination.exists())
299                {
300                    fsource.delete();
301                    getLogger().warn("Impossible to backup custom_nodetypes.xml, custom_nodetypes-" + timestamp + ".xml already exists.");
302                }
303                else
304                {
305                    _customNodeTypesBackupSource = destination;
306                    getLogger().info("Backup custom_nodetypes.xml as custom_nodetypes-" + timestamp + ".xml");
307                    ((MoveableSource) fsource).moveTo(destination);
308                }
309                
310                if (fsource.exists())
311                {
312                    getLogger().error("Impossible to delete custom_nodetypes.xml after creating a backup.");
313                }
314            }
315        }
316        catch (IOException e)
317        {
318            getLogger().error("An error occurred while backuping the custom_nodetypes.xml file", e);
319        }
320        finally
321        {
322            _resolver.release(source);
323            _resolver.release(destination);
324        }
325    }
326    
327    /**
328     * Compare custom_nodetypes.xml with custom_nodetypes-[timestamp].xml and delete the backup if they are the same
329     */
330    public void compareCustomNodetypes()
331    {
332        if (_customNodeTypesBackupSource != null && _customNodeTypesBackupSource.exists())
333        {
334            Source source = null;
335            try
336            {
337                source = _resolver.resolveURI(__REPOSITORY_NODETYPES_PATH + "/custom_nodetypes.xml");
338                
339                if (source.exists() && source instanceof ModifiableTraversableSource)
340                {
341                    if (_compareSources((TraversableSource) source, (TraversableSource) _customNodeTypesBackupSource))
342                    {
343                        getLogger().info(((TraversableSource) _customNodeTypesBackupSource).getName() + " will be deleted, no changes found.");
344                        ((ModifiableTraversableSource) _customNodeTypesBackupSource).delete();
345
346                        
347                        if (_customNodeTypesBackupSource.exists())
348                        {
349                            getLogger().error("Impossible to delete custom_nodetypes.xml after creating a backup.");
350                        }
351                    }
352                    else
353                    {
354                        getLogger().info(((TraversableSource) _customNodeTypesBackupSource).getName() + " will be kept, changes found with last version.");
355                    }
356                }
357                else
358                {
359                    getLogger().warn("Impossible to compare custom_nodetypes.xml, it seems to be unavailable.");
360                }
361            }
362            catch (IOException e)
363            {
364                getLogger().error("Impossible to compare custom_nodetypes.xml with it's backup '" + _customNodeTypesBackupSource.getURI() + "'", e);
365            }
366            finally
367            {
368                _resolver.release(source);
369            }
370        }
371        else
372        {
373            getLogger().debug("There is no backup of custom_nodetypes.xml to compare");
374        }
375        
376        _resolver.release(_customNodeTypesBackupSource);
377        _customNodeTypesBackupSource = null;
378    }
379    
380    /**
381     * Compare 2 sources
382     * @param source1 source to compare
383     * @param source2 source to compare
384     * @return true if the content of both sources is equal
385     * @throws IOException something went wrong reading the sources
386     */
387    protected boolean _compareSources(TraversableSource source1, TraversableSource source2) throws IOException
388    {
389        if (source1 == null && source2 == null)
390        {
391            return true;
392        }
393        if (source1 == null || source2 == null)
394        {
395            return false;
396        }
397        final boolean source1Exists = source1.exists();
398        if (source1Exists != source2.exists())
399        {
400            return false;
401        }
402
403        if (!source1Exists)
404        {
405            // two not existing files are equal
406            return true;
407        }
408        
409
410
411        if (source1.isCollection() || source2.isCollection())
412        {
413            // don't want to compare directory contents
414            throw new IOException("Can't compare collections, only content source");
415        }
416
417        if (source1.getContentLength() != source2.getContentLength())
418        {
419            // lengths differ, cannot be equal
420            return false;
421        }
422
423        if (source1.getURI().equals(source2.getURI()))
424        {
425            // same source
426            return true;
427        }
428
429        try (InputStream input1 = source1.getInputStream();
430             InputStream input2 = source2.getInputStream())
431        {
432            return IOUtils.contentEquals(input1, input2);
433        }
434    }
435}