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