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