001/*
002 *  Copyright 2016 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.plugins.explorer.resources.actions;
017
018import java.io.File;
019import java.io.FileInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.text.Normalizer;
023import java.text.Normalizer.Form;
024import java.util.Collections;
025import java.util.Enumeration;
026import java.util.HashMap;
027import java.util.LinkedHashMap;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Map;
031import java.util.function.Function;
032import java.util.stream.Collectors;
033
034import org.apache.avalon.framework.component.Component;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.cocoon.servlet.multipart.Part;
039import org.apache.cocoon.servlet.multipart.PartOnDisk;
040import org.apache.cocoon.servlet.multipart.RejectedPart;
041import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
042import org.apache.commons.compress.archivers.zip.ZipFile;
043import org.apache.commons.lang.IllegalClassException;
044
045import org.ametys.core.observation.Event;
046import org.ametys.core.observation.ObservationManager;
047import org.ametys.core.user.CurrentUserProvider;
048import org.ametys.plugins.explorer.ObservationConstants;
049import org.ametys.plugins.explorer.resources.ModifiableResource;
050import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
051import org.ametys.plugins.explorer.resources.Resource;
052import org.ametys.plugins.repository.AmetysObject;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.UnknownAmetysObjectException;
055import org.ametys.runtime.plugin.component.AbstractLogEnabled;
056
057import com.google.common.collect.Ordering;
058
059/**
060 * Dedicated helper in order to add or update an explorer resource
061 */
062public final class AddOrUpdateResourceHelper extends AbstractLogEnabled implements Component, Serviceable
063{
064    /** The Avalon role name */
065    public static final String ROLE = AddOrUpdateResourceHelper.class.getName();
066    
067    /** The resource DAO */
068    protected ExplorerResourcesDAO _resourcesDAO;
069    /** The ametys resolver */
070    protected AmetysObjectResolver _resolver;
071    
072    /** The current user provider. */
073    protected CurrentUserProvider _currentUserProvider;
074    
075    /** Observer manager. */
076    protected ObservationManager _observationManager;
077    
078    public void service(ServiceManager serviceManager) throws ServiceException
079    {
080        _resourcesDAO = (ExplorerResourcesDAO) serviceManager.lookup(ExplorerResourcesDAO.ROLE);
081        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
082        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
083        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
084    }
085    
086    /**
087     *  Possible add and update modes
088     */
089    public enum ResourceOperationMode
090    {
091        /** Add */
092        ADD("add"),
093        /** Add with an unzip */
094        ADD_UNZIP("add-unzip"),
095        /** Add and allow rename */
096        ADD_RENAME("add-rename"),
097        /** Update */
098        UPDATE("update");
099        
100        private String _mode;
101        
102        private ResourceOperationMode(String mode)
103        {
104            _mode = mode;
105        }  
106           
107        @Override
108        public String toString()
109        {
110            return _mode;
111        }
112        
113        /**
114         * Converts an raw input mode to the corresponding ResourceOperationMode
115         * @param mode The raw mode to convert
116         * @return the corresponding ResourceOperationMode or null if unknown
117         */
118        public static ResourceOperationMode createsFromRawMode(String mode)
119        {
120            for (ResourceOperationMode entry : ResourceOperationMode.values())
121            {
122                if (entry.toString().equals(mode))
123                {
124                    return entry;
125                }
126            }
127            return null;
128        }
129    }
130    
131    /**
132     * Perform an add or update resource operation
133     * @param part The part representing the file for this operation
134     * @param parentId The identifier of the parent collection
135     * @param mode The operation mode
136     * @return the result of the operation
137     */
138    public ResourceOperationResult performResourceOperation(Part part, String parentId, ResourceOperationMode mode)
139    {
140        try
141        {
142            AmetysObject object = _resolver.resolveById(parentId);
143            if (!(object instanceof ModifiableResourceCollection))
144            {
145                throw new IllegalClassException(ModifiableResourceCollection.class, object.getClass());
146            }
147            
148            return performResourceOperation(part, (ModifiableResourceCollection) object, mode);
149        }
150        catch (UnknownAmetysObjectException e)
151        {
152            getLogger().error("Unable to add file : the collection of id '{}' does not exist anymore", parentId, e);
153            return new ResourceOperationResult("unknown-collection");
154        }
155    }
156    
157    /**
158     * Perform an add or update resource operation
159     * @param part The part representing the file for this operation
160     * @param parent The parent collection
161     * @param mode The operation mode
162     * @return the result of the operation
163     */
164    public ResourceOperationResult performResourceOperation(Part part, ModifiableResourceCollection parent, ResourceOperationMode mode)
165    {
166        if (part instanceof RejectedPart || part == null)
167        {
168            return new ResourceOperationResult("rejected");
169        }
170        
171        PartOnDisk uploadedFilePart = (PartOnDisk) part;
172        return performResourceOperation(uploadedFilePart.getFile(), parent, mode);
173    }
174    
175    /**
176     * Perform an add or update resource operation
177     * @param file The file for this operation
178     * @param parent The parent collection
179     * @param mode The operation mode
180     * @return the result of the operation
181     */
182    public ResourceOperationResult performResourceOperation(File file, ModifiableResourceCollection parent, ResourceOperationMode mode)
183    {
184        // FIXME CMS-2297
185        // checkUserRight(parent, "Plugin_Explorer_File_Add");
186        
187        if (!_resourcesDAO.checkLock(parent))
188        {
189            getLogger().warn("User '{}' is trying to modify the collection '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
190            return new ResourceOperationResult("locked");
191        }
192        
193        String fileName = file.getName();
194        
195        // Unzip
196        if (fileName.toLowerCase().endsWith(".zip") && ResourceOperationMode.ADD_UNZIP.equals(mode))
197        {
198            return _unzip(parent, file);
199        }
200        else
201        {
202            try (InputStream is = new FileInputStream(file))
203            {
204                return performResourceOperation(is, fileName, parent, mode);
205            }
206            catch (Exception e)
207            {
208                getLogger().error("Unable to add file to the collection of id '{}'", parent.getId(), e);
209                return new ResourceOperationResult("error");
210            }
211        }
212    }
213    
214    /**
215     * Perform an add or update resource operation
216     * @param inputStream The data for this operation
217     * @param fileName file name requested
218     * @param parent The parent collection
219     * @param mode The operation mode
220     * @return the result of the operation
221     */
222    public ResourceOperationResult performResourceOperation(InputStream inputStream, String fileName, ModifiableResourceCollection parent, ResourceOperationMode mode)
223    {
224        // FIXME CMS-2297
225        // checkUserRight(parent, "Plugin_Explorer_File_Add");
226        String usedFileName = fileName;
227        
228        if (!Normalizer.isNormalized(usedFileName, Form.NFC))
229        {
230            usedFileName = Normalizer.normalize(usedFileName, Form.NFC);
231        }
232        
233        if (!_resourcesDAO.checkLock(parent))
234        {
235            getLogger().warn("User '{}' is trying to modify the collection '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
236            return new ResourceOperationResult("locked");
237        }
238        
239        ModifiableResource resource = null;
240        
241        // Rename existing
242        if (parent.hasChild(usedFileName))
243        {
244            if (ResourceOperationMode.ADD_RENAME.equals(mode))
245            {
246                // Find a new name
247                String[] f = usedFileName.split("\\.");
248                int index = 1;
249                while (parent.hasChild(usedFileName))
250                {
251                    usedFileName = f[0] + "-" + (index++) + '.' + f[1];
252                }
253                resource = _resourcesDAO.createResource(parent, usedFileName);
254            }
255            else if (ResourceOperationMode.UPDATE.equals(mode))
256            {
257                resource = parent.getChild(usedFileName);
258            }
259            else 
260            {
261                return new ResourceOperationResult("already-exist");
262            }
263        }
264        // Add
265        else
266        {
267            resource = _resourcesDAO.createResource(parent, usedFileName);
268        }
269        
270        try
271        {
272            if (!_resourcesDAO.checkLock(resource))
273            {
274                getLogger().warn("User '{}' is trying to modify the resource '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
275                return new ResourceOperationResult("locked-file");
276            }
277            
278            _resourcesDAO.updateResource(resource, inputStream, fileName);
279            parent.saveChanges();
280            _resourcesDAO.checkpoint(resource);
281        }
282        catch (Exception e)
283        {
284            getLogger().error("Unable to add file to the collection of id '{}'", parent.getId(), e);
285            return new ResourceOperationResult("error");
286        }
287        
288        // Notify listeners
289        if (ResourceOperationMode.UPDATE.equals(mode))
290        {
291            _notifyResourcesUpdated(parent, resource);
292        }
293        else
294        {
295            _notifyResourcesCreated(parent, Collections.singletonList(resource));
296        }
297        
298        return new ResourceOperationResult(resource);
299    }
300    
301    /**
302     * Fire the {@link ObservationConstants#EVENT_RESOURCE_CREATED} event
303     * @param parent The parent collection of the resource
304     * @param resources The created resources
305     */
306    protected void _notifyResourcesCreated(ModifiableResourceCollection parent, List<Resource> resources)
307    {
308        Map<String, Object> eventParams = new HashMap<>();
309        
310        // ARGS_RESOURCES (transform to a map while keeping iteration order)
311        Map<String, Resource> resourceMap = resources.stream()
312                .collect(Collectors.toMap(
313                    Resource::getId, // key = id
314                    Function.identity(), // value = resource
315                    (u, v) -> u, // allow duplicates
316                    LinkedHashMap::new // to respect iteration order
317                ));
318        
319        eventParams.put(ObservationConstants.ARGS_RESOURCES, resourceMap);
320        
321        eventParams.put(ObservationConstants.ARGS_PARENT_ID, parent.getId());
322        eventParams.put(ObservationConstants.ARGS_PARENT_PATH, parent.getPath());
323        
324        _observationManager.notify(new Event(ObservationConstants.EVENT_RESOURCE_CREATED, _currentUserProvider.getUser(), eventParams));
325    }
326    
327    /**
328     * Fire the {@link ObservationConstants#EVENT_RESOURCE_UPDATED} event
329     * @param parent The parent collection of the resource
330     * @param resource The updated resource
331     */
332    protected void _notifyResourcesUpdated(ModifiableResourceCollection parent, Resource resource)
333    {
334        Map<String, Object> eventParams = new HashMap<>();
335        
336        eventParams.put(ObservationConstants.ARGS_ID, resource.getId());
337        eventParams.put(ObservationConstants.ARGS_NAME, resource.getName());
338        eventParams.put(ObservationConstants.ARGS_PATH, resource.getPath());
339        eventParams.put(ObservationConstants.ARGS_RESOURCE_PATH, resource.getResourcePath());
340        
341        eventParams.put(ObservationConstants.ARGS_PARENT_ID, parent.getId());
342        
343        _observationManager.notify(new Event(ObservationConstants.EVENT_RESOURCE_UPDATED, _currentUserProvider.getUser(), eventParams));
344    }
345    
346    /**
347     * Unzip a file
348     * @param collection The collection where to unzip
349     * @param file The zip file
350     * @return messages
351     */
352    private ResourceOperationResult _unzip(ModifiableResourceCollection collection, File file)
353    {
354        ZipFile zipFile = null;
355        
356        try
357        {
358            zipFile = new ZipFile(file, "cp437");
359            List<Resource> extractedResources = _unzip(collection, zipFile);
360            
361            // Notify listeners
362            _notifyResourcesCreated(collection, extractedResources);
363            
364            return new ResourceOperationResult(extractedResources);
365        }
366        catch (IOException e) 
367        {
368            getLogger().error("Unable to unzip file", e);
369            return new ResourceOperationResult("unzip-error");
370        }
371        finally
372        {
373            ZipFile.closeQuietly(zipFile);
374        }
375    }
376    
377    private List<Resource> _unzip(ModifiableResourceCollection collection, ZipFile zipFile) throws IOException
378    {
379        List<Resource> extractedResources = new LinkedList<>();
380        
381        Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
382        while (entries.hasMoreElements())
383        {
384            ZipArchiveEntry zipEntry = entries.nextElement();
385            
386            ModifiableResourceCollection parentCollection = collection;
387            
388            String zipName = zipEntry.getName();
389            String[] path = zipName.split("/");
390            
391            for (int i = 0; i < path.length - 1; i++)
392            {
393                String name = path[i];
394                parentCollection = _addCollection(parentCollection, name);
395            }
396            
397            String name = path[path.length - 1];
398            if (zipEntry.isDirectory())
399            {
400                parentCollection = _addCollection(parentCollection, name);
401            }
402            else
403            {
404                Resource resource = _addZipEntry(parentCollection, zipFile, zipEntry, name);
405                extractedResources.add(resource);
406            }
407            
408        }
409        
410        // sort by resource names
411        Ordering<Resource> resourceNameOrdering = Ordering.natural().onResultOf(Resource::getName);
412        extractedResources.sort(resourceNameOrdering);
413        
414        return extractedResources;
415    }
416    
417    private ModifiableResourceCollection _addCollection (ModifiableResourceCollection collection, String name)
418    {
419        if (collection.hasChild(name))
420        {
421            return collection.getChild(name);
422        }
423        else
424        {
425            ModifiableResourceCollection child = collection.createChild(name, collection.getCollectionType());
426            collection.saveChanges();
427            return child;
428        }
429    }
430    
431    private Resource _addZipEntry (ModifiableResourceCollection collection, ZipFile zipFile, ZipArchiveEntry zipEntry, String fileName) throws IOException
432    {
433        ModifiableResource resource;
434        
435        try (InputStream is = zipFile.getInputStream(zipEntry))
436        {
437            if (collection.hasChild(fileName))
438            {
439                resource = collection.getChild(fileName);
440            }
441            else
442            {
443                resource = _resourcesDAO.createResource(collection, fileName);
444            }
445            
446            _resourcesDAO.updateResource(resource, is, fileName);
447            
448            collection.saveChanges();
449            
450            _resourcesDAO.checkpoint(resource);
451        }
452        
453        return resource;
454    }
455    
456    /**
457     * Class representing the result of a resource operation.
458     */
459    public static class ResourceOperationResult
460    {
461        /** The created or updated resource(s) */
462        private final List<Resource> _resources;
463        /** Indicates if an unzip operation was executed */
464        private final boolean _unzip;
465        /** Indicates if the operation was successful */
466        private final boolean _success;
467        /** Type of error in case of unsuccessful operation */
468        private final String _errorMessage;
469        
470        /**
471         * constructor in case of a successful operation
472         * @param resource The resource of this operation
473         */
474        protected ResourceOperationResult(Resource resource)
475        {
476            _resources = Collections.singletonList(resource);
477            _unzip = false;
478            
479            _success = true;
480            _errorMessage = null;
481        }
482        
483        /**
484         * constructor in case of a successful unzip operation
485         * @param resources The list of resource for this operation
486         */
487        protected ResourceOperationResult(List<Resource> resources)
488        {
489            _resources = resources;
490            _unzip = true;
491            
492            _success = true;
493            _errorMessage = null;
494        }
495        
496        /**
497         * constructor in case of an error
498         * @param errorMessage The error message.
499         */
500        protected ResourceOperationResult(String errorMessage)
501        {
502            _errorMessage = errorMessage;
503            _success = false;
504            
505            _resources = null;
506            _unzip = false;
507        }
508        
509        /**
510         * Retrieves the resource
511         * Note that {@link #getResources()} should be used in case of an unzip. 
512         * @return the resource
513         */
514        public Resource getResource()
515        {
516            return _resources.get(0);
517        }
518        
519        /**
520         * Retrieves the list of resources, in case of an unzip.
521         * @return the resource
522         */
523        public List<Resource> getResources()
524        {
525            return _resources;
526        }
527        
528        /**
529         * Retrieves the unzip
530         * @return the unzip
531         */
532        public boolean isUnzip()
533        {
534            return _unzip;
535        }
536
537        /**
538         * Retrieves the success
539         * @return the success
540         */
541        public boolean isSuccess()
542        {
543            return _success;
544        }
545
546        /**
547         * Retrieves the errorMessage
548         * @return the errorMessage
549         */
550        public String getErrorMessage()
551        {
552            return _errorMessage;
553        }
554    }
555}