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 */
016package org.ametys.web.skin;
017
018import java.io.BufferedReader;
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.InputStreamReader;
022import java.net.URL;
023import java.nio.file.FileSystem;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.util.ArrayList;
027import java.util.Enumeration;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.List;
031import java.util.Map;
032import java.util.Set;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.CascadingRuntimeException;
036import org.apache.avalon.framework.activity.Disposable;
037import org.apache.avalon.framework.activity.Initializable;
038import org.apache.avalon.framework.component.Component;
039import org.apache.avalon.framework.configuration.Configuration;
040import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
041import org.apache.avalon.framework.context.Context;
042import org.apache.avalon.framework.context.ContextException;
043import org.apache.avalon.framework.context.Contextualizable;
044import org.apache.avalon.framework.logger.AbstractLogEnabled;
045import org.apache.avalon.framework.service.ServiceException;
046import org.apache.avalon.framework.service.ServiceManager;
047import org.apache.avalon.framework.service.Serviceable;
048import org.apache.avalon.framework.thread.ThreadSafe;
049import org.apache.cocoon.Constants;
050import org.apache.cocoon.components.ContextHelper;
051import org.apache.cocoon.environment.Request;
052import org.apache.commons.lang3.StringUtils;
053import org.apache.excalibur.source.Source;
054import org.apache.excalibur.source.SourceNotFoundException;
055import org.apache.excalibur.source.SourceResolver;
056
057import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
058import org.ametys.cms.contenttype.ContentTypesHelper;
059import org.ametys.core.util.JarFSManager;
060import org.ametys.runtime.servlet.RuntimeConfig;
061import org.ametys.runtime.servlet.RuntimeServlet;
062import org.ametys.web.WebConstants;
063import org.ametys.web.WebHelper;
064import org.ametys.web.data.type.ModelItemTypeExtensionPoint;
065import org.ametys.web.parameters.ViewAndParametersParser;
066import org.ametys.web.parameters.view.ViewParametersManager;
067import org.ametys.web.repository.site.SiteManager;
068
069/**
070 * Manages the skins
071 */
072public class SkinsManager extends AbstractLogEnabled implements ThreadSafe, Serviceable, Component, Contextualizable, Initializable, Disposable
073{
074    /** The avalon role name */
075    public static final String ROLE = SkinsManager.class.getName();
076
077    /** A cache of skins objects classified by absolute path */
078    protected Map<Path, Skin> _skins = new HashMap<>();
079    
080    /** The skins declared as external: name of the skin and file location */
081    protected Map<String, Path> _externalSkins = new HashMap<>();
082    
083    /** The skins declared in jar files */
084    protected Map<String, Path> _resourcesSkins = new HashMap<>();
085    
086    /** Versions for bundled skins */
087    protected Map<String, String> _versions = new HashMap<>();
088    
089    /** The avalon service manager */
090    protected ServiceManager _manager;
091    /** The excalibur source resolver */
092    protected SourceResolver _sourceResolver;
093    /** Avalon context */
094    protected Context _context;
095    /** Cocoon context */
096    protected org.apache.cocoon.environment.Context _cocoonContext;
097    /** The site manager */
098    protected SiteManager _siteManager;
099    /** The view parameters manager */
100    protected ViewParametersManager _viewParametersManager;
101    /** The view and parameters parser */
102    protected ViewAndParametersParser _viewAndParametersParser;
103    /** The extension point for type of view parameter */
104    protected ModelItemTypeExtensionPoint _viewParametersEP;
105    /** The skin configuration helper */
106    protected SkinConfigurationHelper _skinConfigurationHelper;
107    /** The content type extension point */
108    protected ContentTypeExtensionPoint _contentTypeExtensionPoint;
109    /** The content types helper */
110    protected ContentTypesHelper _contentTypesHelper;
111
112    @Override
113    public void service(ServiceManager manager) throws ServiceException
114    {
115        _manager = manager;
116        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
117        _skinConfigurationHelper = (SkinConfigurationHelper) manager.lookup(SkinConfigurationHelper.ROLE);
118    }
119    
120    @Override
121    public void contextualize(Context context) throws ContextException
122    {
123        _context = context;
124        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
125    }
126    
127    ServiceManager getServiceManager()
128    {
129        return _manager;
130    }
131    
132    Context getContext()
133    {
134        return _context;
135    }
136    
137    SourceResolver getSourceResolver()
138    {
139        return _sourceResolver;
140    }
141    
142    ViewParametersManager getViewParametersManager()
143    {
144        if (_viewParametersManager == null)
145        {
146            try 
147            {
148                _viewParametersManager = (ViewParametersManager) _manager.lookup(ViewParametersManager.ROLE);
149            } 
150            catch (ServiceException e) 
151            {
152                throw new RuntimeException(e);
153            }
154        }
155        return _viewParametersManager;
156    }
157    
158    ViewAndParametersParser getViewAndParametersParser()
159    {
160        if (_viewAndParametersParser == null)
161        {
162            try 
163            {
164                _viewAndParametersParser = (ViewAndParametersParser) _manager.lookup(ViewAndParametersParser.ROLE);
165            } 
166            catch (ServiceException e) 
167            {
168                throw new RuntimeException(e);
169            }
170        }
171        return _viewAndParametersParser;
172    }
173    
174    ModelItemTypeExtensionPoint getViewParameterTypeExtensionPoint()
175    {
176        if (_viewParametersEP == null)
177        {
178            try 
179            {
180                _viewParametersEP = (ModelItemTypeExtensionPoint) _manager.lookup(ModelItemTypeExtensionPoint.ROLE_VIEW_PARAM);
181            } 
182            catch (ServiceException e) 
183            {
184                throw new RuntimeException(e);
185            }
186        }
187        return _viewParametersEP;
188    }
189    
190    public void initialize() throws Exception
191    {
192        // Skins can be in external locations
193        _listExternalSkins("context://" + RuntimeServlet.EXTERNAL_LOCATIONS);
194        
195        // Skins can be in jars
196        _listResourcesSkins();
197    }
198    
199    public void dispose()
200    {
201        for (Skin skin : _skins.values())
202        {
203            skin.dispose();
204        }
205        _skins.clear();
206    }
207    
208    private void _listResourcesSkins() throws IOException
209    {
210        Enumeration<URL> skinResources = getClass().getClassLoader().getResources("META-INF/ametys-skins");
211        
212        while (skinResources.hasMoreElements())
213        {
214            URL skinResource = skinResources.nextElement();
215            
216            try (InputStream is = skinResource.openStream();
217                 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8")))
218            {
219                String skin;
220                while ((skin = br.readLine()) != null)
221                {
222                    String skinNameAndVersion = StringUtils.substringBefore(skin, ':');  
223                    String skinName = StringUtils.substringBefore(skinNameAndVersion, '@');
224                    String version = StringUtils.trimToNull(StringUtils.substringAfter(skinNameAndVersion, '@'));
225                    String skinResourceURI = StringUtils.substringAfter(skin, ':');  
226                    
227                    FileSystem skinFileSystem = JarFSManager.getInstance().getFileSystemByResource(skinResourceURI);
228                    Path skinPath = skinFileSystem.getPath(skinResourceURI);
229                    
230                    if (_isASkinPath(skinPath))
231                    {
232                        _resourcesSkins.put(skinName, skinPath);
233                        _versions.put(skinName, version);
234                    }
235                    else
236                    {
237                        getLogger().error("The skin '" + skinName + "' declared in a JAR file will be ignored as it is not a true skin");
238                    }
239                }
240            }
241        }        
242    }
243
244    private void _listExternalSkins(String uri) throws Exception
245    {
246        Configuration externalConf;
247        
248        // Read file
249        Source externalLocation = null;
250        try
251        {
252            externalLocation = _sourceResolver.resolveURI(uri);
253            if (!externalLocation.exists())
254            {
255                throw new SourceNotFoundException("No file at " + uri);
256            }
257            
258            DefaultConfigurationBuilder externalConfBuilder = new DefaultConfigurationBuilder();
259            try (InputStream external = externalLocation.getInputStream())
260            {
261                externalConf = externalConfBuilder.build(external, uri);
262            }
263        }
264        catch (SourceNotFoundException e)
265        {
266            getLogger().debug("No external location file");
267            return;
268        }
269        finally
270        {
271            _sourceResolver.release(externalLocation);
272        }
273        
274        // Apply file
275        for (Configuration skinConf : externalConf.getChild("skins").getChildren("skin"))
276        {
277            String name = skinConf.getAttribute("name", null);
278            String location = skinConf.getValue(null);
279            
280            if (name != null && location != null)
281            {
282                Path skinDir = _getPath(location);
283                // Do not check at this time, since it is not read-only, this can change
284                _externalSkins.put(name, skinDir);
285            }
286        }
287    }
288    
289    /*
290     * Returns the corresponding file, either absolute or relative to the context path
291     */
292    private Path _getPath(String path)
293    {
294        if (path == null)
295        {
296            return null;
297        }
298        
299        Path directory = Path.of(path);
300        if (directory.isAbsolute())
301        {
302            return directory;
303        }
304        else
305        {
306            return Path.of(_cocoonContext.getRealPath("/" + path));
307        }
308    }
309
310    /**
311     * Get the list of existing skins from JARs
312     * @return A set of skin names. Can be null if there is an error.
313     */
314    public Set<String> getResourceSkins()
315    {
316        return _resourcesSkins.keySet();
317    }
318    /**
319     * Get the list of existing skins
320     * @return A set of skin names. Can be null if there is an error.
321     */
322    public Set<String> getSkins()
323    {
324        try
325        {
326            Set<String> skins = new HashSet<>();
327            
328            // JAR skins
329            skins.addAll(_resourcesSkins.keySet());
330            
331            // External skins
332            _externalSkins.entrySet().stream()
333                          .filter(e -> _isASkinPath(e.getValue()))
334                          .map(Map.Entry::getKey)
335                          .forEach(skins::add);
336            
337            // Skins at location
338            Path skinsDir = getLocalSkinsLocation();
339            if (Files.exists(skinsDir) && Files.isDirectory(skinsDir))
340            {
341                try (Stream<Path> files = Files.list(skinsDir))
342                {
343                    files.filter(this::_isASkinPath)
344                        .map(p -> p.getFileName().toString())
345                        .forEach(skins::add);
346                }
347            }
348            
349            return skins;
350        }
351        catch (Exception e)
352        {
353            getLogger().error("Can not determine the list of skins available", e);
354            return null;
355        }
356    }
357    
358    /**
359     * Get the skin version or null if unknown. Only available for bundled skins.
360     * @param skinName the skin name
361     * @return the skin version if known.
362     */
363    public String getVersion(String skinName)
364    {
365        return _versions.get(skinName);
366    }
367    
368    /**
369     * Get a skin
370     * @param id The id of the skin
371     * @return The skin or null if the skin does not exist
372     */
373    public Skin getSkin(String id)
374    {
375        if (id == null)
376        {
377            return null;
378        }
379        
380        Path skinPath;
381        boolean modifiable = false;
382        
383        skinPath = _resourcesSkins.get(id);
384        if (skinPath == null)
385        {
386            skinPath = _externalSkins.get(id);
387            if (skinPath == null)
388            {
389                skinPath = getLocalSkinsLocation().resolve(id);
390                if (Files.exists(skinPath) && Files.isDirectory(skinPath))
391                {
392                    modifiable = true;
393                }
394                else
395                {
396                    // No skin with this name
397                    return null;
398                }
399            }
400        }
401        
402        if (!_isASkinPath(skinPath))
403        {
404            // A skin with this name but is not a skin
405            if (_skins.containsKey(skinPath))
406            {
407                Skin skin = _skins.get(skinPath);
408                skin.dispose();
409            }
410            
411            _skins.put(skinPath, null);
412            return null;
413        }
414        
415        Skin skin = _skins.get(skinPath);
416        if (skin == null)
417        {
418            skin = new Skin(id, skinPath, modifiable, this, _skinConfigurationHelper);
419            _skins.put(skinPath, skin);
420        }
421        
422        skin.refreshValues();
423        return skin;
424    }
425    
426    /**
427     * Get the skin and recursively its parents skins in the right weight order
428     * @param skin The non null skin to check
429     * @return The list of parent skins INCLUDING the given one
430     */
431    public List<Skin> getSkinAndParents(Skin skin)
432    {
433        List<Skin> skinAndParents = new ArrayList<>();
434        
435        skinAndParents.add(skin);
436        
437        for (String parentSkinId : skin.getParents())
438        {
439            Skin parentSkin = getSkin(parentSkinId);
440            if (parentSkin == null)
441            {
442                throw new IllegalStateException("The skin '" + skin.getId() + "' extends the unexisting skin '" + parentSkinId + "'");
443            }
444            skinAndParents.addAll(getSkinAndParents(parentSkin));
445        }
446        
447        return skinAndParents;
448    }
449    
450    /**
451     * Compute the skin that are directly derived from the given one
452     * @param skin A non null skin
453     * @return A non null list of skins
454     */
455    public List<Skin> getDirectChildren(Skin skin)
456    {
457        List<Skin> children = new ArrayList<>();
458        
459        for (String otherSkinId : getSkins())
460        {
461            if (!otherSkinId.equals(skin.getId()))
462            {
463                Skin otherSkin = getSkin(otherSkinId);
464                if (otherSkin.getParents().contains(skin.getId()))
465                {
466                    children.add(otherSkin);
467                }
468            }
469        }
470        
471        return children;
472    }
473    
474    /**
475     * Get the skins location
476     * @return the skin location
477     */
478    public Path getLocalSkinsLocation()
479    {
480        try
481        {
482            Request request = ContextHelper.getRequest(_context);
483            String skinLocation = (String) request.getAttribute("skin-location");
484            if (skinLocation != null)
485            {
486                return Path.of(RuntimeConfig.getInstance().getAmetysHome().getAbsolutePath(), skinLocation);
487            }
488        }
489        catch (CascadingRuntimeException e)
490        {
491            // Ignore
492        }
493        
494        return Path.of(_cocoonContext.getRealPath("/skins"));
495    }
496
497    /**
498     * Get the skin name from request or <code>null</code> if not found
499     * @return The skin name or <code>null</code>
500     */
501    public String getSkinNameFromRequest ()
502    {
503        Request request = null;
504        try
505        {
506            request = ContextHelper.getRequest(_context);
507            
508            return getSkinNameFromRequest(request);
509        }
510        catch (RuntimeException e)
511        {
512            // No running request
513            return null;
514        }
515    }
516    
517    /**
518     * Get the skin name from request or <code>null</code> if not found
519     * @param request The request
520     * @return The skin name or <code>null</code>
521     */
522    public String getSkinNameFromRequest(Request request)
523    {
524        if (_siteManager == null)
525        {
526            try
527            {
528                _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE);
529            }
530            catch (ServiceException e)
531            {
532                throw new IllegalStateException(e);
533            }
534        }
535        
536        if (request == null)
537        {
538            return null;
539        }
540        
541        // First, search the skin name in the request attributes.
542        String skinName = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SKIN_ID);
543        
544        // Then, test if the site name is present as a request attribute to deduce the skin name.
545        if (StringUtils.isEmpty(skinName))
546        {
547            String siteName = WebHelper.getSiteName(request);
548            
549            if (StringUtils.isNotEmpty(siteName))
550            {
551                skinName = _siteManager.getSite(siteName).getSkinId();
552            }
553        }
554        
555        return skinName;
556    }
557    
558    private boolean _isASkinPath(Path skinDir)
559    {
560        if (!Files.exists(skinDir) || !Files.isDirectory(skinDir))
561        {
562            return false;
563        }
564        
565        Path templateDir = skinDir.resolve(Skin.TEMPLATES_PATH);
566        Path confDir = skinDir.resolve(Skin.CONF_PATH);
567        if ((!Files.exists(templateDir) || !Files.isDirectory(templateDir))
568                && (!Files.exists(confDir) || Files.isDirectory(confDir)))
569        {
570            return false;
571        }
572        
573        return true;
574    }
575}