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