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