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