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