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.webcontentio;
017
018import java.io.File;
019import java.io.FileNotFoundException;
020import java.io.FileOutputStream;
021import java.io.FilenameFilter;
022import java.io.IOException;
023import java.io.InputStream;
024import java.time.ZonedDateTime;
025import java.util.HashMap;
026import java.util.Map;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.context.Contextualizable;
031import org.apache.avalon.framework.logger.AbstractLogEnabled;
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.Constants;
036import org.apache.cocoon.ProcessingException;
037import org.apache.cocoon.environment.Context;
038import org.apache.commons.io.FileUtils;
039import org.apache.excalibur.source.SourceUtil;
040
041import org.ametys.cms.contenttype.ContentType;
042import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
043import org.ametys.cms.repository.Content;
044import org.ametys.cms.repository.WorkflowAwareContent;
045import org.ametys.core.right.RightManager;
046import org.ametys.core.right.RightManager.RightResult;
047import org.ametys.core.right.RightsException;
048import org.ametys.core.ui.Callable;
049import org.ametys.core.user.CurrentUserProvider;
050import org.ametys.core.user.UserIdentity;
051import org.ametys.plugins.repository.AmetysObjectResolver;
052import org.ametys.plugins.repository.AmetysRepositoryException;
053import org.ametys.plugins.repository.ModifiableAmetysObject;
054import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
055import org.ametys.plugins.repository.RepositoryConstants;
056import org.ametys.plugins.repository.jcr.NameHelper;
057import org.ametys.plugins.workflow.support.WorkflowProvider;
058import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
059import org.ametys.web.repository.content.ModifiableWebContent;
060import org.ametys.web.repository.page.ModifiablePage;
061import org.ametys.web.repository.page.ModifiableZone;
062import org.ametys.web.repository.page.ModifiableZoneItem;
063import org.ametys.web.repository.page.Page;
064import org.ametys.web.repository.page.Page.PageType;
065import org.ametys.web.repository.page.SitemapElement;
066import org.ametys.web.repository.page.ZoneItem.ZoneType;
067import org.ametys.web.repository.site.Site;
068import org.ametys.web.repository.sitemap.Sitemap;
069
070import com.opensymphony.workflow.WorkflowException;
071
072/**
073 * Manager for importing contents
074 */
075public class ContentIOManager extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
076{
077    /** Avalon Role */
078    public static final String ROLE = ContentIOManager.class.getName();
079    
080    private Context _context;
081
082    private ContentImporterExtensionPoint _contentImporterExtensionPoint;
083    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
084    private RightManager _rightsManager;
085    private WorkflowProvider _workflowProvider;
086
087    private CurrentUserProvider _currentUserProvider;
088
089    private AmetysObjectResolver _resolver;
090
091    @Override
092    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
093    {
094        _context = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
095    }
096    
097    @Override
098    public void service(ServiceManager manager) throws ServiceException
099    {
100        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
101        _contentImporterExtensionPoint = (ContentImporterExtensionPoint) manager.lookup(ContentImporterExtensionPoint.ROLE);
102        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
103        _rightsManager = (RightManager) manager.lookup(RightManager.ROLE);
104        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
105        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
106    }
107    
108    /**
109     * Import recursively documents contained in a directory on the server
110     * Created contents are of type 'article' and pages are also created upon successful content creation. 
111     * @param directoryPath The absolute path of directory on the server
112     * @param rootPageId The id of parent page
113     * @return An array containing the number of successful and unsuccessful content created.
114     * @throws AmetysRepositoryException If an error occurred
115     * @throws ProcessingException if an error occurs during processing.
116     */
117    @Callable
118    public Map<String, Object> importContent (String directoryPath, String rootPageId) throws ProcessingException, AmetysRepositoryException
119    {
120        UserIdentity user = _currentUserProvider.getUser();
121        
122        SitemapElement rootPage = _resolver.resolveById(rootPageId);
123        
124        if (!(rootPage instanceof ModifiableAmetysObject))
125        {
126            throw new ProcessingException("The selected page '" + rootPage.getPath() + "' is not modifiable.");
127        }
128        
129        int[] result = importContent(directoryPath, rootPage, user);
130        
131        Map<String, Object> mapResult = new HashMap<>();
132        mapResult.put("success", result[0]);
133        mapResult.put("error", result[1]);
134        
135        return mapResult;
136    }
137    
138    /**
139     * Import recursively documents contained in a directory on the server
140     * Created contents are of type 'article' and pages are also created upon successful content creation. 
141     * @param path The absolute path of directory on the server
142     * @param rootPage The parent page of the new contents
143     * @param user The user responsible for contents import
144     * @return An array containing the number of successful and unsuccessful content created.
145     * @throws ProcessingException if an error occurs during processing.
146     */
147    public int[] importContent(String path, SitemapElement rootPage, UserIdentity user) throws ProcessingException
148    {
149        int[] result = new int[2];
150        
151        File file = new File(path);
152        if (!file.exists())
153        {
154            throw new ProcessingException("The file '" + path + "' does not exist.");
155        }
156        
157        if (file.isFile())
158        {
159            Page page = _importFromFile(file, rootPage, user, false);
160            if (page != null)
161            {
162                result[0]++;
163            }
164            else
165            {
166                result[1]++;
167            }
168        }
169        else
170        {
171            result =  _importFromDirectory(file, rootPage, user, false);
172        }
173        
174        if (rootPage.getSite().needsSave())
175        {
176            rootPage.getSite().saveChanges();
177        }
178        
179        return result;
180    }
181    
182    /**
183     * Import an InputStream as a content into a new page
184     * @param is The input stream to import
185     * @param mimeType The Mime type of the input stream
186     * @param contentName The name associated with the input stream
187     * @param user The creator
188     * @param rootPage The parent page of the newly created page
189     * @param isContextExtern True if the current context is not in a site
190     * @return The created page or null if failed to create the page
191     * @throws IOException if an error occurs with the temporary file.
192     */
193    public ModifiablePage importContent(InputStream is, String mimeType, String contentName, UserIdentity user, SitemapElement rootPage, boolean isContextExtern) throws IOException
194    {
195        File tmpFile = null;
196        try
197        {
198            tmpFile = new File(System.getProperty("java.io.tmpdir") + File.separator + Long.toString(Math.round(Math.random() * 1000000.0)), contentName);
199            
200            tmpFile.getParentFile().mkdirs();
201            tmpFile.createNewFile();
202            
203            try (FileOutputStream tmpFileOS = new FileOutputStream(tmpFile))
204            {
205                SourceUtil.copy(is, tmpFileOS);
206            }
207            
208            ModifiablePage page = _importFromFile(tmpFile, rootPage, user, isContextExtern);
209            
210            if (rootPage.getSite().needsSave())
211            {
212                rootPage.getSite().saveChanges();
213            }
214            
215            return page;
216        }
217        catch (FileNotFoundException e)
218        {
219            getLogger().warn("Unable to create a temporary file " + tmpFile.getAbsolutePath() + " to import.");
220        }
221        finally
222        {
223            FileUtils.deleteQuietly(tmpFile);
224        }
225        
226        return null;
227    }
228    
229    private int[] _importFromDirectory(File dir, SitemapElement rootPage, UserIdentity user, boolean isContextExtern)
230    {
231        int[] result = new int[2];
232        
233        File[] children = dir.listFiles(new FilenameFilter()
234        {
235            // Get all children except child the same directory name (if exists)
236            @Override
237            public boolean accept(File directory, String name)
238            {
239                return !name.startsWith(directory.getName() + '.');
240            }
241        });
242        
243        for (File child : children)
244        {
245            if (child.isFile())
246            {
247                Page page = _importFromFile(child, rootPage, user, isContextExtern);
248                if (page != null)
249                {
250                    result[0]++;
251                }
252                else
253                {
254                    result[1]++;
255                }
256            }
257            else if (child.isDirectory())
258            {
259                File[] subChildren = child.listFiles(new FilenameFilter()
260                {
261                    @Override
262                    public boolean accept(File directory, String name)
263                    {
264                        return name.startsWith(directory.getName() + '.');
265                    }
266                });
267                
268                boolean contentImported = false;
269                ModifiablePage childPage = null;
270                
271                if (subChildren.length > 0)
272                {
273                    childPage = _importFromFile(subChildren[0], rootPage, user, isContextExtern);
274                    if (childPage != null)
275                    {
276                        result[0]++;
277                    }
278                    else
279                    {
280                        result[1]++;
281                    }
282                    
283                    contentImported = childPage != null;
284                }
285                
286                if (!contentImported)
287                {
288                    // no child content with the same name
289                    
290                    String pageTitle = child.getName();
291                    
292                    String originalPageName = NameHelper.filterName(pageTitle);
293                    
294                    String pageName = originalPageName;
295                    int index = 2;
296                    while (rootPage.hasChild(pageName))
297                    {
298                        pageName = originalPageName + "-" + (index++);
299                    }
300                    
301                    childPage = ((ModifiableTraversableAmetysObject) rootPage).createChild(pageName, "ametys:defaultPage");
302                    childPage.setType(PageType.NODE);
303                    childPage.setSiteName(rootPage.getSiteName());
304                    childPage.setSitemapName(rootPage.getSitemapName());
305                    childPage.setTitle(child.getName());
306                }
307                
308                int[] subResult = _importFromDirectory(child, childPage, user, isContextExtern);
309                result[0] += subResult[0];
310                result[1] += subResult[1];
311            }
312        }
313        
314        return result;
315    }
316    
317    /**
318     * Import a file into a new content and a new page.
319     * @param file The file to import
320     * @param rootPage The parent page of the newly created page
321     * @param user The user
322     * @param isContextExtern True if the current context is not in a site
323     * @return True if the content and page was successfully created.
324     */
325    private ModifiablePage _importFromFile(File file, SitemapElement rootPage, UserIdentity user, boolean isContextExtern)
326    {
327        String mimeType = _context.getMimeType(file.getName());
328        ContentImporter importer = _contentImporterExtensionPoint.getContentImporterForMimeType(mimeType);
329        
330        if (importer == null)
331        {
332            getLogger().warn("Unable to import file " + file.getAbsolutePath() + ": no importer found.");
333            return null;
334        }
335        
336        try
337        {
338            Map<String, String> params = new HashMap<>();
339            // import content
340            
341            Content content = _convertFileToContent(importer, file, rootPage.getSite(), rootPage.getSitemap(), user, params);
342            if (content != null)
343            {
344                if (!hasRight(content.getTypes()[0], rootPage, user, isContextExtern))
345                {
346                    throw new RightsException("insufficient rights to create a new page and content for user '" + UserIdentity.userIdentityToString(user) + "'.");
347                }
348                
349                // content has been imported, create the page
350                ModifiablePage page = _createPageAndSetContent(rootPage, content, rootPage.getSite(), rootPage.getSitemap(), params, file);
351                importer.postTreatment(page, content, file);
352                return page;
353            }
354        }
355        catch (AmetysRepositoryException e)
356        {
357            getLogger().error("Unable to import content from file " + file.getAbsolutePath(), e);
358        }
359        catch (IOException e)
360        {
361            getLogger().error("Unable to import content from file " + file.getAbsolutePath(), e);
362        }
363        catch (WorkflowException e)
364        {
365            getLogger().error("Unable to import content from file " + file.getAbsolutePath(), e);
366        }
367
368        return null;
369    }
370    
371    private Content _convertFileToContent(ContentImporter importer, File file, Site site, Sitemap sitemap, UserIdentity user, Map<String, String> params) throws WorkflowException, AmetysRepositoryException
372    {
373        // Creates a workflow entry
374        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow();
375        long workflowId = workflow.initialize("content", 0, new HashMap<>());
376        
377        ModifiableTraversableAmetysObject rootContents = site.getRootContents();
378        
379        String fileName = file.getName();
380        
381        // find an available name and creates the content
382        int i = fileName.lastIndexOf('.');
383        String desiredContentName = i != -1 ? fileName.substring(0, i) : fileName;
384        
385        String originalContentName = NameHelper.filterName(desiredContentName);
386        
387        String contentName = originalContentName;
388        int index = 2;
389        while (rootContents.hasChild(contentName))
390        {
391            contentName = originalContentName + "-" + (index++);
392        }
393
394        ModifiableWebContent content = rootContents.createChild(contentName, RepositoryConstants.NAMESPACE_PREFIX + ":defaultWebContent");
395        
396        ((WorkflowAwareContent) content).setWorkflowId(workflowId);
397
398        // Common metadata
399        content.setCreator(user);
400        content.setCreationDate(ZonedDateTime.now());
401        content.setLastContributor(user);
402        content.setLastModified(ZonedDateTime.now());
403        content.setLanguage(sitemap.getName());
404        content.setSiteName(site.getName());
405        
406        try
407        {
408            importer.importContent(file, content, params);
409        }
410        catch (IOException e)
411        {
412            getLogger().error("Unable to import content from file " + file.getAbsolutePath(), e);
413            content.remove();
414            return null;
415        }
416        
417        return content;
418    }
419    
420    private ModifiablePage _createPageAndSetContent(SitemapElement rootPage, Content content, Site site, Sitemap sitemap, Map<String, String> params, File file)
421    {
422        String template = params.get("page.template");
423        
424        String fileName = file.getName();
425        int i = fileName.lastIndexOf('.');
426        
427        String pageTitle = i != -1 ? fileName.substring(0, i) : fileName;
428        
429        String originalPageName = NameHelper.filterName(pageTitle);
430        
431        String pageName = originalPageName;
432        int index = 2;
433        while (rootPage.hasChild(pageName))
434        {
435            pageName = originalPageName + "-" + (index++);
436        }
437        
438        ModifiablePage page = ((ModifiableTraversableAmetysObject) rootPage).createChild(pageName, "ametys:defaultPage");
439        page.setType(PageType.CONTAINER);
440        page.setTemplate(template == null ? "page" : template);
441        page.setSiteName(site.getName());
442        page.setSitemapName(sitemap.getName());
443        page.setTitle(pageTitle);
444        
445        String longTitle = params.get("page.longTitle");
446        if (longTitle != null)
447        {
448            page.setLongTitle(longTitle);
449        }
450        
451        ModifiableZone zone = page.createZone("default");
452        ModifiableZoneItem zoneItem = zone.addZoneItem();
453        zoneItem.setType(ZoneType.CONTENT);
454        zoneItem.setContent(content);
455        
456        return page;
457    }
458    
459    /**
460     * Test if the current user has the right needed by the content type to create a content.
461     * @param contentTypeId the content type ID.
462     * @param page the current page.
463     * @param user The user
464     * @param isContextExtern True if the current context is not in a site
465     * @return true if the user has the right needed, false otherwise.
466     */
467    protected boolean hasRight(String contentTypeId, SitemapElement page, UserIdentity user, boolean isContextExtern)
468    {
469        ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId);
470        if (contentType != null)
471        {
472            String right = contentType.getRight();
473            return right == null || _rightsManager.hasRight(user, right, page) == RightResult.RIGHT_ALLOW;
474        }
475        
476        return false;
477    }
478}