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.StringUtils;
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                _unzip(parentDir, new ZipFile(uploadedFile, "cp437"));
342
343                result.put("unzip", true);
344                result.put("success", true);
345                return result;
346            }
347            catch (IOException e)
348            {
349                getLogger().error("Failed to unzip file " + uploadedFile.getPath(), e);
350                result.put("success", false);
351                result.put("error", "unzip-error");
352                return result;
353            }
354        }
355        else if (file.exists())
356        {
357            if ("add-rename".equals(mode))
358            {
359                // Find a new name
360                String[] f = fileName.split("\\.");
361                int index = 1;
362                while (parentDir.getChild(fileName).exists())
363                {
364                    fileName = f[0] + "-" + (index++) + '.' + f[1];
365                }
366
367                file = (FileSource) parentDir.getChild(fileName);
368            }
369            else if (!"update".equals(mode))
370            {
371                result.put("success", false);
372                result.put("error", "already-exist");
373                return result;
374            }
375        }
376        else
377        {
378            file.getFile().createNewFile();
379        }
380
381        InputStream is = new FileInputStream(uploadedFile);
382
383        SourceUtil.copy(is, file.getOutputStream());
384
385        result.put("name", file.getName());
386        result.put("uri", file.getURI());
387        result.put("success", true);
388
389        return result;
390    }
391    
392    private void _unzip(FileSource destSrc, ZipFile zipFile) throws IOException
393    {
394        Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
395        while (entries.hasMoreElements())
396        {
397            FileSource parentCollection = destSrc;
398
399            ZipArchiveEntry zipEntry = entries.nextElement();
400
401            String zipName = zipEntry.getName();
402            String[] path = zipName.split("/");
403
404            for (int i = 0; i < path.length - 1; i++)
405            {
406                String name = path[i];
407                parentCollection = _addCollection(parentCollection, name);
408            }
409
410            String name = path[path.length - 1];
411            if (zipEntry.isDirectory())
412            {
413                parentCollection = _addCollection(parentCollection, name);
414            }
415            else
416            {
417                _addZipEntry(parentCollection, zipFile, zipEntry, name);
418            }
419        }
420    }
421
422    private FileSource _addCollection(FileSource collection, String name) throws IOException
423    {
424        FileSource src = (FileSource) collection.getChild(name);
425        if (!src.exists())
426        {
427            src.makeCollection();
428        }
429
430        return src;
431    }
432
433    private void _addZipEntry(FileSource collection, ZipFile zipFile, ZipArchiveEntry zipEntry, String fileName) throws IOException
434    {
435        FileSource fileSrc = (FileSource) collection.getChild(fileName);
436
437        try (InputStream is = zipFile.getInputStream(zipEntry))
438        {
439            SourceUtil.copy(is, fileSrc.getOutputStream());
440        }
441        catch (IOException e)
442        {
443            // Do nothing
444        }
445    }
446
447    /**
448     * Remove a folder or a file
449     * 
450     * @param fileUri the file/folder URI
451     * @return the result map.
452     * @throws IOException If an error occurs while removing the folder/file
453     */
454    public Map<String, Object> deleteFile(String fileUri) throws IOException
455    {
456        Map<String, Object> result = new HashMap<>();
457
458        FileSource file = (FileSource) _srcResolver.resolveURI(fileUri);
459
460        if (file.exists())
461        {
462            FileUtils.deleteQuietly(file.getFile());
463            result.put("success", true);
464        }
465        else
466        {
467            result.put("success", false);
468            result.put("error", "no-exists");
469        }
470
471        return result;
472    }
473    
474    /**
475     * Delete all files corresponding to the file filter into the file tree.
476     * @param path the path to delete (can be a file or a directory)
477     * @param fileFilter the file filter to apply
478     * @param recursiveDelete if <code>true</code>, the file tree will be explored to delete files
479     * @param deleteEmptyDirs if <code>true</code>, empty dirs will be deleted
480     * @throws IOException if an error occured while exploring or deleting files
481     */
482    public void delete(Path path, DirectoryStream.Filter<Path> fileFilter, boolean recursiveDelete, boolean deleteEmptyDirs) throws IOException
483    {
484        if (Files.isDirectory(path))
485        {
486            if (recursiveDelete)
487            {
488                try (DirectoryStream<Path> entries = Files.newDirectoryStream(path))
489                {
490                    for (Path entry : entries)
491                    {
492                        delete(entry, fileFilter, recursiveDelete, deleteEmptyDirs);
493                    }
494                }
495            }
496            
497            if (deleteEmptyDirs && PathUtils.isEmptyDirectory(path))
498            {
499                Files.delete(path);
500            }
501        }
502        else if (fileFilter.accept(path))
503        {
504            Files.delete(path);
505        }
506    }
507
508    /**
509     * Rename a file or a folder
510     * 
511     * @param fileUri the relative URI of the file or folder to rename
512     * @param name the new name of the file/folder
513     * @return The result Map with the name, path of the renamed file/folder, or
514     *         a boolean "already-exist" is a file/folder already exists with
515     *         this name.
516     * @throws IOException if an error occurs while renaming the file/folder
517     */
518    public Map<String, Object> renameFile(String fileUri, String name) throws IOException
519    {
520        Map<String, Object> result = new HashMap<>();
521
522        FileSource file = (FileSource) _srcResolver.resolveURI(fileUri);
523        FileSource parentDir = (FileSource) file.getParent();
524
525        // Case sensitive exists
526        if (file.getFile().getName().equals(name) && parentDir.getChild(name).exists())
527        {
528            result.put("success", false);
529            result.put("error", "already-exist");
530        }
531        else
532        {
533            Source dest = _srcResolver.resolveURI(parentDir.getURI() + name);
534            file.moveTo(dest);
535
536            result.put("success", true);
537            result.put("uri", parentDir.getURI() + name);
538            result.put("name", name);
539        }
540
541        return result;
542    }
543
544    /**
545     * Tests if a file/folder with given name exists
546     * 
547     * @param parentUri the parent folder URI
548     * @param name the name of the child
549     * @return true if the file exists
550     * @throws IOException if an error occurred
551     */
552    public boolean hasChild(String parentUri, String name) throws IOException
553    {
554        FileSource currentDir = (FileSource) _srcResolver.resolveURI(parentUri);
555        return currentDir.getChild(name).exists();
556    }
557
558    /**
559     * Copy a file or folder
560     * 
561     * @param srcUri The URI of file/folder to copy
562     * @param parentTargetUri The URI of parent target file
563     * @return a result map with the name and uri of copied file in case of
564     *         success.
565     * @throws IOException If an error occured manipulating the source
566     */
567    public Map<String, Object> copySource(String srcUri, String parentTargetUri) throws IOException
568    {
569        Map<String, Object> result = new HashMap<>();
570
571        FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri);
572
573        if (!srcFile.exists())
574        {
575            result.put("success", false);
576            result.put("error", "no-exists");
577            return result;
578        }
579
580        String srcFileName = srcFile.getName();
581        FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (srcFileName.length() > 0 ? "/" + srcFileName : ""));
582
583        // Find unique file name
584        int index = 2;
585        String fileName = srcFileName;
586        while (targetFile.exists())
587        {
588            fileName = srcFileName + " (" + index + ")";
589            targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : ""));
590            index++;
591        }
592
593        if (srcFile.getFile().isDirectory())
594        {
595            FileUtils.copyDirectory(srcFile.getFile(), targetFile.getFile());
596        }
597        else
598        {
599            FileUtils.copyFile(srcFile.getFile(), targetFile.getFile());
600        }
601
602        result.put("success", true);
603        result.put("name", targetFile.getName());
604        result.put("uri", targetFile.getURI());
605
606        return result;
607    }
608
609    /**
610     * Move a file or folder
611     * 
612     * @param srcUri The URI of file/folder to move
613     * @param parentTargetUri The URI of parent target file
614     * @return a result map with the name and uri of moved file in case of
615     *         success.
616     * @throws IOException If an error occurred manipulating the source
617     */
618    public Map<String, Object> moveSource(String srcUri, String parentTargetUri) throws IOException
619    {
620        Map<String, Object> result = new HashMap<>();
621
622        FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri);
623
624        if (!srcFile.exists())
625        {
626            result.put("success", false);
627            result.put("error", "no-exists");
628            return result;
629        }
630
631        FileSource parentDargetDir = (FileSource) _srcResolver.resolveURI(parentTargetUri);
632        String fileName = srcFile.getName();
633        FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : ""));
634
635        if (targetFile.exists())
636        {
637            result.put("msg", "already-exists");
638            return result;
639        }
640
641        FileUtils.moveToDirectory(srcFile.getFile(), parentDargetDir.getFile(), false);
642
643        result.put("success", true);
644        result.put("name", targetFile.getName());
645        result.put("uri", targetFile.getURI());
646
647        return result;
648    }
649    
650    /**
651     * Get the URIs of sources which match filter value. Source are filtered both on their filename and in their content for text file.
652     * The search will be performed on the current source and all its descendants
653     * @param source The source to start search
654     * @param value the value to match
655     * @return the URIs of matching source
656     */
657    public List<String> filterSources(TraversableSource source, String value)
658    {
659        return _filterSources(source, _standardizeValue(value));
660    }
661        
662    private List<String> _filterSources(TraversableSource source, String value)
663    {
664        List<String> matches = new ArrayList<>();
665        if (source.isCollection())
666        {
667            // Check if the collection match
668            if (_sourceNameMatch(source, value))
669            {
670                matches.add(source.getURI());
671            }
672            
673            // Check if any children match
674            try
675            {
676                Collection<TraversableSource> children = source.getChildren();
677                for (TraversableSource child : children)
678                {
679                    matches.addAll(_filterSources(child, value));
680                }
681            }
682            catch (IOException e)
683            {
684                getLogger().error("Failed to retrieve children for source '" + source.getURI() + "'. Potential children will be ignored.");
685            }
686        }
687        else if (_resourceMatch(source, value))
688        {
689            matches.add(source.getURI());
690        }
691        
692        return matches;
693        
694    }
695    // pre-process string before comparison
696    private String _standardizeValue(String value)
697    {
698        return value.toLowerCase();
699    }
700
701    private boolean _resourceMatch(TraversableSource currentSrc, String value)
702    {
703        if (_sourceNameMatch(currentSrc, value))
704        {
705            return true;
706        }
707        else
708        {
709            // detect always return something as "application/octet-stream" if nothing else
710            MediaType mediaType = MediaType.parse(_tikaProvider.getTika().detect(currentSrc.getName()));
711            // Only read the file if its a text file.
712            // We will be able to read line by line that way
713            // without loading all the file at once
714            if (_isSupportedType(mediaType))
715            {
716                try (InputStream is = currentSrc.getInputStream())
717                {
718                    BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); // We just hope its actually UTF-8
719                    String line;
720                    while ((line = reader.readLine()) != null)
721                    {
722                        if (_standardizeValue(line).contains(value))
723                        {
724                            return true;
725                        }
726                    }
727                }
728                catch (IOException e)
729                {
730                    getLogger().error("An error occurred while trying to read the definition file at '" + currentSrc.getURI() + "'", e);
731                }
732            }
733        }
734        return false;
735    }
736
737    private boolean _isSupportedType(MediaType mediaType)
738    {
739        String type = mediaType.getType();
740        if (StringUtils.equals(type, "text"))
741        {
742            return true;
743        }
744        else if (StringUtils.equals(type, "application"))
745        {
746            String subtype = mediaType.getSubtype();
747            return StringUtils.equals(subtype, "xml")
748                || StringUtils.contains(subtype, "+xml")    // + to avoid matching mybinaryxmltype
749                || StringUtils.equals(subtype, "json")
750                || StringUtils.contains(subtype, "+json");  // + to avoid matching mybinaryjsontype
751        }
752        return false;
753    }
754
755    private boolean _sourceNameMatch(TraversableSource currentSrc, String value)
756    {
757        return _standardizeValue(currentSrc.getName()).contains(value);
758    }
759}