001/*
002 *  Copyright 2016 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.core.ui.minimize;
018
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collections;
023import java.util.Comparator;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032import java.util.stream.Collectors;
033
034import org.apache.avalon.framework.configuration.Configurable;
035import org.apache.avalon.framework.configuration.Configuration;
036import org.apache.avalon.framework.configuration.ConfigurationException;
037import org.apache.avalon.framework.context.Context;
038import org.apache.avalon.framework.context.ContextException;
039import org.apache.avalon.framework.context.Contextualizable;
040import org.apache.avalon.framework.parameters.Parameters;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.cocoon.ProcessingException;
044import org.apache.cocoon.components.ContextHelper;
045import org.apache.cocoon.environment.ObjectModelHelper;
046import org.apache.cocoon.environment.Request;
047import org.apache.cocoon.transformation.ServiceableTransformer;
048import org.apache.cocoon.xml.AttributesImpl;
049import org.apache.commons.io.FilenameUtils;
050import org.apache.commons.lang3.StringUtils;
051import org.xml.sax.Attributes;
052import org.xml.sax.SAXException;
053
054import org.ametys.core.DevMode;
055import org.ametys.core.DevMode.DEVMODE;
056import org.ametys.runtime.workspace.WorkspaceMatcher;
057
058/**
059 * This transformer will minimize every scripts together
060 */
061public class MinimizeTransformer extends ServiceableTransformer implements Contextualizable, Configurable
062{
063    private static final String __DEFAULT_URI_PATTERN = "^/plugins/[^/]+/resources/";
064    private static final Set<String> __ALL_MEDIA = Set.of("print", "screen", "speech"); // Keep alphabetical order
065    
066    /** Is developer mode? */
067    protected Boolean _isSuperDevMode;
068    /** The avalon context */
069    protected Context _context;
070    /** The list of patterns available for minimization */
071    protected List<Pattern> _patterns;
072    /** The current local */
073    protected String _locale;
074    /** Should css medias be taken in account? */
075    protected Boolean _inlineCssMedias;
076    /** The current context */
077    protected String _currentContextPath;
078
079    /* Current queue being minified */
080    private Map<String, Map<String, String>> _filesQueue;
081    private String _queueTag;
082    private Set<String> _queueMedia;
083    private boolean _isCurrentTagQueued;
084    
085    private HashCache _hashCache;
086    
087    @Override
088    public void service(ServiceManager smanager) throws ServiceException
089    {
090        super.service(smanager);
091        
092        _hashCache = (HashCache) smanager.lookup(HashCache.ROLE);
093    }
094    
095    @Override
096    public void contextualize(Context context) throws ContextException
097    {
098        _context = context;
099    }
100    
101    @Override
102    public void configure(Configuration configuration) throws ConfigurationException
103    {
104        Configuration paternsConfig = configuration.getChild("patterns");
105        
106        _patterns = new ArrayList<>();
107        for (Configuration paternConfig : paternsConfig.getChildren("pattern"))
108        {
109            Pattern pattern = Pattern.compile(paternConfig.getValue());
110            _patterns.add(pattern);
111        }
112        
113        if (_patterns.isEmpty())
114        {
115            _patterns.add(Pattern.compile(__DEFAULT_URI_PATTERN));
116        }
117        
118        _inlineCssMedias = Boolean.valueOf(configuration.getChild("inline-css-medias").getValue("true"));
119    }
120    
121    /**
122     * Get the url of the minimizer
123     * @return The minimizer url
124     */
125    protected String _getMinimizeUrl()
126    {
127        Request request = ObjectModelHelper.getRequest(objectModel);
128        return request.getContextPath() + StringUtils.defaultString((String) request.getAttribute(WorkspaceMatcher.WORKSPACE_URI)) + "/plugins/core-ui/resources-minimized/";
129    }
130    
131    @Override
132    public void setup(org.apache.cocoon.environment.SourceResolver res, Map obj, String src, Parameters par) throws ProcessingException, SAXException, IOException 
133    {
134        super.setup(res, obj, src, par);
135        
136        Request request = ContextHelper.getRequest(_context);
137        _locale = request.getLocale().getLanguage();
138        _isSuperDevMode = DEVMODE.SUPER_DEVELOPPMENT.equals(DevMode.getDeveloperMode(request));
139        _currentContextPath = request.getContextPath();
140    }
141    
142    @Override
143    public void startDocument() throws SAXException
144    {
145        _filesQueue = new LinkedHashMap<>();
146        _queueTag = "";
147        _isCurrentTagQueued = false;
148        _queueMedia = new HashSet<>();
149        
150        super.startDocument();
151    }
152    
153    @Override
154    public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException
155    {
156        if (!_isSuperDevMode)
157        {
158            if (_isMinimizable(StringUtils.lowerCase(loc), a))
159            {
160                _isCurrentTagQueued = true;
161                _addToQueue(StringUtils.lowerCase(loc), a);
162            }
163            else
164            {
165                _isCurrentTagQueued = false;
166                
167                if (_filesQueue.size() > 0)
168                {
169                    _processQueue();
170                }
171                
172                super.startElement(uri, loc, raw, a);
173            }
174        }
175        else
176        {
177            super.startElement(uri, loc, raw, a);
178        }
179    }
180    
181    @Override
182    public void endElement(String uri, String loc, String raw) throws SAXException
183    {
184        if (!_isSuperDevMode)
185        { 
186            if (_isCurrentTagQueued)
187            {
188                _isCurrentTagQueued = false;
189            }
190            else
191            {
192                if (_filesQueue.size() > 0)
193                {
194                    _processQueue();
195                }
196
197                super.endElement(uri, loc, raw);
198            }
199        }
200        else
201        {
202            super.endElement(uri, loc, raw);
203        }
204    }
205    
206    /**
207     * Check if the current tag can be minimized by this transformer
208     * @param tagName The tag name
209     * @param attrs The attribute
210     * @return True if minimizable
211     */
212    private boolean _isMinimizable(String tagName, Attributes attrs)
213    {
214        if ("true".equals(attrs.getValue("data-donotminimize")))
215        {
216            return false;
217        }
218        
219        String uri = null;
220        
221        if ("script".equals(tagName))
222        {
223            String type = attrs.getValue("type");
224            if (StringUtils.isNotEmpty(type) && !"text/javascript".equals(type) && !"application/javascript".equals(type))
225            {
226                // unsupported script type
227                return false;
228            }
229            
230            uri = attrs.getValue("src");
231        }
232        
233        if ("link".equals(tagName))
234        {
235            String type = attrs.getValue("type");
236            if (StringUtils.isNotEmpty(type) && !"text/css".equals(type))
237            {
238                // unsupported script type
239                return false;
240            }
241            
242            String rel = attrs.getValue("rel");
243            if (!"stylesheet".equals(rel))
244            {
245                // unsupported relation type
246                return false;
247            }
248            
249            uri = attrs.getValue("href");
250        }
251        
252        if (StringUtils.isNotEmpty(uri))
253        {
254            uri = FilenameUtils.normalize(uri, true);
255            uri = _removeContextPath(uri);
256            
257            for (Pattern pattern : _patterns)
258            {
259                Matcher matcher = pattern.matcher(uri);
260                if (matcher.find())
261                {
262                    return true;
263                }
264            }
265        }
266        
267        return false;
268    }
269    
270    /**
271     * Remove the context path from the uri to be able to analyse correctly the patterns
272     * @param uri The incomming uri
273     * @return The uri with no context path
274     */
275    protected String _removeContextPath(String uri)
276    {
277        return StringUtils.prependIfMissing(StringUtils.removeStart(uri, _currentContextPath + "/"), "/");
278    }
279
280    /**
281     * Add a new tag to the current queue. If the new tag is not compatible with the current queue, it is processed first and emptied before adding the new type of tag.
282     * @param tagName The tag name
283     * @param attrs The tag attribute
284     * @throws SAXException If an error occurred
285     */
286    private void _addToQueue(String tagName, Attributes attrs) throws SAXException
287    {
288        Map<String, String> fileOptions = new HashMap<>();
289        
290        if (_filesQueue.size() > 0)
291        {
292            // Check if can be added to current queue. If not, process queue before adding.
293            boolean sameAsCurrentQueue = tagName.equals(_queueTag);
294            
295            if (sameAsCurrentQueue && "link".equals(tagName))
296            {
297                String media = attrs.getValue("media");
298                Set<String> medias = _toMediaList(media);
299                
300                if (_inlineCssMedias || _areAlwaysInlinableMedias(medias))
301                {
302                    // allow different medias in queue, but store the value as file option
303                    fileOptions.put("media", StringUtils.join(medias, ","));
304                }
305                else
306                {
307                    // media must be the same as current queue
308                    sameAsCurrentQueue = medias.equals(_queueMedia);
309                }
310            }
311            
312            if (!sameAsCurrentQueue)
313            {
314                _processQueue();
315            }
316        }
317        
318        String uri = FilenameUtils.normalize("script".equals(tagName) ? attrs.getValue("src") : attrs.getValue("href"), true);
319        
320        if (StringUtils.isNotEmpty(uri))
321        {
322            uri = FilenameUtils.normalize(uri, true);
323            uri = _removeContextPath(uri);
324            
325            fileOptions.put("tag", tagName);
326            _filesQueue.put(uri, fileOptions);
327            if (_filesQueue.size() == 1)
328            {
329                // first item in queue, set the queue attributes for further additions compatibility checks.
330                _queueTag = tagName;
331                
332                if (!_inlineCssMedias && "link".equals(tagName))
333                {
334                    String media = attrs.getValue("media");
335                    _queueMedia = _toMediaList(media);
336                }
337            }
338        }
339    }
340    
341    private boolean _areAlwaysInlinableMedias(Set<String> medias)
342    {
343     // Since IE9, print can be inlined in other media css files
344        return Collections.singleton("print").equals(medias) // The new file is print only 
345                && (_queueMedia.contains("print") || _queueMedia.isEmpty()); // AND The current file has print included (empty media == all)
346    }
347
348    /**
349     * Process the current files queue by saxing it as one file, which name is the hash of the sum of files. 
350     * This hash is cached, to allow the retrieval of the real files from the hash value.
351     * The files parameters, such as the media value of css files, are also stored in the cache.
352     * @throws SAXException If an error occurred
353     */
354    private void _processQueue() throws SAXException
355    {
356        String hash = _hashCache.createHash(_filesQueue, getHashSalt());
357        
358        AttributesImpl attrs = new AttributesImpl();
359        
360        String minimizeUrl = _getMinimizeUrl();
361        
362        if ("script".equals(_queueTag))
363        {
364            attrs.addCDATAAttribute("type", "text/javascript");
365            attrs.addCDATAAttribute("src", minimizeUrl + hash + ".js");
366        }
367        
368        if ("link".equals(_queueTag))
369        {
370            attrs.addCDATAAttribute("type", "text/css");
371            attrs.addCDATAAttribute("rel", "stylesheet");
372            attrs.addCDATAAttribute("href", minimizeUrl + hash + ".css");
373            
374            if (!_inlineCssMedias && !_queueMedia.isEmpty())  
375            {
376                String medias = StringUtils.join(_queueMedia, ",");
377                attrs.addCDATAAttribute("media", medias);
378            }
379        }
380        
381        super.startElement("", _queueTag, _queueTag, attrs);
382        super.endElement("", _queueTag, _queueTag);
383        
384        _queueTag = null;
385        _filesQueue.clear();
386    }
387    
388    /**
389     * Return the salt used by the hash cache
390     * @return The salt string
391     */
392    protected String getHashSalt()
393    {
394        return _locale;
395    }
396    
397    private Set<String> _toMediaList(String media)
398    {
399        Set<String> medias = Arrays.asList(StringUtils.split(StringUtils.defaultString(media, "").toLowerCase(), ",")).stream()
400            .map(String::trim)
401            .sorted(Comparator.naturalOrder())
402            .collect(Collectors.toSet());
403        
404        if (medias.contains("all") || medias.isEmpty() || __ALL_MEDIA.equals(medias))
405        {
406            return Collections.emptySet();
407        }
408
409        return medias;
410    }
411}