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