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 */
016package org.ametys.core.file;
017
018import java.io.BufferedReader;
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.InputStreamReader;
024import java.io.OutputStream;
025import java.nio.charset.StandardCharsets;
026import java.nio.file.DirectoryStream;
027import java.nio.file.Files;
028import java.nio.file.Path;
029import java.util.ArrayList;
030import java.util.Collection;
031import java.util.Date;
032import java.util.Enumeration;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.context.Context;
039import org.apache.avalon.framework.context.ContextException;
040import org.apache.avalon.framework.context.Contextualizable;
041import org.apache.avalon.framework.logger.AbstractLogEnabled;
042import org.apache.avalon.framework.service.ServiceException;
043import org.apache.avalon.framework.service.ServiceManager;
044import org.apache.avalon.framework.service.Serviceable;
045import org.apache.cocoon.servlet.multipart.Part;
046import org.apache.cocoon.servlet.multipart.PartOnDisk;
047import org.apache.cocoon.servlet.multipart.RejectedPart;
048import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
049import org.apache.commons.compress.archivers.zip.ZipFile;
050import org.apache.commons.io.FileUtils;
051import org.apache.commons.io.IOUtils;
052import org.apache.commons.io.file.PathUtils;
053import org.apache.commons.lang3.Strings;
054import org.apache.excalibur.source.ModifiableTraversableSource;
055import org.apache.excalibur.source.Source;
056import org.apache.excalibur.source.SourceResolver;
057import org.apache.excalibur.source.SourceUtil;
058import org.apache.excalibur.source.TraversableSource;
059import org.apache.excalibur.source.impl.FileSource;
060import org.apache.tika.mime.MediaType;
061
062import org.ametys.core.user.CurrentUserProvider;
063import org.ametys.core.util.DateUtils;
064
065
066/**
067 * Helper for managing files and folders of a application directory such as
068 * WEB-INF/params
069 */
070public final class FileHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
071{
072    /** The Avalon role name */
073    public static final String ROLE = FileHelper.class.getName();
074
075    /** The current user provider. */
076    protected CurrentUserProvider _currentUserProvider;
077    
078    /** The tika provider */
079    protected TikaProvider _tikaProvider;
080    /** The source resolver */
081    protected SourceResolver _srcResolver;
082
083    private org.apache.cocoon.environment.Context _cocoonContext;
084
085    @Override
086    public void service(ServiceManager serviceManager) throws ServiceException
087    {
088        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
089        _srcResolver = (org.apache.excalibur.source.SourceResolver) serviceManager.lookup(org.apache.excalibur.source.SourceResolver.ROLE);
090        _tikaProvider = (TikaProvider) serviceManager.lookup(TikaProvider.ROLE); 
091    }
092
093    public void contextualize(Context context) throws ContextException
094    {
095        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(org.apache.cocoon.Constants.CONTEXT_ENVIRONMENT_CONTEXT);
096    }
097    
098    /**
099     * Get the files/folders of a given source 
100     * @param rootURI The uri of root folder
101     * @param path The path of folder in root folder
102     * @return the child files and folders as JSON representation
103     * @throws IOException if an error occurred
104     */
105    public List<Map<String, Object>> getFiles(String rootURI, String path) throws IOException
106    {
107        return getFiles(rootURI, path, List.of());
108    }
109    
110    /**
111     * Get files and folders contained in the given path.
112     * @param rootURI The uri of root folder
113     * @param path the relative file's path from files root directory
114     * @param ignoredSources The names of source to ignore
115     * @return the list of parameters files and folders as JSON representation
116     * @throws IOException If an error occurred while listing files
117     */
118    public List<Map<String, Object>> getFiles(String rootURI, String path, List<String> ignoredSources) throws IOException
119    {
120        TraversableSource rootDir = (TraversableSource) _srcResolver.resolveURI(rootURI);
121        TraversableSource currentDir = (TraversableSource) _srcResolver.resolveURI(rootURI + (path.length() > 0 ? "/" + path : ""));
122
123        if (!currentDir.exists() || !currentDir.isCollection())
124        {
125            throw new IOException("The source folder '" + currentDir.getURI() + "' does not exist or is not a folder.");
126        }
127        
128        List<Map<String, Object>> nodes = new ArrayList<>();
129        
130        for (TraversableSource child : (Collection<TraversableSource>) currentDir.getChildren())
131        {
132            if (!ignoredSources.contains(child.getName()))
133            {
134                if (child.isCollection())
135                {
136                    nodes.add(getFolderProperties(child, rootDir));
137                }
138                else
139                {
140                    nodes.add(getFileProperties(child, rootDir));
141                }
142            }
143        }
144        return nodes;
145    }
146    
147    /**
148     * Convert collection to JSON object
149     * @param folder the folder
150     * @param root the root directory
151     * @return JSON object
152     */
153    public Map<String, Object> getFolderProperties (TraversableSource folder, TraversableSource root)
154    {
155        Map<String, Object> jsonObject = new HashMap<>();
156        jsonObject.put("type", "collection");
157        jsonObject.put("name", folder.getName());
158        jsonObject.put("path", _getRelativePath(root, folder));
159        return jsonObject;
160    }
161    
162    /**
163     * Convert file to JSON object
164     * @param file the file
165     * @param root the root directory
166     * @return JSON object
167     */
168    public Map<String, Object> getFileProperties (TraversableSource file, TraversableSource root)
169    {
170        Map<String, Object> jsonObject = new HashMap<>();
171        
172        jsonObject.put("type", "resource");
173        jsonObject.put("name", file.getName());
174        jsonObject.put("path", _getRelativePath(root, file));
175        
176        jsonObject.put("size", file.getContentLength());
177        jsonObject.put("lastModified", DateUtils.dateToString(new Date(file.getLastModified())));
178        
179        String mimeType = _cocoonContext.getMimeType(file.getName().toLowerCase());
180        jsonObject.put("mimetype", mimeType != null ? mimeType : "application/unknown");
181        
182        return jsonObject;
183    }
184    
185    /**
186     * Get the relative path from root directory
187     * @param root The root directory
188     * @param file The file
189     * @return The relative path
190     */
191    protected String _getRelativePath (TraversableSource root, TraversableSource file)
192    {
193        String relPath = file.getURI().substring(root.getURI().length());
194        
195        if (relPath.endsWith("/"))
196        {
197            relPath = relPath.substring(0, relPath.length() - 1);
198        }
199        
200        return relPath;
201    }
202    
203    /**
204     * Saves text to given file in UTF-8 format
205     * 
206     * @param fileURI the file URI. Must point to an existing file.
207     * @param text the UTF-8 file content
208     * @return A result map.
209     * @throws IOException If an error occurred while saving
210     */
211    public Map<String, Object> saveFile(String fileURI, String text) throws IOException
212    {
213        Map<String, Object> result = new HashMap<>();
214
215        ModifiableTraversableSource src = null;
216        try
217        {
218            src = (ModifiableTraversableSource) _srcResolver.resolveURI(fileURI);
219                
220            if (!src.exists())
221            {
222                result.put("success", false);
223                result.put("error", "unknown-file");
224                return result;
225            }
226    
227            if (src.isCollection())
228            {
229                result.put("success", false);
230                result.put("error", "is-not-file");
231                return result;
232            }
233    
234            try (OutputStream os = src.getOutputStream())
235            {
236                IOUtils.write(text, os, StandardCharsets.UTF_8);
237            }
238    
239            if (src.getName().startsWith("messages") && src.getName().endsWith(".xml"))
240            {
241                result.put("isI18n", true);
242            }
243        }
244        finally 
245        {
246            _srcResolver.release(src);
247        }
248
249        result.put("success", true);
250        return result;
251    }
252
253    /**
254     * Create a folder
255     * 
256     * @param parentURI the parent URI, relative to the root
257     * @param name the name of the new folder to create
258     * @param renameIfExists true if the folder have to be renamed if the folder
259     *            with same name already exits.
260     * @return The result Map with the name and uri of created folder, or a
261     *         boolean "success" to false if an error occurs.
262     * @throws IOException If an error occurred adding the folder
263     */
264    public Map<String, Object> addFolder(String parentURI, String name, boolean renameIfExists) throws IOException
265    {
266        Map<String, Object> result = new HashMap<>();
267
268        FileSource parentDir = (FileSource) _srcResolver.resolveURI(parentURI);
269
270        if (!parentDir.isCollection())
271        {
272            result.put("success", false);
273            result.put("error", "is-not-folder");
274            return result;
275        }
276
277        int index = 2;
278        String folderName = name;
279
280        if (!renameIfExists && parentDir.getChild(folderName).exists())
281        {
282            result.put("success", false);
283            result.put("error", "already-exist");
284            return result;
285        }
286
287        while (parentDir.getChild(folderName).exists())
288        {
289            folderName = name + " (" + index + ")";
290            index++;
291        }
292
293        FileSource folder = (FileSource) parentDir.getChild(folderName);
294        folder.makeCollection();
295
296        result.put("success", true);
297        result.put("name", folder.getName());
298        result.put("uri", folder.getURI());
299
300        return result;
301    }
302
303    /**
304     * Add or update a file
305     * 
306     * @param part The file multipart to upload
307     * @param parentDir The parent directory
308     * @param mode The insertion mode: 'add-rename' or 'update' or null.
309     * @param unzip true to unzip .zip file
310     * @return the result map
311     * @throws IOException If an error occurred manipulating the file
312     */
313    public Map<String, Object> addOrUpdateFile(Part part, FileSource parentDir, String mode, boolean unzip) throws IOException
314    {
315        Map<String, Object> result = new HashMap<>();
316
317        if (!(part instanceof PartOnDisk))
318        {
319            result.put("success", false);
320            if (part instanceof RejectedPart rejectedPart && rejectedPart.getMaxContentLength() == 0)
321            {
322                result.put("error", "infected");
323            }
324            else // if (part == null || partUploaded instanceof RejectedPart)
325            {
326                result.put("error", "rejected");
327            }
328            return result;
329        }
330
331        PartOnDisk uploadedFilePart = (PartOnDisk) part;
332        File uploadedFile = uploadedFilePart.getFile();
333
334        String fileName = uploadedFile.getName();
335        FileSource file = (FileSource) parentDir.getChild(fileName);
336        if (fileName.toLowerCase().endsWith(".zip") && unzip)
337        {
338            try
339            {
340                // Unzip the uploaded file
341                ZipFile zipFile = ZipFile.builder().setFile(uploadedFile).setCharset("cp437").get();
342                _unzip(parentDir, zipFile);
343
344                result.put("unzip", true);
345                result.put("success", true);
346                return result;
347            }
348            catch (IOException e)
349            {
350                getLogger().error("Failed to unzip file " + uploadedFile.getPath(), e);
351                result.put("success", false);
352                result.put("error", "unzip-error");
353                return result;
354            }
355        }
356        else if (file.exists())
357        {
358            if ("add-rename".equals(mode))
359            {
360                // Find a new name
361                String[] f = fileName.split("\\.");
362                int index = 1;
363                while (parentDir.getChild(fileName).exists())
364                {
365                    fileName = f[0] + "-" + (index++) + '.' + f[1];
366                }
367
368                file = (FileSource) parentDir.getChild(fileName);
369            }
370            else if (!"update".equals(mode))
371            {
372                result.put("success", false);
373                result.put("error", "already-exist");
374                return result;
375            }
376        }
377        else
378        {
379            file.getFile().createNewFile();
380        }
381
382        InputStream is = new FileInputStream(uploadedFile);
383
384        SourceUtil.copy(is, file.getOutputStream());
385
386        result.put("name", file.getName());
387        result.put("uri", file.getURI());
388        result.put("success", true);
389
390        return result;
391    }
392    
393    private void _unzip(FileSource destSrc, ZipFile zipFile) throws IOException
394    {
395        Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
396        while (entries.hasMoreElements())
397        {
398            FileSource parentCollection = destSrc;
399
400            ZipArchiveEntry zipEntry = entries.nextElement();
401
402            String zipName = zipEntry.getName();
403            String[] path = zipName.split("/");
404
405            for (int i = 0; i < path.length - 1; i++)
406            {
407                String name = path[i];
408                parentCollection = _addCollection(parentCollection, name);
409            }
410
411            String name = path[path.length - 1];
412            if (zipEntry.isDirectory())
413            {
414                parentCollection = _addCollection(parentCollection, name);
415            }
416            else
417            {
418                _addZipEntry(parentCollection, zipFile, zipEntry, name);
419            }
420        }
421    }
422
423    private FileSource _addCollection(FileSource collection, String name) throws IOException
424    {
425        FileSource src = (FileSource) collection.getChild(name);
426        if (!src.exists())
427        {
428            src.makeCollection();
429        }
430
431        return src;
432    }
433
434    private void _addZipEntry(FileSource collection, ZipFile zipFile, ZipArchiveEntry zipEntry, String fileName) throws IOException
435    {
436        FileSource fileSrc = (FileSource) collection.getChild(fileName);
437
438        try (InputStream is = zipFile.getInputStream(zipEntry))
439        {
440            SourceUtil.copy(is, fileSrc.getOutputStream());
441        }
442        catch (IOException e)
443        {
444            // Do nothing
445        }
446    }
447
448    /**
449     * Remove a folder or a file
450     * 
451     * @param fileUri the file/folder URI
452     * @return the result map.
453     * @throws IOException If an error occurs while removing the folder/file
454     */
455    public Map<String, Object> deleteFile(String fileUri) throws IOException
456    {
457        Map<String, Object> result = new HashMap<>();
458
459        FileSource file = (FileSource) _srcResolver.resolveURI(fileUri);
460
461        if (file.exists())
462        {
463            FileUtils.deleteQuietly(file.getFile());
464            result.put("success", true);
465        }
466        else
467        {
468            result.put("success", false);
469            result.put("error", "no-exists");
470        }
471
472        return result;
473    }
474    
475    /**
476     * Delete all files corresponding to the file filter into the file tree.
477     * @param path the path to delete (can be a file or a directory)
478     * @param fileFilter the file filter to apply
479     * @param recursiveDelete if <code>true</code>, the file tree will be explored to delete files
480     * @param deleteEmptyDirs if <code>true</code>, empty dirs will be deleted
481     * @throws IOException if an error occured while exploring or deleting files
482     */
483    public void delete(Path path, DirectoryStream.Filter<Path> fileFilter, boolean recursiveDelete, boolean deleteEmptyDirs) throws IOException
484    {
485        if (Files.isDirectory(path))
486        {
487            if (recursiveDelete)
488            {
489                try (DirectoryStream<Path> entries = Files.newDirectoryStream(path))
490                {
491                    for (Path entry : entries)
492                    {
493                        delete(entry, fileFilter, recursiveDelete, deleteEmptyDirs);
494                    }
495                }
496            }
497            
498            if (deleteEmptyDirs && PathUtils.isEmptyDirectory(path))
499            {
500                Files.delete(path);
501            }
502        }
503        else if (fileFilter.accept(path))
504        {
505            Files.delete(path);
506        }
507    }
508
509    /**
510     * Rename a file or a folder
511     * 
512     * @param fileUri the relative URI of the file or folder to rename
513     * @param name the new name of the file/folder
514     * @return The result Map with the name, path of the renamed file/folder, or
515     *         a boolean "already-exist" is a file/folder already exists with
516     *         this name.
517     * @throws IOException if an error occurs while renaming the file/folder
518     */
519    public Map<String, Object> renameFile(String fileUri, String name) throws IOException
520    {
521        Map<String, Object> result = new HashMap<>();
522
523        FileSource file = (FileSource) _srcResolver.resolveURI(fileUri);
524        FileSource parentDir = (FileSource) file.getParent();
525
526        // Case sensitive exists
527        if (file.getFile().getName().equals(name) && parentDir.getChild(name).exists())
528        {
529            result.put("success", false);
530            result.put("error", "already-exist");
531        }
532        else
533        {
534            Source dest = _srcResolver.resolveURI(parentDir.getURI() + name);
535            file.moveTo(dest);
536
537            result.put("success", true);
538            result.put("uri", parentDir.getURI() + name);
539            result.put("name", name);
540        }
541
542        return result;
543    }
544
545    /**
546     * Tests if a file/folder with given name exists
547     * 
548     * @param parentUri the parent folder URI
549     * @param name the name of the child
550     * @return true if the file exists
551     * @throws IOException if an error occurred
552     */
553    public boolean hasChild(String parentUri, String name) throws IOException
554    {
555        FileSource currentDir = (FileSource) _srcResolver.resolveURI(parentUri);
556        return currentDir.getChild(name).exists();
557    }
558
559    /**
560     * Copy a file or folder
561     * 
562     * @param srcUri The URI of file/folder to copy
563     * @param parentTargetUri The URI of parent target file
564     * @return a result map with the name and uri of copied file in case of
565     *         success.
566     * @throws IOException If an error occured manipulating the source
567     */
568    public Map<String, Object> copySource(String srcUri, String parentTargetUri) throws IOException
569    {
570        Map<String, Object> result = new HashMap<>();
571
572        FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri);
573
574        if (!srcFile.exists())
575        {
576            result.put("success", false);
577            result.put("error", "no-exists");
578            return result;
579        }
580
581        String srcFileName = srcFile.getName();
582        FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (srcFileName.length() > 0 ? "/" + srcFileName : ""));
583
584        // Find unique file name
585        int index = 2;
586        String fileName = srcFileName;
587        while (targetFile.exists())
588        {
589            fileName = srcFileName + " (" + index + ")";
590            targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : ""));
591            index++;
592        }
593
594        if (srcFile.getFile().isDirectory())
595        {
596            FileUtils.copyDirectory(srcFile.getFile(), targetFile.getFile());
597        }
598        else
599        {
600            FileUtils.copyFile(srcFile.getFile(), targetFile.getFile());
601        }
602
603        result.put("success", true);
604        result.put("name", targetFile.getName());
605        result.put("uri", targetFile.getURI());
606
607        return result;
608    }
609
610    /**
611     * Move a file or folder
612     * 
613     * @param srcUri The URI of file/folder to move
614     * @param parentTargetUri The URI of parent target file
615     * @return a result map with the name and uri of moved file in case of
616     *         success.
617     * @throws IOException If an error occurred manipulating the source
618     */
619    public Map<String, Object> moveSource(String srcUri, String parentTargetUri) throws IOException
620    {
621        Map<String, Object> result = new HashMap<>();
622
623        FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri);
624
625        if (!srcFile.exists())
626        {
627            result.put("success", false);
628            result.put("error", "no-exists");
629            return result;
630        }
631
632        FileSource parentDargetDir = (FileSource) _srcResolver.resolveURI(parentTargetUri);
633        String fileName = srcFile.getName();
634        FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : ""));
635
636        if (targetFile.exists())
637        {
638            result.put("msg", "already-exists");
639            return result;
640        }
641
642        FileUtils.moveToDirectory(srcFile.getFile(), parentDargetDir.getFile(), false);
643
644        result.put("success", true);
645        result.put("name", targetFile.getName());
646        result.put("uri", targetFile.getURI());
647
648        return result;
649    }
650    
651    /**
652     * Get the URIs of sources which match filter value. Source are filtered both on their filename and in their content for text file.
653     * The search will be performed on the current source and all its descendants
654     * @param source The source to start search
655     * @param value the value to match
656     * @return the URIs of matching source
657     */
658    public List<String> filterSources(TraversableSource source, String value)
659    {
660        return _filterSources(source, _standardizeValue(value));
661    }
662        
663    private List<String> _filterSources(TraversableSource source, String value)
664    {
665        List<String> matches = new ArrayList<>();
666        if (source.isCollection())
667        {
668            // Check if the collection match
669            if (_sourceNameMatch(source, value))
670            {
671                matches.add(source.getURI());
672            }
673            
674            // Check if any children match
675            try
676            {
677                Collection<TraversableSource> children = source.getChildren();
678                for (TraversableSource child : children)
679                {
680                    matches.addAll(_filterSources(child, value));
681                }
682            }
683            catch (IOException e)
684            {
685                getLogger().error("Failed to retrieve children for source '" + source.getURI() + "'. Potential children will be ignored.");
686            }
687        }
688        else if (_resourceMatch(source, value))
689        {
690            matches.add(source.getURI());
691        }
692        
693        return matches;
694        
695    }
696    // pre-process string before comparison
697    private String _standardizeValue(String value)
698    {
699        return value.toLowerCase();
700    }
701
702    private boolean _resourceMatch(TraversableSource currentSrc, String value)
703    {
704        if (_sourceNameMatch(currentSrc, value))
705        {
706            return true;
707        }
708        else
709        {
710            // detect always return something as "application/octet-stream" if nothing else
711            MediaType mediaType = MediaType.parse(_tikaProvider.getTika().detect(currentSrc.getName()));
712            // Only read the file if its a text file.
713            // We will be able to read line by line that way
714            // without loading all the file at once
715            if (_isSupportedType(mediaType))
716            {
717                try (InputStream is = currentSrc.getInputStream())
718                {
719                    BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); // We just hope its actually UTF-8
720                    String line;
721                    while ((line = reader.readLine()) != null)
722                    {
723                        if (_standardizeValue(line).contains(value))
724                        {
725                            return true;
726                        }
727                    }
728                }
729                catch (IOException e)
730                {
731                    getLogger().error("An error occurred while trying to read the definition file at '" + currentSrc.getURI() + "'", e);
732                }
733            }
734        }
735        return false;
736    }
737
738    private boolean _isSupportedType(MediaType mediaType)
739    {
740        String type = mediaType.getType();
741        if (Strings.CS.equals(type, "text"))
742        {
743            return true;
744        }
745        else if (Strings.CS.equals(type, "application"))
746        {
747            String subtype = mediaType.getSubtype();
748            return Strings.CS.equals(subtype, "xml")
749                || Strings.CS.contains(subtype, "+xml")    // + to avoid matching mybinaryxmltype
750                || Strings.CS.equals(subtype, "json")
751                || Strings.CS.contains(subtype, "+json");  // + to avoid matching mybinaryjsontype
752        }
753        return false;
754    }
755
756    private boolean _sourceNameMatch(TraversableSource currentSrc, String value)
757    {
758        return _standardizeValue(currentSrc.getName()).contains(value);
759    }
760}