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 PartOnDisk))
201        {
202            if (part instanceof RejectedPart rejectedPart && rejectedPart.getMaxContentLength() == 0)
203            {
204                return new ResourceOperationResult("infected");
205            }
206            else // if (part == null || partUploaded instanceof RejectedPart)
207            {
208                return new ResourceOperationResult("rejected");
209            }
210        }
211        
212        PartOnDisk uploadedFilePart = (PartOnDisk) part;
213        return performResourceOperation(uploadedFilePart.getFile(), parent, mode);
214    }
215    
216    /**
217     * Perform an add or update resource operation
218     * @param file The file for this operation
219     * @param parent The parent collection
220     * @param mode The operation mode
221     * @return the result of the operation
222     */
223    public ResourceOperationResult performResourceOperation(File file, ModifiableResourceCollection parent, ResourceOperationMode mode)
224    {
225        if (!_resourcesDAO.checkLock(parent))
226        {
227            getLogger().warn("User '{}' is trying to modify the collection '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
228            return new ResourceOperationResult("locked");
229        }
230        
231        String fileName = file.getName();
232
233        try (InputStream is = new FileInputStream(file))
234        {
235            return performResourceOperation(is, fileName, parent, mode);
236        }
237        catch (Exception e)
238        {
239            getLogger().error("Unable to add file to the collection of id '{}'", parent.getId(), e);
240            return new ResourceOperationResult("error");
241        }
242    }
243    
244    /**
245     * Perform an add or update resource operation
246     * @param inputStream The data for this operation
247     * @param fileName file name requested
248     * @param parent The parent collection
249     * @param mode The operation mode
250     * @return the result of the operation
251     */
252    public ResourceOperationResult performResourceOperation(InputStream inputStream, String fileName, ModifiableResourceCollection parent, ResourceOperationMode mode)
253    {
254        String usedFileName = fileName;
255        
256        if (!Normalizer.isNormalized(usedFileName, Form.NFC))
257        {
258            usedFileName = Normalizer.normalize(usedFileName, Form.NFC);
259        }
260        
261        if (!_resourcesDAO.checkLock(parent))
262        {
263            getLogger().warn("User '{}' is trying to modify the collection '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
264            return new ResourceOperationResult("locked");
265        }
266        
267        if (fileName.toLowerCase().endsWith(".zip") && ResourceOperationMode.ADD_UNZIP.equals(mode))
268        {
269            return _unzip(parent, inputStream);
270        }
271        
272        ModifiableResource resource = null;
273        
274        // Rename existing
275        if (parent.hasChild(usedFileName))
276        {
277            if (ResourceOperationMode.ADD_RENAME.equals(mode))
278            {
279                // Find a new name
280                String[] f = usedFileName.split("\\.");
281                int index = 1;
282                while (parent.hasChild(usedFileName))
283                {
284                    usedFileName = f[0] + "-" + (index++) + '.' + f[1];
285                }
286                resource = _resourcesDAO.createResource(parent, usedFileName);
287            }
288            else if (ResourceOperationMode.UPDATE.equals(mode))
289            {
290                resource = parent.getChild(usedFileName);
291            }
292            else 
293            {
294                return new ResourceOperationResult("already-exist");
295            }
296        }
297        // Add
298        else
299        {
300            resource = _resourcesDAO.createResource(parent, usedFileName);
301        }
302        
303        try
304        {
305            if (!_resourcesDAO.checkLock(resource))
306            {
307                getLogger().warn("User '{}' is trying to modify the resource '{}' but it is locked by another user", _currentUserProvider.getUser(), parent.getName());
308                return new ResourceOperationResult("locked-file");
309            }
310            
311            _resourcesDAO.updateResource(resource, inputStream, fileName);
312            parent.saveChanges();
313            _resourcesDAO.checkpoint(resource);
314        }
315        catch (Exception e)
316        {
317            getLogger().error("Unable to add file to the collection of id '{}'", parent.getId(), e);
318            return new ResourceOperationResult("error");
319        }
320        
321        // Notify listeners
322        if (ResourceOperationMode.UPDATE.equals(mode))
323        {
324            _notifyResourcesUpdated(parent, resource);
325        }
326        else
327        {
328            _notifyResourcesCreated(parent, Collections.singletonList(resource));
329        }
330        
331        return new ResourceOperationResult(resource);
332    }
333    
334    /**
335     * Fire the {@link ObservationConstants#EVENT_RESOURCE_CREATED} event
336     * @param parent The parent collection of the resource
337     * @param resources The created resources
338     */
339    protected void _notifyResourcesCreated(ModifiableResourceCollection parent, List<Resource> resources)
340    {
341        Map<String, Object> eventParams = new HashMap<>();
342        
343        // ARGS_RESOURCES (transform to a map while keeping iteration order)
344        Map<String, Resource> resourceMap = resources.stream()
345                .collect(Collectors.toMap(
346                    Resource::getId, // key = id
347                    Function.identity(), // value = resource
348                    (u, v) -> u, // allow duplicates
349                    LinkedHashMap::new // to respect iteration order
350                ));
351        
352        eventParams.put(ObservationConstants.ARGS_RESOURCES, resourceMap);
353        
354        eventParams.put(ObservationConstants.ARGS_PARENT_ID, parent.getId());
355        eventParams.put(ObservationConstants.ARGS_PARENT_PATH, parent.getPath());
356        
357        _observationManager.notify(new Event(ObservationConstants.EVENT_RESOURCE_CREATED, _currentUserProvider.getUser(), eventParams));
358    }
359    
360    /**
361     * Fire the {@link ObservationConstants#EVENT_RESOURCE_UPDATED} event
362     * @param parent The parent collection of the resource
363     * @param resource The updated resource
364     */
365    protected void _notifyResourcesUpdated(ModifiableResourceCollection parent, Resource resource)
366    {
367        Map<String, Object> eventParams = new HashMap<>();
368        
369        eventParams.put(ObservationConstants.ARGS_ID, resource.getId());
370        eventParams.put(ObservationConstants.ARGS_NAME, resource.getName());
371        eventParams.put(ObservationConstants.ARGS_PATH, resource.getPath());
372        eventParams.put(ObservationConstants.ARGS_RESOURCE_PATH, resource.getResourcePath());
373        
374        eventParams.put(ObservationConstants.ARGS_PARENT_ID, parent.getId());
375        
376        _observationManager.notify(new Event(ObservationConstants.EVENT_RESOURCE_UPDATED, _currentUserProvider.getUser(), eventParams));
377    }
378    
379    /**
380     * Unzip an inputStream and add the content to the resource collection
381     * @param collection The collection where to unzip
382     * @param inputStream the inputStream of data we want to unzip
383     * @return messages
384     */
385    private ResourceOperationResult _unzip(ModifiableResourceCollection collection, InputStream inputStream)
386    {
387        try (ZipArchiveInputStream zipInputStream = new ZipArchiveInputStream(inputStream, "cp437"))
388        {
389            List<Resource> extractedResources = _unzip(collection, zipInputStream);
390            
391            // Notify listeners
392            _notifyResourcesCreated(collection, extractedResources);
393            
394            return new ResourceOperationResult(extractedResources);
395        }
396        catch (IOException e) 
397        {
398            getLogger().error("Unable to unzip file", e);
399            return new ResourceOperationResult("unzip-error");
400        }
401    }
402    
403    private List<Resource> _unzip(ModifiableResourceCollection collection, ZipArchiveInputStream zipInputStream) throws IOException
404    {
405        List<Resource> extractedResources = new LinkedList<>();
406        
407        ArchiveEntry zipEntry;
408        while ((zipEntry = zipInputStream.getNextEntry()) != null)
409        {
410            ModifiableResourceCollection parentCollection = collection;
411            
412            String zipName = zipEntry.getName();
413            String[] path = zipName.split("/");
414            
415            for (int i = 0; i < path.length - 1; i++)
416            {
417                String name = path[i];
418                parentCollection = _addCollection(parentCollection, name);
419            }
420            
421            String name = path[path.length - 1];
422            if (zipEntry.isDirectory())
423            {
424                parentCollection = _addCollection(parentCollection, name);
425            }
426            else
427            {
428                // because of the getNextEntry() call, zipInputStream is restricted to the data of the entry
429                Resource resource = _addZipEntry(parentCollection, zipInputStream, name);
430                extractedResources.add(resource);
431            }
432
433        }
434        
435        // sort by resource names
436        Ordering<Resource> resourceNameOrdering = Ordering.natural().onResultOf(Resource::getName);
437        extractedResources.sort(resourceNameOrdering);
438        
439        return extractedResources;
440    }
441    
442    private ModifiableResourceCollection _addCollection (ModifiableResourceCollection collection, String name)
443    {
444        if (collection.hasChild(name))
445        {
446            return collection.getChild(name);
447        }
448        else
449        {
450            ModifiableResourceCollection child = collection.createChild(name, collection.getCollectionType());
451            collection.saveChanges();
452            return child;
453        }
454    }
455    
456    private Resource _addZipEntry (ModifiableResourceCollection collection, InputStream zipInputStream, String fileName)
457    {
458        ModifiableResource resource;
459        
460        if (collection.hasChild(fileName))
461        {
462            resource = collection.getChild(fileName);
463        }
464        else
465        {
466            resource = _resourcesDAO.createResource(collection, fileName);
467        }
468        // the call to updateResource will close the InputStream.
469        // We don't want the InputStream to be closed because it's a zipInputStream
470        // containing all the zip data, not just this entry.
471        try (CloseShieldInputStream csis = new CloseShieldInputStream(zipInputStream))
472        {
473            _resourcesDAO.updateResource(resource, csis, fileName);
474        }
475        
476        collection.saveChanges();
477
478        _resourcesDAO.checkpoint(resource);
479        
480        return resource;
481    }
482    
483    /**
484     * Class representing the result of a resource operation.
485     */
486    public static class ResourceOperationResult
487    {
488        /** The created or updated resource(s) */
489        private final List<Resource> _resources;
490        /** Indicates if an unzip operation was executed */
491        private final boolean _unzip;
492        /** Indicates if the operation was successful */
493        private final boolean _success;
494        /** Type of error in case of unsuccessful operation */
495        private final String _errorMessage;
496        
497        /**
498         * constructor in case of a successful operation
499         * @param resource The resource of this operation
500         */
501        protected ResourceOperationResult(Resource resource)
502        {
503            _resources = Collections.singletonList(resource);
504            _unzip = false;
505            
506            _success = true;
507            _errorMessage = null;
508        }
509        
510        /**
511         * constructor in case of a successful unzip operation
512         * @param resources The list of resource for this operation
513         */
514        protected ResourceOperationResult(List<Resource> resources)
515        {
516            _resources = resources;
517            _unzip = true;
518            
519            _success = true;
520            _errorMessage = null;
521        }
522        
523        /**
524         * constructor in case of an error
525         * @param errorMessage The error message.
526         */
527        protected ResourceOperationResult(String errorMessage)
528        {
529            _errorMessage = errorMessage;
530            _success = false;
531            
532            _resources = null;
533            _unzip = false;
534        }
535        
536        /**
537         * Retrieves the resource
538         * Note that {@link #getResources()} should be used in case of an unzip. 
539         * @return the resource
540         */
541        public Resource getResource()
542        {
543            return _resources.get(0);
544        }
545        
546        /**
547         * Retrieves the list of resources, in case of an unzip.
548         * @return the resource
549         */
550        public List<Resource> getResources()
551        {
552            return _resources;
553        }
554        
555        /**
556         * Retrieves the unzip
557         * @return the unzip
558         */
559        public boolean isUnzip()
560        {
561            return _unzip;
562        }
563
564        /**
565         * Retrieves the success
566         * @return the success
567         */
568        public boolean isSuccess()
569        {
570            return _success;
571        }
572
573        /**
574         * Retrieves the errorMessage
575         * @return the errorMessage
576         */
577        public String getErrorMessage()
578        {
579            return _errorMessage;
580        }
581    }
582}