001/*
002 *  Copyright 2017 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.extraction.edition;
017
018import java.io.File;
019import java.io.IOException;
020import java.io.OutputStream;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.nio.file.Paths;
024import java.nio.file.StandardCopyOption;
025import java.util.Collections;
026import java.util.List;
027import java.util.Map;
028import java.util.Properties;
029import java.util.function.BiConsumer;
030import java.util.stream.Collectors;
031import java.util.stream.IntStream;
032import java.util.stream.Stream;
033
034import javax.xml.parsers.DocumentBuilder;
035import javax.xml.parsers.DocumentBuilderFactory;
036import javax.xml.transform.OutputKeys;
037import javax.xml.transform.Transformer;
038import javax.xml.transform.TransformerFactory;
039import javax.xml.transform.dom.DOMSource;
040import javax.xml.transform.sax.SAXTransformerFactory;
041import javax.xml.transform.sax.TransformerHandler;
042import javax.xml.transform.stream.StreamResult;
043
044import org.apache.avalon.framework.service.ServiceException;
045import org.apache.avalon.framework.service.ServiceManager;
046import org.apache.cocoon.xml.AttributesImpl;
047import org.apache.cocoon.xml.XMLUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.excalibur.source.Source;
050import org.apache.excalibur.source.SourceResolver;
051import org.apache.excalibur.source.TraversableSource;
052import org.apache.excalibur.source.impl.FileSource;
053import org.apache.xml.serializer.OutputPropertiesFactory;
054import org.w3c.dom.Document;
055import org.w3c.dom.Element;
056import org.w3c.dom.NodeList;
057
058import org.ametys.cms.repository.ContentDAO;
059import org.ametys.cms.workflow.ContentWorkflowHelper;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.ui.StaticClientSideElement;
062import org.ametys.core.user.UserIdentity;
063import org.ametys.core.util.I18nUtils;
064import org.ametys.plugins.core.user.UserHelper;
065import org.ametys.plugins.extraction.ExtractionConstants;
066import org.ametys.plugins.extraction.execution.ExtractionDAO;
067import org.ametys.plugins.extraction.rights.ExtractionAccessController;
068import org.ametys.runtime.authentication.AccessDeniedException;
069import org.ametys.runtime.i18n.I18nizableText;
070
071/**
072 * This client site element manages a button to create an extraction definition file
073 */
074public class EditExtractionClientSideElement extends StaticClientSideElement
075{
076    /** The Avalon role name */
077    public static final String ROLE = EditExtractionClientSideElement.class.getName();
078    
079    private SourceResolver _sourceResolver;
080    private ContentWorkflowHelper _contentWorkflowHelper;
081    private I18nUtils _i18nUtils;
082    private ContentDAO _contentDAO;
083    private UserHelper _userHelper;
084    private ExtractionDAO _extractionDAO;
085    
086    @Override
087    public void service(ServiceManager serviceManager) throws ServiceException
088    {
089        super.service(serviceManager);
090        _sourceResolver = (SourceResolver) serviceManager.lookup(SourceResolver.ROLE);
091        _contentWorkflowHelper = (ContentWorkflowHelper) serviceManager.lookup(ContentWorkflowHelper.ROLE);
092        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
093        _contentDAO = (ContentDAO) serviceManager.lookup(ContentDAO.ROLE);
094        _userHelper = (UserHelper) serviceManager.lookup(UserHelper.ROLE);
095        _extractionDAO = (ExtractionDAO) serviceManager.lookup(ExtractionDAO.ROLE);
096        
097    }
098
099    /**
100     * Creates an extraction definition file.
101     * @param relativeDefinitionFilePath The path of the extraction definition file to create. This path has to be relative to the base definition directory.
102     * @param language the language used to create the description
103     * @return Map containing success boolean and the created extraction informations, or error codes if one occurs
104     * @throws Exception if an error occurs
105     */
106    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
107    public Map<String, Object> createExtraction(String relativeDefinitionFilePath, String language) throws Exception
108    {
109        // Create extraction definitions directory
110        Source definitionsSrc = _sourceResolver.resolveURI(ExtractionConstants.DEFINITIONS_DIR);
111        File definitionsDir = ((FileSource) definitionsSrc).getFile();
112        definitionsDir.mkdirs();
113        
114        String absoluteDefinitionFilePath = ExtractionConstants.DEFINITIONS_DIR + relativeDefinitionFilePath;
115        TraversableSource definitionSrc = (TraversableSource) _sourceResolver.resolveURI(absoluteDefinitionFilePath);
116        File definitionFile = ((FileSource) definitionSrc).getFile();
117
118        if (definitionFile.exists())
119        {
120            getLogger().error("A definition file already exists at path '{}'", relativeDefinitionFilePath);
121            return Map.of(
122                    "success", false,
123                    "error", "already-exists");
124        }
125        
126        if (!_extractionDAO.canWrite(_currentUserProvider.getUser(), (TraversableSource) definitionSrc.getParent()))
127        {
128            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to access extraction feature without convenient right");
129        }
130        
131        try (OutputStream os = Files.newOutputStream(Paths.get(definitionFile.getAbsolutePath())))
132        {
133            // Create the description content
134            String extractionName = _getExtractionNameFromFileName(definitionFile.getName());
135            I18nizableText descriptionTitle = new I18nizableText(ExtractionConstants.PLUGIN_NAME, ExtractionConstants.DESCRIPTION_DEFAULT_TITLE_KEY, Collections.singletonList(extractionName));
136            
137            Map<String, Object> contentInfos = _contentWorkflowHelper.createContent(
138                    ExtractionConstants.DESCRIPTION_CONTENT_WORKFLOW_NAME,
139                    ExtractionConstants.DESCRIPTION_CONTENT_INITIAL_ACTION_ID,
140                    extractionName,
141                    _i18nUtils.translate(descriptionTitle, language),
142                    new String[] {ExtractionConstants.DESCRIPTION_CONTENT_TYPE_ID},
143                    new String[0],
144                    language
145                );
146            String descriptionId = (String) contentInfos.get("contentId");
147            
148            // Create a transformer for saving sax into a file
149            TransformerHandler handler = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
150            
151            StreamResult result = new StreamResult(os);
152            handler.setResult(result);
153
154            // create the format of result
155            Properties format = new Properties();
156            format.put(OutputKeys.METHOD, "xml");
157            format.put(OutputKeys.INDENT, "yes");
158            format.put(OutputKeys.ENCODING, "UTF-8");
159            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4");
160            handler.getTransformer().setOutputProperties(format);
161            handler.startDocument();
162
163            // sax skeleton
164            XMLUtils.startElement(handler, ExtractionConstants.EXTRACTION_TAG);
165            
166            // Generate SAX events for the extraction's description
167            AttributesImpl attributes = new AttributesImpl();
168            attributes.addCDATAAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME, descriptionId);
169            XMLUtils.createElement(handler, ExtractionConstants.DESCRIPTION_TAG, attributes);
170                        
171            // Generate SAX events for the extraction's author
172            UserIdentity author = _currentUserProvider.getUser();
173            _userHelper.saxUserIdentity(author, handler, ExtractionConstants.AUTHOR_TAG);
174            
175            XMLUtils.endElement(handler, ExtractionConstants.EXTRACTION_TAG);
176            
177            handler.endDocument();
178        
179            return Map.of(
180                    "success", true, 
181                    "path", relativeDefinitionFilePath,
182                    "name", definitionFile.getName());
183        }
184        catch (Exception e)
185        {
186            getLogger().error("Error when trying to create the extraction definition file '{}'", relativeDefinitionFilePath, e);
187            return Map.of(
188                    "success", false,
189                    "error", "other-error");
190        }
191    }
192    
193    private String _getExtractionNameFromFileName(String fileName)
194    {
195        return fileName.contains(".") ? fileName.substring(0, fileName.lastIndexOf(".")) : fileName;
196    }
197    
198    /**
199     * Adds a description to an extraction.
200     * @param definitionFileName The extraction definition file name
201     * @param descriptionId the identifier of the description 
202     * @return Map containing success boolean and error codes if one occurs
203     * @throws Exception if an error occurs
204     */
205    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
206    public Map<String, Object> addDescription(String definitionFileName, String descriptionId) throws Exception
207    {
208        String definitionFilePath = ExtractionConstants.DEFINITIONS_DIR + definitionFileName;
209        FileSource definitionSrc = (FileSource) _sourceResolver.resolveURI(definitionFilePath);
210        
211        if (!_extractionDAO.canWrite(_currentUserProvider.getUser(), definitionSrc))
212        {
213            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to access extraction feature without convenient right");
214        }
215        
216        return _modifyDefinitionFile(definitionSrc, descriptionId, this::_insertDescriptionInDocument);
217    }
218
219    private void _insertDescriptionInDocument(Document document, String descriptionId)
220    {
221        Element extractionRoot = document.getDocumentElement();
222        
223        // Delete the current description node if exists
224        _getElements(document.getDocumentElement(), ExtractionConstants.DESCRIPTION_TAG)
225            .forEach(extractionRoot::removeChild);
226
227        // Insert the description in the extraction root node
228        Element description = document.createElement(ExtractionConstants.DESCRIPTION_TAG);
229        description.setAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME, descriptionId);
230        extractionRoot.insertBefore(description, extractionRoot.getFirstChild());
231    }
232    
233    private <T> Map<String, Object> _modifyDefinitionFile(FileSource definitionSrc, T dataToModify, BiConsumer<Document, T> modifyingConsumer) throws Exception
234    {
235        File definitionFile = definitionSrc.getFile();
236        
237        if (!definitionFile.exists())
238        {
239            getLogger().error("Error while adding a description to the extraction '{}': this definition file doesn't exist.", definitionSrc.getURI());
240            return Map.of(
241                    "success", false,
242                    "error", "unexisting");
243        }
244        
245        String tmpFilePath = ExtractionConstants.DEFINITIONS_DIR + definitionSrc.getName() + ".tmp";
246        Source tmpSrc = _sourceResolver.resolveURI(tmpFilePath);
247        File tmpFile = ((FileSource) tmpSrc).getFile();
248        
249        try (OutputStream os = Files.newOutputStream(Paths.get(tmpFile.getAbsolutePath())))
250        {
251            // Parse existing definition file
252            Document document = _parseDefinitionFile(definitionFile);
253            
254            // Apply the modification
255            modifyingConsumer.accept(document, dataToModify);
256
257            // Write the updated definition file
258            DOMSource source = new DOMSource(document);
259            Transformer transformer = TransformerFactory.newInstance().newTransformer();
260            StreamResult result = new StreamResult(os);
261            transformer.transform(source, result);
262            
263            Files.copy(tmpFile.toPath(), definitionFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
264
265            return Map.of("success", true);
266        }
267        catch (Exception e)
268        {
269            getLogger().error("Error when trying to modify the extraction '{}'", definitionSrc.getURI(), e);
270            return Map.of(
271                    "success", false,
272                    "error", "other-error");
273        }
274        finally
275        {
276            // delete the temporary file to keep the original one
277            _deleteTemporaryFile(definitionSrc.getURI(), tmpFile.toPath());
278        }
279    }
280    
281    private void _deleteTemporaryFile(String definitionFileName, Path temporaryFilePath)
282    {
283        try
284        {
285            Files.deleteIfExists(temporaryFilePath);
286        }
287        catch (IOException e)
288        {
289            getLogger().error("Error when deleting the temporary file for '{}'", definitionFileName, e);
290        }
291    }
292
293    /**
294     * Renames an extraction definition file.
295     * @param relativeOldFilePath The extraction definition old file path, relative to the base definitions directory
296     * @param newFileName The extraction definition new file name
297     * @return Map containing success boolean and error codes if one occurs
298     * @throws Exception if an error occurs
299     */
300    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
301    public Map<String, Object> renameExtraction(String relativeOldFilePath, String newFileName) throws Exception
302    {
303        String fileUri = ExtractionConstants.DEFINITIONS_DIR + relativeOldFilePath;
304        FileSource file = (FileSource) _sourceResolver.resolveURI(fileUri);
305        
306        if (!_extractionDAO.canWrite(_currentUserProvider.getUser(), file))
307        {
308            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to access extraction feature without convenient right");
309        }
310        
311        String relativeParentPath = StringUtils.removeEnd(relativeOldFilePath, file.getName());
312        String relativeNewFilePath = relativeParentPath + newFileName;
313        return _extractionDAO.moveOrRenameExtractionDefinitionFile(relativeOldFilePath, relativeNewFilePath);
314    }
315    
316    /**
317     * Deletes an extraction definition file.
318     * @param definitionFileName The extraction definition file to delete
319     * @return <code>true</code> if extraction deletion succeed, <code>false</code> otherwise
320     * @throws Exception if an error occurs
321     */
322    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
323    public boolean deleteExtraction(String definitionFileName) throws Exception
324    {
325        String filePath = ExtractionConstants.DEFINITIONS_DIR + definitionFileName;
326        FileSource source = (FileSource) _sourceResolver.resolveURI(filePath);
327        
328        if (!source.exists())
329        {
330            throw new IllegalArgumentException("Error while deleting '" + definitionFileName + "': this definition file doesn't exist.");
331        }
332        
333        if (!_extractionDAO.canDelete(_currentUserProvider.getUser(), source))
334        {
335            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to access extraction feature without convenient right");
336        }
337        
338        try
339        {
340            // Get description identifiers
341            File file = source.getFile();
342            Document document = _parseDefinitionFile(file);
343            List<String> descriptionIds = _getElements(document.getDocumentElement(), ExtractionConstants.DESCRIPTION_TAG)
344                .map(description -> description.getAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME))
345                .collect(Collectors.toList());
346            
347            // Delete descriptions contents
348            _contentDAO.deleteContents(descriptionIds, ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID);
349
350            Files.deleteIfExists(file.toPath());
351
352            String context = ExtractionAccessController.ROOT_CONTEXT + "/" + definitionFileName;
353            _extractionDAO.deleteRights(context);
354        }
355        catch (IOException e)
356        {
357            if (getLogger().isErrorEnabled())
358            {
359                getLogger().error("Error while deleting definition file '" + definitionFileName + "'.", e);
360            }
361            throw e;
362        }
363        
364        return true;
365    }
366    
367    private Document _parseDefinitionFile(File definitionFile) throws Exception
368    {
369        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
370        DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
371        return documentBuilder.parse(definitionFile);
372    }
373    
374    private Stream<Element> _getElements(Element extractionRoot, String nodeTagName)
375    {
376        NodeList nodeList = extractionRoot.getElementsByTagName(nodeTagName);
377        return IntStream.range(0, nodeList.getLength())
378                .mapToObj(nodeList::item)
379                .map(Element.class::cast);
380    }
381    
382}