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