001/*
002 *  Copyright 2019 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.resources.vuejs;
018
019import java.io.BufferedReader;
020import java.io.File;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.InputStreamReader;
024import java.io.OutputStream;
025import java.net.MalformedURLException;
026import java.nio.charset.StandardCharsets;
027import java.util.ArrayList;
028import java.util.Collection;
029import java.util.List;
030import java.util.Map;
031import java.util.regex.Matcher;
032import java.util.regex.Pattern;
033
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.parameters.Parameters;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.cocoon.ProcessingException;
041import org.apache.cocoon.components.ContextHelper;
042import org.apache.cocoon.environment.ObjectModelHelper;
043import org.apache.cocoon.environment.Request;
044import org.apache.cocoon.environment.Response;
045import org.apache.commons.io.IOUtils;
046import org.apache.commons.lang3.ArrayUtils;
047import org.apache.commons.lang3.RegExUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.excalibur.source.Source;
050import org.apache.excalibur.source.SourceException;
051import org.apache.excalibur.source.TraversableSource;
052import org.apache.excalibur.source.impl.FileSource;
053
054import org.ametys.core.resources.ProxiedContextPathProvider;
055import org.ametys.plugins.core.ui.resources.AbstractCompiledResourceHandler;
056
057/**
058 * Resource handler to compile any VueJS resource on the fly if needed, or serve it
059 * The sources have to be located in a directory X/vuejs, while the resources will be sought at X/resources/vuejs 
060 */
061public class VueJsResourceHandler extends AbstractCompiledResourceHandler implements Contextualizable
062{
063    private Context _context;
064    private LocationParser _lp;
065    private ProxiedContextPathProvider _proxiedContextPathProvider;
066
067    /**
068     * Constructor with an already resolved {@link Source}.
069     * @param source the source
070     */
071    public VueJsResourceHandler(Source source)
072    {
073        super(source);
074    }
075    
076    public void contextualize(Context context) throws ContextException
077    {
078        _context = context;
079    }
080    
081    @Override
082    public void service(ServiceManager manager) throws ServiceException
083    {
084        super.service(manager);
085        _proxiedContextPathProvider = (ProxiedContextPathProvider) manager.lookup(ProxiedContextPathProvider.ROLE);
086    }
087
088    @Override
089    public Source setup(String rawLocation, Map objectModel, Parameters parameters, boolean readForDownload) throws IOException, ProcessingException
090    {
091        _requestedLocation = rawLocation;
092        _objectModel = objectModel;
093        _parameters = parameters;
094        _readForDownload = readForDownload;
095        
096        // css files are already minimized files
097        String location = rawLocation.replaceAll("\\.min\\.css$", ".css");
098        
099        // In this implementation, it is not: one source => one target file
100        // We cannot know if a given file will exists after compilation without compiling
101        // So we pre-compile all the time
102        _lp = new LocationParser(location);
103        if (!_lp.matches())
104        {
105            throw new IOException("Path does not match: " + location);
106        }
107        
108        // Special case for source maps: we serve ".vue" files in the source directory
109        Source vueSourceFileSource = _handleSouresFiles();
110        if (vueSourceFileSource != null)
111        {
112            _source = vueSourceFileSource;
113            return vueSourceFileSource;
114        }
115
116        // Now lets do the job
117        Source componentSource = _resolver.resolveURI(_lp.getComponentLocation());
118        Source binarySource = _resolver.resolveURI(_lp.getBinaryLocation());
119        
120        synchronized (this)
121        {
122            // Are compiled files not up-to-date?
123            if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(componentSource))
124            {
125                Source packageJsonSource = _resolver.resolveURI(_lp.getPackageJSONLocation());
126                if (!binarySource.exists() || binarySource.getLastModified() < _getLastModified(packageJsonSource))
127                {
128                    // we cannot get last modification time of node_modules (too long)
129                    // so lets compare if package.json file is newer than last compilation
130                    
131                    try
132                    {
133                        _installDependencies();
134                    }
135                    catch (ProcessingException e)
136                    {
137                        throw new IOException("Error while retrieving dependencies: " + location, e);
138                    }
139                }
140                
141                try
142                {
143                    _compile(binarySource);
144                }
145                catch (ProcessingException e)
146                {
147                    throw new IOException("Compilation error with module: " + location, e);
148                }
149            }
150        }
151        
152        Source binaryResource = _resolver.resolveURI(_lp.getBinaryLocation() + _lp.getVuejsFile());
153        _source = binaryResource;
154        return binaryResource;
155    }
156    
157    private Source _handleSouresFiles() throws MalformedURLException, IOException
158    {
159        if (!StringUtils.equals(_lp.getComponent(), "/_components") && !StringUtils.endsWith(_lp.getVuejsFile(), ".vue") && !StringUtils.endsWith(_lp.getVuejsFile(), "/main.js"))
160        {
161            return null;
162        }
163        
164        // Fix a chrome issue with sourcemaps file and cache...
165        Response response = ContextHelper.getResponse(_context);
166        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
167        response.setDateHeader("Expires", 0);
168        
169        // the real path is artificially prefixed by the resource type to bypass browser issues
170        String file = _lp.getVuejsFile();
171        file = file.substring(file.indexOf('/', 1)); // file is prefixed by /css or /js 
172        
173        String root = _lp.getSourceLocation();
174        if (StringUtils.equals(_lp.getComponent(), "/_components"))
175        {
176            // for common sources, there's no 'src' folder
177            root = StringUtils.removeEnd(root, "/src");
178        }
179        
180        // This is related to sourcemap
181        Source originalResource = _resolver.resolveURI(root + file);
182        return originalResource;
183    }
184    
185    private void _executeCommandeLine(String[] command, File directory, String successLog, String errorKeyword, String errorLog) throws IOException, ProcessingException
186    {
187        // We cannot execute directly "vue", because java do not take in the path env var
188        String[] cmdPrefix = System.getProperty("os.name").toLowerCase().startsWith("windows") ? new String[] {"cmd", "/c"} : new String[] {};
189        
190        Process process = Runtime.getRuntime().exec(ArrayUtils.addAll(cmdPrefix, command), null, directory);
191        
192        List<String> out = new ArrayList<>();
193        ReadStream inputStreamReader = new ReadStream(process.getInputStream(), out);
194        ReadStream errorStreamReader = new ReadStream(process.getErrorStream(), out);
195        Thread inputThread = new Thread(inputStreamReader);
196        Thread errorThread = new Thread(errorStreamReader);
197        inputThread.start();
198        errorThread.start();
199        
200        try
201        {
202            int exitCode = process.waitFor();
203            
204            // We cannot get the process exit code since these executables always returns 0...
205            // We cannot check if errorStreamReader.isEmpty() since it is not
206            // So let's search the ERROR keyword
207            String outAsString = StringUtils.join(out, '\n');
208            
209            if (exitCode != 0 || outAsString.contains(errorKeyword)) 
210            {
211                throw new ProcessingException(errorLog + "\n" + outAsString);
212            }
213            else if (getLogger().isInfoEnabled())
214            {
215                getLogger().info(successLog + "\n" + outAsString);
216            }
217        }
218        catch (InterruptedException e)
219        {
220            throw new ProcessingException(e);
221        }
222    }
223
224    private void _compile(Source binarySource) throws IOException, ProcessingException
225    {
226        if (getLogger().isInfoEnabled())
227        {
228            getLogger().info("Compiling " + _lp.getSourceLocation());
229        }
230        
231        File sourceDir = ((FileSource) _resolver.resolveURI(_lp.getSourceLocation())).getFile();
232        File outputDir = ((FileSource) binarySource).getFile();
233        outputDir.delete();
234        _executeCommandeLine(new String[] {"vue", "build", "--target", "lib", "--dest", outputDir.getAbsolutePath()}, 
235                            sourceDir, 
236                            "Compilation of " + _lp.getSourceLocation() + " ended with", 
237                            "ERROR", 
238                            "Could not compile " + _lp.getSourceLocation() + " due to:");
239    }
240    
241    private void _installDependencies() throws IOException, ProcessingException
242    {
243        if (getLogger().isInfoEnabled())
244        {
245            getLogger().info("Getting dependencies of " + _lp.getComponentLocation());
246        }
247        
248        File sourceDir = ((FileSource) _resolver.resolveURI(_lp.getComponentLocation())).getFile();
249        _executeCommandeLine(new String[] {"npm", "install"},
250                            sourceDir,
251                            "Installing dependencies of " + _lp.getComponentLocation() + " ended with:",
252                            "ERR!",
253                            "Could not get dependencies " + _lp.getComponentLocation() + " due to:");
254    }
255    
256    @Override
257    public void generate(OutputStream out) throws IOException, ProcessingException
258    {
259        if (_source.getURI().endsWith(".map"))
260        {
261            // Sourcemap url needs to be adapted
262            Request request = ObjectModelHelper.getRequest(_objectModel);
263            
264            String currentURI = request.getSitemapURI();
265            String currentPrefix = StringUtils.substringBefore(currentURI, "/vuejs");
266            
267            String sourceUri = currentURI.substring(0, currentURI.lastIndexOf('.')); // strip trailing .map
268            String ext = sourceUri.substring(sourceUri.lastIndexOf('.') + 1);
269            
270            String componentPrefix = _proxiedContextPathProvider.getContextPath() + "/" + currentPrefix + "/vuejs" + _lp.getComponent() + "/" + ext;
271            String commonPrefix = _proxiedContextPathProvider.getContextPath() + "/" + currentPrefix + "/vuejs/_components/" + ext;
272            
273            try (InputStream is = _source.getInputStream())
274            {
275                // all following regexp aims to clean webpack-generated source maps
276                String mapContent = IOUtils.toString(is, StandardCharsets.UTF_8);
277                String mapContentFixed = RegExUtils.replaceAll(mapContent, "\"webpack://[^.\"]*(/vueserve/[^?\"]*\")", "\"" + componentPrefix + "$1"); // our sources from the component
278                
279                if (ext.equals("js"))
280                {
281                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/\\.\\./_components(/[^?\"]*\\.vue\")", "\"" + commonPrefix + "$1"); // our common .vue
282                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/_components(/[^?\"]*\\.js\")", "\"" + commonPrefix + "$1"); // our common .js
283                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*(/main\\.js\")", "\"" + componentPrefix + "$1"); // our main.js
284                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://main/external \\\\\"[^\"]*\\\\\"\"", "\"unused\""); // external dependencies
285                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/node_modules/([^\"]*/)([^\"]*\\.js\")", "\"sources-unavailable://$1source-unavailable-$2"); // internal dependencies (node_modules)
286                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*\"", "\"unused\""); // other webpack-generated artefacts
287                }
288                else if (ext.equals("css"))
289                {
290                    mapContentFixed = RegExUtils.replaceAll(mapContentFixed, "\"webpack://[^\"]*/_components(/[^?\"]*\\.vue\")", "\"" + commonPrefix + "$1"); // our common .vue
291                }
292                
293                IOUtils.write(mapContentFixed, out, StandardCharsets.UTF_8);
294            }
295        }
296        else
297        {
298            super.generate(out);
299        }
300    }
301
302    @Override
303    public long getLastModified()
304    {
305        return _getLastModified(_source);
306    }
307
308    // Recursively get the most recent last modified date
309    private long _getLastModified(Source inputSource)
310    {
311        long result = inputSource.getLastModified();
312        
313        if (_isSourceDirectory(inputSource))
314        {
315            TraversableSource folder = (TraversableSource) inputSource;
316            try
317            {
318                Collection<Source> children = folder.getChildren();
319                for (Source child : children)
320                {
321                    result = Math.max(result, _getLastModified(child));
322                }
323            }
324            catch (SourceException e)
325            {
326                getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e);
327            }
328            
329        }
330        
331        return result;
332    }
333    
334    @Override
335    protected List<String> getDependenciesList(Source inputSource)
336    {
337        List<String> dependencies = new ArrayList<>();
338
339        if (_isSourceDirectory(inputSource))
340        {
341            TraversableSource folder = (TraversableSource) inputSource;
342            try
343            {
344                Collection<Source> children = folder.getChildren();
345                for (Source child : children)
346                {
347                    dependencies.add(child.getURI());
348                }
349            }
350            catch (SourceException e)
351            {
352                getLogger().warn("Cannot get dependencies files of source " + inputSource.getURI(), e);
353            }
354        }
355        
356        return dependencies;
357    }
358
359    private boolean _isSourceDirectory(Source inputSource)
360    {
361        return inputSource instanceof TraversableSource && ((TraversableSource) inputSource).isCollection() && !inputSource.getURI().endsWith("/node_modules/") && !inputSource.getURI().endsWith("/dist/");
362    }
363
364    static final class LocationParser
365    {
366        private static final Pattern __LOCATION = Pattern.compile("^(.*)(/[^/]*)(/vuejs)(/[^/]*)(/.*)$");
367        private static final String __OUTPUT_DIR = "/dist";
368        private static final String __SOURCE_DIR = "/src";
369        private static final String __PACKAGE_JSON_FILE = File.separator + "package.json";
370        
371        private String _location;
372        private String _mainLocation;
373        private String _parentFolder;
374        private String _vuejsDirectoryName;
375        private String _vuejsComponentName;
376        private String _vuejsFile;
377
378        LocationParser(String location)
379        {
380            _location = location;
381            
382            Matcher matcher = __LOCATION.matcher(location);
383            if (matcher.matches())
384            {
385                // cocoon://plugins/test/resources/vuejs/mycomponent/main.js
386                _mainLocation = matcher.group(1);       // cocoon://plugins/test
387                _parentFolder = matcher.group(2);       // /resources
388                _vuejsDirectoryName = matcher.group(3); // /vuesjs
389                _vuejsComponentName = matcher.group(4); // /mycomponent
390                _vuejsFile = matcher.group(5);          // /main.js
391            }
392        }
393        
394        boolean matches()
395        {
396            return _mainLocation != null;
397        }
398        
399        String getLocation()
400        {
401            return _location;
402        }
403        
404        String getParentFolder()
405        {
406            return _parentFolder;
407        }
408        
409        String getComponent()
410        {
411            return _vuejsComponentName;
412        }
413        
414        String getComponentLocation()
415        {
416            return _mainLocation + _vuejsDirectoryName + _vuejsComponentName;
417        }
418        
419        String getPackageJSONLocation()
420        {
421            return getComponentLocation() + __PACKAGE_JSON_FILE;
422        }
423        
424        String getVuejsFile()
425        {
426            return _vuejsFile;
427        }
428        
429        String getBinaryLocation()
430        {
431            return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __OUTPUT_DIR;
432        }
433        
434        String getSourceLocation()
435        {
436            return _mainLocation + _vuejsDirectoryName + _vuejsComponentName + __SOURCE_DIR;
437        }
438    }
439    
440    class ReadStream implements Runnable 
441    {
442        private final InputStream _inputStream;
443        private final List<String> _sf;
444        private boolean _isEmpty;
445
446        ReadStream(InputStream inputStream, List<String> sf) 
447        {
448            _inputStream = inputStream;
449            _sf = sf;
450            _isEmpty = true;
451        }
452
453        private BufferedReader getBufferedReader(InputStream is) 
454        {
455            return new BufferedReader(new InputStreamReader(is));
456        }
457
458        @Override
459        public void run() 
460        {
461            BufferedReader br = getBufferedReader(_inputStream);
462            String line = "";
463            try 
464            {
465                while ((line = br.readLine()) != null) 
466                {
467                    _sf.add(line);
468                    _isEmpty = false;
469                }
470            } 
471            catch (IOException e) 
472            {
473                e.printStackTrace();
474            }
475        }
476        
477        boolean isEmpty()
478        {
479            return _isEmpty;
480        }
481    }
482}