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