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