001/*
002 *  Copyright 2021 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.forms.actions;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026
027import org.apache.avalon.framework.parameters.Parameters;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.cocoon.acting.ServiceableAction;
031import org.apache.cocoon.environment.ObjectModelHelper;
032import org.apache.cocoon.environment.Redirector;
033import org.apache.cocoon.environment.Request;
034import org.apache.cocoon.environment.SourceResolver;
035import org.apache.commons.collections.ListUtils;
036import org.apache.commons.lang.StringUtils;
037import org.apache.commons.lang3.ArrayUtils;
038
039import org.ametys.core.captcha.CaptchaHelper;
040import org.ametys.core.cocoon.ActionResultGenerator;
041import org.ametys.core.right.RightManager;
042import org.ametys.core.user.CurrentUserProvider;
043import org.ametys.core.user.UserIdentity;
044import org.ametys.core.user.UserManager;
045import org.ametys.plugins.forms.dao.FormDAO;
046import org.ametys.plugins.forms.dao.FormEntryDAO;
047import org.ametys.plugins.forms.helper.FormMailHelper;
048import org.ametys.plugins.forms.helper.FormMailHelper.LimitationMailType;
049import org.ametys.plugins.forms.helper.FormWorkflowHelper;
050import org.ametys.plugins.forms.helper.LimitedEntriesHelper;
051import org.ametys.plugins.forms.helper.ScheduleOpeningHelper;
052import org.ametys.plugins.forms.helper.ScheduleOpeningHelper.FormStatus;
053import org.ametys.plugins.forms.question.FormQuestionType;
054import org.ametys.plugins.forms.question.sources.ChoiceSourceType;
055import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
056import org.ametys.plugins.forms.question.types.ComputedQuestionType;
057import org.ametys.plugins.forms.repository.Form;
058import org.ametys.plugins.forms.repository.FormEntry;
059import org.ametys.plugins.forms.repository.FormPage;
060import org.ametys.plugins.forms.repository.FormPageRule;
061import org.ametys.plugins.forms.repository.FormPageRule.PageRuleType;
062import org.ametys.plugins.forms.repository.FormQuestion;
063import org.ametys.plugins.forms.repository.type.Rule;
064import org.ametys.plugins.forms.repository.type.Rule.QuestionRuleType;
065import org.ametys.plugins.repository.AmetysObjectIterable;
066import org.ametys.plugins.repository.AmetysObjectResolver;
067import org.ametys.plugins.repository.RepositoryConstants;
068import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
069import org.ametys.runtime.i18n.I18nizableText;
070import org.ametys.runtime.model.View;
071import org.ametys.runtime.model.ViewItem;
072import org.ametys.web.FOAmetysObjectCreationHelper;
073import org.ametys.web.cache.PageHelper;
074import org.ametys.web.repository.page.ModifiableZoneItem;
075import org.ametys.web.repository.page.SitemapElement;
076import org.ametys.web.repository.page.ZoneItem;
077import org.ametys.web.repository.site.Site;
078
079import com.google.common.collect.ArrayListMultimap;
080import com.google.common.collect.Multimap;
081/**
082 * Process the user entries to the form.
083 */
084public class ProcessFormAction extends ServiceableAction
085{
086    /** The catpcha key */
087    public static final String CAPTCHA_KEY = "ametys-captcha";
088    
089    /** The ametys object resolver. */
090    protected AmetysObjectResolver _resolver;
091    /** The form dao. */
092    protected FormDAO _formDAO;
093    /** The form entry dao. */
094    protected FormEntryDAO _entryDAO;
095    /** The FO content creation helper */
096    protected FOAmetysObjectCreationHelper _foAmetysObjectCreationHelper;
097    /** The form workflow helper */
098    protected FormWorkflowHelper _formWorkflowHelper;
099    /** The current user provider */
100    protected CurrentUserProvider _currentUserProvider;
101    /** The users manager */
102    protected UserManager _userManager;
103    /** the Handle Limited Entries Helper */
104    protected LimitedEntriesHelper _limitedEntriesHelper;
105    /** The form mail helper */
106    protected FormMailHelper _formMailHelper;
107    /** The schedule opening helper */
108    protected ScheduleOpeningHelper _scheduleOpeningHelper;
109    /** The right manager */
110    protected RightManager _rightManager;
111    /** The page helper */
112    protected PageHelper _pageHelper;
113    
114    @Override
115    public void service(ServiceManager serviceManager) throws ServiceException
116    {
117        super.service(serviceManager);
118        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
119        _formDAO = (FormDAO) serviceManager.lookup(FormDAO.ROLE);
120        _entryDAO = (FormEntryDAO) serviceManager.lookup(FormEntryDAO.ROLE);
121        _foAmetysObjectCreationHelper = (FOAmetysObjectCreationHelper) serviceManager.lookup(FOAmetysObjectCreationHelper.ROLE);
122        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
123        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
124        _formWorkflowHelper = (FormWorkflowHelper) serviceManager.lookup(FormWorkflowHelper.ROLE);
125        _limitedEntriesHelper = (LimitedEntriesHelper) serviceManager.lookup(LimitedEntriesHelper.ROLE);
126        _formMailHelper = (FormMailHelper) serviceManager.lookup(FormMailHelper.ROLE);
127        _scheduleOpeningHelper = (ScheduleOpeningHelper) serviceManager.lookup(ScheduleOpeningHelper.ROLE);
128        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
129        _pageHelper = (PageHelper) serviceManager.lookup(PageHelper.ROLE);
130    }
131    
132    @Override
133    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
134    { 
135        Request request = ObjectModelHelper.getRequest(objectModel);
136        Map<String, String> result = _processForm(request);
137        if (result == null)
138        {
139            return null;
140        }
141        request.setAttribute(ActionResultGenerator.MAP_REQUEST_ATTR, result);
142        return EMPTY_MAP;
143    }
144
145    /**
146     * Process form
147     * @param request the request
148     * @return the results
149     */
150    protected Map<String, String> _processForm(Request request)
151    {
152        Map<String, String> result = new HashMap<>();
153        
154        String formId = request.getParameter("formId");
155        if (StringUtils.isNotEmpty(formId))
156        {
157            // Retrieve the current workspace.
158            String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
159            try
160            {
161                // Force the workspace.
162                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
163            
164                // Get the form object.
165                Form form = (Form) _resolver.resolveById(formId);
166                if (!_rightManager.currentUserHasReadAccess(form))
167                {
168                    throw new IllegalAccessError("Can't answer to the form data without convenient right");
169                }
170                
171                UserIdentity user = _currentUserProvider.getUser();
172                String clientIp = _limitedEntriesHelper.getClientIp(request);
173                
174                Multimap<String, I18nizableText> formErrors = ArrayListMultimap.create();
175                boolean canUserSubmit = _limitedEntriesHelper.canUserSubmit(form, user, clientIp);
176                boolean isOpen = _scheduleOpeningHelper.getStatus(form) == FormStatus.OPEN;
177                boolean isConfigured = _formDAO.isFormConfigured(form);
178                if (canUserSubmit && isOpen && isConfigured)
179                {
180                    if (!_checkCaptcha(request, form, formErrors))
181                    {
182                        request.setAttribute("form", form);
183                        request.setAttribute("form-errors", formErrors);
184                        return null;
185                    }
186                    
187                    // Add the user entries into jcr.
188                    FormEntry entry = _entryDAO.createEntry(form);
189                    try
190                    {
191                        View entryView = View.of(entry.getModel());
192                        Map<String, Object> formInputValues = _foAmetysObjectCreationHelper.getFormValues(request, entryView, "", formErrors);
193                        _adaptFormValuesForChoiceList(form, formInputValues);
194                        View filteredEntryView = _getRuleFilteredEntryView(form, entryView, formInputValues);
195                        formErrors.putAll(_foAmetysObjectCreationHelper.validateValues(formInputValues, filteredEntryView));
196                        for (FormQuestion question : form.getQuestions())
197                        {
198                            question.getType().validateEntryValues(question, formInputValues, formErrors);
199                        }
200                        
201                        if (!formErrors.isEmpty())
202                        {
203                            // If there were errors in the input, store it as a request attribute and stop.
204                            request.setAttribute("form", form);
205                            request.setAttribute("form-errors", formErrors);
206                            return null;
207                        }
208                        
209                        entry.synchronizeValues(filteredEntryView, formInputValues);
210                        
211                        _handleComputedValues(form.getQuestions(), entry);
212                        entry.setUser(_currentUserProvider.getUser());
213                        entry.setIP(clientIp);
214                        entry.setSubmitDate(ZonedDateTime.now());
215                        entry.setActive(true);
216                        _setEntryId(entry);
217                        
218                        _formWorkflowHelper.initializeWorkflow(entry);
219                        
220                        form.saveChanges();
221                        
222                        // send mail
223                        _sendEmails(entry);
224                        
225                        if (form.isQueueEnabled())
226                        {
227                            int totalSubmissions = form.getActiveEntries().size();
228                            long rankInQueue = totalSubmissions - form.getMaxEntries().get();
229                            result.put("isInQueue", String.valueOf(rankInQueue > 0));
230                            if (rankInQueue > 0)
231                            {
232                                result.put("rankInQueue", String.valueOf(rankInQueue));
233                            }
234                        }
235                    }
236                    catch (Exception e)
237                    {
238                        request.setAttribute("form", form);
239                        request.setAttribute("form-errors", formErrors);
240                        getLogger().error("An error occured while storing entry", e);
241                        return null;
242                    }
243                }
244                else
245                {
246                    if (!canUserSubmit)
247                    {
248                        formErrors.put("entries-limit-reached", new I18nizableText("plugin.forms", "PLUGINS_FORMS_ENTRIES_LIMIT_REACHED_ERROR"));
249                        request.setAttribute("form", form);
250                        request.setAttribute("form-errors", formErrors);
251                    }
252                    
253                    if (!isOpen)
254                    {
255                        formErrors.put("scheduled-not-open", new I18nizableText("plugin.forms", "PLUGINS_FORMS_OPENING_SCHEDULE_PROCESS_ERROR"));
256                        request.setAttribute("form", form);
257                        request.setAttribute("form-errors", formErrors);
258                    }
259                    
260                    return null;
261                }
262            }
263            finally 
264            {
265                // Restore context
266                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
267            }
268        }
269        
270        return result;
271    }
272    
273    /**
274     * Remove empty value for choice list because empty value is an other value
275     * @param form the form
276     * @param formInputValues the form inputs to change
277     */
278    protected void _adaptFormValuesForChoiceList(Form form, Map<String, Object> formInputValues)
279    {
280        List<FormQuestion> choiceListQuestions = form.getQuestions()
281            .stream()
282            .filter(q -> q.getType() instanceof ChoicesListQuestionType)
283            .toList();
284        
285        for (FormQuestion question : choiceListQuestions)
286        {
287            ChoicesListQuestionType type = (ChoicesListQuestionType) question.getType();
288            ChoiceSourceType sourceType = type.getSourceType(question);
289            
290            String nameForForm = question.getNameForForm();
291            if (formInputValues.containsKey(nameForForm))
292            {
293                Object object = formInputValues.get(nameForForm);
294                Object newValue = sourceType.removeEmptyOrOtherValue(object);
295                if (newValue == null)
296                {
297                    formInputValues.remove(nameForForm);
298                }
299                else
300                {
301                    formInputValues.put(nameForForm, newValue);
302                }
303            }
304        }
305    }
306
307    /**
308     * Check the captcha if needed
309     * @param request the request
310     * @param form the form
311     * @param formErrors the form errors
312     * @return <code>true</code> if the captcha is good
313     */
314    protected boolean _checkCaptcha(Request request, Form form, Multimap<String, I18nizableText> formErrors)
315    {
316        String zoneItemId = request.getParameter("ametys-zone-item-id");
317        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
318        
319        if (!_isFormOnZoneItem(form, zoneItemId))
320        {
321            throw new IllegalAccessError("The form '" + form.getId() + "' doesn't belong to the zone item '" + zoneItemId + "'");
322        }
323        
324        SitemapElement sitemapElement = zoneItem.getZone().getSitemapElement();
325        Site site = form.getSite();
326        String captchaPolicy = site.getValue("display-captcha-policy");
327        
328        if (_pageHelper.isCaptchaRequired(sitemapElement))
329        {
330            String captchaValue = request.getParameter(CAPTCHA_KEY);
331            String captchaKey = request.getParameter(CAPTCHA_KEY + "-key");
332            if (!CaptchaHelper.checkAndInvalidate(captchaKey, captchaValue))
333            {
334                formErrors.put(CAPTCHA_KEY, new I18nizableText("plugin.forms", "PLUGINS_FORMS_ERROR_CAPTCHA_INVALID"));
335                return false;
336            }
337        }
338        else if (captchaPolicy == null || "restricted".equals(captchaPolicy)) 
339        {
340            if (!_rightManager.currentUserHasReadAccess(sitemapElement))
341            {
342                throw new IllegalAccessError("The user try to answer to form '" + form.getId() + "' which belong to an other zone item '" + zoneItemId + "'");
343            }
344        }
345        
346        return true;
347    }
348    
349    private boolean _isFormOnZoneItem(Form form, String zoneItemId)
350    {
351        String xpathQuery = "//element(" + form.getSiteName() + ", ametys:site)//element(*, ametys:zoneItem)[@ametys-internal:service = 'org.ametys.forms.service.Display' and ametys:service_parameters/@ametys:formId = '" + form.getId() + "']";
352        AmetysObjectIterable<ModifiableZoneItem> zoneItems = _resolver.query(xpathQuery);
353        
354        return zoneItems.stream()
355                    .filter(z -> z.getId().equals(zoneItemId))
356                    .findAny()
357                    .isPresent();
358    }
359    
360    /**
361     * Set entry id (auto-incremental id)
362     * @param entry the entry
363     */
364    protected void _setEntryId(FormEntry entry)
365    {
366        Optional<Long> max = entry.getForm()
367            .getEntries()
368            .stream()
369            .map(FormEntry::getEntryId)
370            .filter(Objects::nonNull)
371            .max(Long::compare);
372        
373        if (max.isPresent())
374        {
375            entry.setEntryId(max.get() + 1);
376        }
377        else
378        {
379            entry.setEntryId(1L);
380        }
381    }
382
383    /**
384     * Get a view without elements hidden by a rule
385     * @param form The current form
386     * @param entryView The entry view with possibly unwanted viewItems
387     * @param formInputValues The input values  
388     * @return a view with filtered items
389     */
390    protected View _getRuleFilteredEntryView(Form form, View entryView, Map<String, Object> formInputValues)
391    {
392        View filteredEntryView = new View();
393        
394        List<FormQuestion> activeQuestions = _getActiveQuestions(form, formInputValues);
395        
396        for (FormQuestion target : activeQuestions)
397        {
398            if (!target.getType().onlyForDisplay(target))
399            {
400                ViewItem viewItem = entryView.getViewItem(target.getNameForForm());
401                Optional<Rule> firstQuestionRule = target.getFirstQuestionRule();
402                if (firstQuestionRule.isPresent())
403                {
404                    Rule rule = firstQuestionRule.get();
405                    FormQuestion sourceQuestion = _resolver.resolveById(rule.getSourceId());
406                    List<String> ruleValues = _getRuleValues(formInputValues, sourceQuestion.getNameForForm());
407                    boolean equalsRuleOption = ruleValues.contains(rule.getOption());
408                    QuestionRuleType ruleAction = rule.getAction();
409                    
410                    if (!equalsRuleOption && ruleAction.equals(QuestionRuleType.HIDE)
411                            || equalsRuleOption && ruleAction.equals(QuestionRuleType.SHOW))
412                    {
413                        filteredEntryView.addViewItem(viewItem);
414                        _manageOtherOption(entryView, filteredEntryView, target);
415                    }
416                }
417                else
418                {
419                    filteredEntryView.addViewItem(viewItem);
420                    _manageOtherOption(entryView, filteredEntryView, target);
421                }
422            }
423        }
424        return filteredEntryView;
425    }
426
427    /**
428     * Get a list of the form questions not being hidden by a rule
429     * @param form the current form
430     * @param formInputValues map of input values
431     * @return a list of visible questions
432     */
433    protected List<FormQuestion> _getActiveQuestions(Form form, Map<String, Object> formInputValues)
434    {
435        String nextActivePage = null;
436        List<FormQuestion> activeQuestions = new ArrayList<>();
437        for (FormPage page : form.getPages())
438        {
439            if (nextActivePage == null || page.getId().equals(nextActivePage))
440            {
441                nextActivePage = null;
442                for (FormQuestion question : page.getQuestions())
443                {
444                    activeQuestions.add(question);
445                    
446                    if (question.getType() instanceof ChoicesListQuestionType type && !type.getSourceType(question).remoteData())
447                    {
448                        List<String> ruleValues = _getRuleValues(formInputValues, question.getNameForForm());
449                        for (FormPageRule rule : question.getPageRules())
450                        {
451                            if (ruleValues.contains(rule.getOption()))
452                            {
453                                nextActivePage = _getNextActivePage(rule);
454                            }
455                        }
456                    }
457                }
458            }
459            
460            FormPageRule rule = page.getRule();
461            if (rule != null && nextActivePage == null)
462            {
463                nextActivePage = _getNextActivePage(rule);
464            }
465        }
466        return activeQuestions;
467    }
468
469    private String _getNextActivePage(FormPageRule rule)
470    {
471        return rule.getType() == PageRuleType.FINISH
472                ? "finish"
473                : rule.getPageId();
474    }
475
476    private List<String> _getRuleValues(Map<String, Object> formInputValues, String nameForForm)
477    {
478        Object ruleValue = formInputValues.get(nameForForm);
479        if (ruleValue == null)
480        {
481            return ListUtils.EMPTY_LIST;
482        }
483        
484        if (ruleValue.getClass().isArray())
485        {
486            String[] stringArray = ArrayUtils.toStringArray((Object[]) ruleValue);
487            return Arrays.asList(stringArray);
488        }
489        else
490        {
491            return List.of(ruleValue.toString());
492        }
493    }
494    
495    private void _manageOtherOption(View entryView, View filteredEntryView, FormQuestion target)
496    {
497        if (target.getType() instanceof ChoicesListQuestionType type && type.hasOtherOption(target))
498        {
499            ViewItem viewOtherItem = entryView.getViewItem(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME + target.getNameForForm());
500            filteredEntryView.addViewItem(viewOtherItem);
501        }
502    }
503
504    /**
505     * Handle computed values
506     * @param questions the form questions
507     * @param entry the entry
508     */
509    protected void _handleComputedValues(List<FormQuestion> questions, FormEntry entry)
510    {
511        for (FormQuestion question : questions)
512        {
513            FormQuestionType questionType = question.getType();
514            if (questionType instanceof ComputedQuestionType)
515            {
516                Object computedValue = ((ComputedQuestionType) questionType).getComputingType(question).getComputedValue(question, entry);
517                if (computedValue != null)
518                {
519                    entry.setValue(question.getNameForForm(), computedValue);
520                }
521            }
522        }
523    }
524    
525    /**
526     * Send the receipt and notification emails.
527     * @param entry the current entry
528     */
529    protected void _sendEmails(FormEntry entry)
530    {
531        Form form = entry.getForm();
532        
533        Optional<String[]> adminEmails = form.getAdminEmails();
534        if (adminEmails.isPresent())
535        {
536            String[] emailsAsArray = adminEmails.get();
537            _formMailHelper.sendEmailsForAdmin(form, entry, emailsAsArray);
538            
539            if (form.isEntriesLimited())
540            {
541                int totalSubmissions = form.getActiveEntries().size();
542                Long maxEntries = form.getMaxEntries().get();
543                if (maxEntries == totalSubmissions)
544                {
545                    _formMailHelper.sendLimitationReachedMailForAdmin(entry, emailsAsArray, LimitationMailType.LIMIT);
546                }
547                else if (form.isQueueEnabled() && form.getQueueSize().isPresent() && form.getQueueSize().get() + maxEntries == totalSubmissions)
548                {
549                    _formMailHelper.sendLimitationReachedMailForAdmin(entry, emailsAsArray, LimitationMailType.QUEUE);
550                }
551            }
552        }
553
554        _formMailHelper.sendReceiptEmail(form, entry);
555    }
556}