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}