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