From 46b051dac173e34e4a96b18170ac458fe856d6c6 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 29 Dec 2025 08:17:33 -0800 Subject: [PATCH 1/2] GitHub Issue 687: Workflow actions missing transaction audit detail --- .../org/labkey/api/action/BaseViewAction.java | 1579 +++++++++-------- .../labkey/api/view/TransactionViewForm.java | 40 + 2 files changed, 835 insertions(+), 784 deletions(-) create mode 100644 api/src/org/labkey/api/view/TransactionViewForm.java diff --git a/api/src/org/labkey/api/action/BaseViewAction.java b/api/src/org/labkey/api/action/BaseViewAction.java index c648b33afee..dff2a449544 100644 --- a/api/src/org/labkey/api/action/BaseViewAction.java +++ b/api/src/org/labkey/api/action/BaseViewAction.java @@ -1,784 +1,795 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.action; - -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.beanutils.DynaBean; -import org.apache.commons.beanutils.PropertyUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.SpringAttachmentFile; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.data.Container; -import org.labkey.api.data.ConvertHelper; -import org.labkey.api.security.User; -import org.labkey.api.util.HelpTopic; -import org.labkey.api.util.HttpUtil; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.ContainerUser; -import org.springframework.beans.AbstractPropertyAccessor; -import org.springframework.beans.BeanUtils; -import org.springframework.beans.BeanWrapper; -import org.springframework.beans.BeansException; -import org.springframework.beans.InvalidPropertyException; -import org.springframework.beans.MutablePropertyValues; -import org.springframework.beans.NotReadablePropertyException; -import org.springframework.beans.NotWritablePropertyException; -import org.springframework.beans.PropertyAccessException; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.beans.TypeMismatchException; -import org.springframework.core.MethodParameter; -import org.springframework.core.convert.TypeDescriptor; -import org.springframework.lang.Nullable; -import org.springframework.validation.BeanPropertyBindingResult; -import org.springframework.validation.BindException; -import org.springframework.validation.BindingErrorProcessor; -import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.validation.Validator; -import org.springframework.web.bind.ServletRequestDataBinder; -import org.springframework.web.bind.ServletRequestParameterPropertyValues; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.servlet.ModelAndView; - -import java.beans.PropertyDescriptor; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Predicate; - -public abstract class BaseViewAction
extends PermissionCheckableAction implements Validator, HasPageConfig, ContainerUser -{ - protected static final Logger logger = LogHelper.getLogger(BaseViewAction.class, "BaseViewAction"); - - private PageConfig _pageConfig = null; - private PropertyValues _pvs; - private boolean _robot = false; // Is this request from GoogleBot or some other crawler? - private boolean _debug = false; - - protected boolean _print = false; - protected Class _commandClass; - protected String _commandName = "form"; - - protected BaseViewAction() - { - String methodName = getCommandClassMethodName(); - - if (null == methodName) - return; - - // inspect the action's *public* methods to determine form class - Class typeBest = null; - for (Method m : this.getClass().getMethods()) - { - if (methodName.equals(m.getName())) - { - Class[] types = m.getParameterTypes(); - if (types.length < 1) - continue; - Class typeCurrent = types[0]; - assert null == _commandClass || typeCurrent.equals(_commandClass); - - // Using templated classes to extend a base action can lead to multiple - // versions of a method with acceptable types, so take the most extended - // type we can find. - if (typeBest == null || typeBest.isAssignableFrom(typeCurrent)) - typeBest = typeCurrent; - } - } - if (typeBest != null) - setCommandClass(typeBest); - } - - - protected abstract String getCommandClassMethodName(); - - - protected BaseViewAction(@NotNull Class commandClass) - { - setCommandClass(commandClass); - } - - - public void setProperties(PropertyValues pvs) - { - _pvs = pvs; - } - - - public void setProperties(Map m) - { - _pvs = new MutablePropertyValues(m); - } - - - /* Doesn't guarantee non-null, non-empty */ - public Object getProperty(String key, String d) - { - PropertyValue pv = _pvs.getPropertyValue(key); - return pv == null ? d : pv.getValue(); - } - - - public Object getProperty(Enum key) - { - PropertyValue pv = _pvs.getPropertyValue(key.name()); - return pv == null ? null : pv.getValue(); - } - - - public Object getProperty(String key) - { - PropertyValue pv = _pvs.getPropertyValue(key); - return pv == null ? null : pv.getValue(); - } - - public PropertyValues getPropertyValues() - { - return _pvs; - } - - - public static PropertyValues getPropertyValuesForFormBinding(PropertyValues pvs, @NotNull Predicate allowBind) - { - if (null == pvs) - return null; - MutablePropertyValues ret = new MutablePropertyValues(); - for (PropertyValue pv : pvs.getPropertyValues()) - { - if (allowBind.test(pv.getName())) - ret.addPropertyValue(pv); - } - return ret; - } - - static final String FORM_DATE_ENCODED_PARAM = "formDataEncoded"; - - /** - * When a double quote is encountered in a multipart/form-data context, it is encoded as %22 using URL-encoding by browsers. - * This process replaces the double quote with its hexadecimal equivalent in a URL-safe format, preventing it from being misinterpreted as the end of a value or a boundary. - * The consequence of such encoding is we can't distinguish '"' from the actual '%22' in parameter name. - * As a workaround, a client-side util `encodeFormDataQuote` is used to convert %22 to %2522 and " to %22 explicitly, while passing in an additional param formDataEncoded=true. - * This class converts those encoded param names back to its decoded form during PropertyValues binding. - * See Issue 52827, 52925 and 52119 for more information. - */ - static public class ViewActionParameterPropertyValues extends ServletRequestParameterPropertyValues - { - - public ViewActionParameterPropertyValues(ServletRequest request) { - this(request, null, null); - } - - public ViewActionParameterPropertyValues(ServletRequest request, @Nullable String prefix, @Nullable String prefixSeparator) - { - super(request, prefix, prefixSeparator); - if (isFormDataEncoded()) - { - for (int i = 0; i < getPropertyValues().length; i++) - { - PropertyValue formDataPropValue = getPropertyValues()[i]; - String propValueName = formDataPropValue.getName(); - String decoded = PageFlowUtil.decodeQuoteEncodedFormDataKey(propValueName); - if (!propValueName.equals(decoded)) - setPropertyValueAt(new PropertyValue(decoded, formDataPropValue.getValue()), i); - } - } - } - - private boolean isFormDataEncoded() - { - PropertyValue formDataPropValue = getPropertyValue(FORM_DATE_ENCODED_PARAM); - if (formDataPropValue != null) - { - Object v = formDataPropValue.getValue(); - String formDataPropValueStr = v == null ? null : String.valueOf(v); - if (StringUtils.isNotBlank(formDataPropValueStr)) - return (Boolean) ConvertUtils.convert(formDataPropValueStr, Boolean.class); - } - - return false; - } - } - - @Override - public ModelAndView handleRequest(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws Exception - { - if (null == getPropertyValues()) - setProperties(new ViewActionParameterPropertyValues(request)); - getViewContext().setBindPropertyValues(getPropertyValues()); - handleSpecialProperties(); - - return handleRequest(); - } - - - private void handleSpecialProperties() - { - _robot = PageFlowUtil.isRobotUserAgent(getViewContext().getRequest().getHeader("User-Agent")); - - // Special flag puts actions in "debug" mode, during which they should log extra information that would be - // helpful for testing or debugging problems - if (!_robot && hasStringValue("_debug")) - { - _debug = true; - } - - // SpringActionController.defaultPageConfig() has logic for isPrint, don't need to duplicate here - _print = PageConfig.Template.Print == HttpView.currentPageConfig().getTemplate(); - } - - private boolean hasStringValue(String propertyName) - { - Object o = getProperty(propertyName); - if (o == null) - { - return false; - } - if (o instanceof String s) - { - return !StringUtils.isBlank(s); - } - if (o instanceof String[] strings) - { - for (String s : strings) - { - if (!StringUtils.isBlank(s)) - { - return true; - } - } - } - return false; - } - - public abstract ModelAndView handleRequest() throws Exception; - - - @Override - public void setPageConfig(PageConfig page) - { - _pageConfig = page; - } - - - @Override - public Container getContainer() - { - return getViewContext().getContainer(); - } - - - @Override - public User getUser() - { - return getViewContext().getUser(); - } - - - @Override - public PageConfig getPageConfig() - { - return _pageConfig; - } - - - public void setTitle(String title) - { - assert null != getPageConfig() : "action not initialized property"; - getPageConfig().setTitle(title); - } - - - public void setHelpTopic(String topicName) - { - setHelpTopic(new HelpTopic(topicName)); - } - - - public void setHelpTopic(HelpTopic topic) - { - assert null != getPageConfig() : "action not initialized properly"; - getPageConfig().setHelpTopic(topic); - } - - - protected Object newInstance(Class c) - { - try - { - return c == null ? null : c.getConstructor().newInstance(); - } - catch (Exception x) - { - if (x instanceof RuntimeException) - throw ((RuntimeException)x); - else - throw new RuntimeException(x); - } - } - - - protected @NotNull FORM getCommand(HttpServletRequest request) throws Exception - { - FORM command = (FORM) createCommand(); - - if (command instanceof HasViewContext) - ((HasViewContext)command).setViewContext(getViewContext()); - - return command; - } - - - protected @NotNull FORM getCommand() throws Exception - { - return getCommand(getViewContext().getRequest()); - } - - - // - // PARAMETER BINDING - // - // don't assume parameters always come from a request, use PropertyValues interface - // - - public @NotNull BindException defaultBindParameters(FORM form, PropertyValues params) - { - return defaultBindParameters(form, getCommandName(), params); - } - - - public static @NotNull BindException defaultBindParameters(Object form, String commandName, PropertyValues params) - { - /* check for do-it-myself forms */ - if (form instanceof HasBindParameters) - { - return ((HasBindParameters)form).bindParameters(params); - } - - if (form instanceof DynaBean) - { - return simpleBindParameters(form, commandName, params); - } - else - { - return springBindParameters(form, commandName, params); - } - } - - public static @NotNull BindException springBindParameters(Object command, String commandName, PropertyValues params) - { - Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); - ServletRequestDataBinder binder = new ServletRequestDataBinder(command, commandName); - - String[] fields = binder.getDisallowedFields(); - List fieldList = new ArrayList<>(fields != null ? Arrays.asList(fields) : Collections.emptyList()); - fieldList.addAll(Arrays.asList("class.*", "Class.*", "*.class.*", "*.Class.*")); - binder.setDisallowedFields(fieldList.toArray(new String[] {})); - - ConvertHelper.getPropertyEditorRegistrar().registerCustomEditors(binder); - BindingErrorProcessor defaultBEP = binder.getBindingErrorProcessor(); - binder.setBindingErrorProcessor(getBindingErrorProcessor(defaultBEP)); - binder.setFieldMarkerPrefix(SpringActionController.FIELD_MARKER); - try - { - // most paths probably called getPropertyValuesForFormBinding() already, but this is a public static method, so call it again - binder.bind(getPropertyValuesForFormBinding(params, allow)); - BindException errors = new NullSafeBindException(binder.getBindingResult()); - return errors; - } - catch (InvalidPropertyException x) - { - // Maybe we should propagate exception and return SC_BAD_REQUEST (in ExceptionUtil.handleException()) - // most POST handlers check errors.hasErrors(), but not all GET handlers do - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding property: " + x.getPropertyName()); - return errors; - } - catch (NumberFormatException x) - { - // Malformed array parameter throws this exception, unfortunately. Just reject the request. #21931 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; invalid array index (" + x.getMessage() + ")"); - return errors; - } - catch (NegativeArraySizeException x) - { - // Another malformed array parameter throws this exception. #23929 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; negative array size (" + x.getMessage() + ")"); - return errors; - } - catch (IllegalArgumentException x) - { - // General bean binding problem. #23929 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding property; (" + x.getMessage() + ")"); - return errors; - } - } - - - static BindingErrorProcessor getBindingErrorProcessor(final BindingErrorProcessor defaultBEP) - { - return new BindingErrorProcessor() - { - @Override - public void processMissingFieldError(String missingField, BindingResult bindingResult) - { - defaultBEP.processMissingFieldError(missingField, bindingResult); - } - - @Override - public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) - { - Object newValue = ex.getPropertyChangeEvent().getNewValue(); - if (newValue instanceof String) - newValue = StringUtils.trimToNull((String)newValue); - - // convert NULL conversion errors to required errors - if (null == newValue) - defaultBEP.processMissingFieldError(ex.getPropertyChangeEvent().getPropertyName(), bindingResult); - else - defaultBEP.processPropertyAccessException(ex, bindingResult); - } - }; - } - - - /* - * This binder doesn't have much to offer over the standard spring data binding except that it will - * handle DynaBeans. - */ - public static @NotNull BindException simpleBindParameters(Object command, String commandName, PropertyValues params) - { - Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); - - BindException errors = new NullSafeBindException(command, "Form"); - - // unfortunately ObjectFactory and BeanObjectFactory are not good about reporting errors - // do this by hand - for (PropertyValue pv : params.getPropertyValues()) - { - String propertyName = pv.getName(); - Object value = pv.getValue(); - if (!allow.test(propertyName)) - continue; - - try - { - Object converted = value; - Class propClass = PropertyUtils.getPropertyType(command, propertyName); - if (null == propClass) - continue; - if (value == null) - { - /* */ - } - else if (propClass.isPrimitive()) - { - converted = ConvertUtils.convert(String.valueOf(value), propClass); - } - else if (propClass.isArray()) - { - if (value instanceof Collection) - value = ((Collection) value).toArray(new String[0]); - else if (!value.getClass().isArray()) - value = new String[] {String.valueOf(value)}; - converted = ConvertUtils.convert((String[])value, propClass); - } - PropertyUtils.setProperty(command, propertyName, converted); - } - catch (ConversionException x) - { - errors.addError(new FieldError(commandName, propertyName, value, true, new String[] {"ConversionError", "typeMismatch"}, null, "Could not convert to value: " + value)); - } - catch (Exception x) - { - errors.addError(new ObjectError(commandName, new String[]{"Error"}, new Object[] {value}, x.getMessage())); - logger.error("unexpected error", x); - } - } - return errors; - } - - @Override - public boolean supports(Class clazz) - { - return getCommandClass().isAssignableFrom(clazz); - } - - public Map getTransactionAuditDetails() - { - return getTransactionAuditDetails(getViewContext()); - } - - public static Map getTransactionAuditDetails(ViewContext viewContext) - { - Map map = new HashMap<>(); - map.put(TransactionAuditProvider.TransactionDetail.Action, viewContext.getActionURL().getController() + "-" + viewContext.getActionURL().getAction()); - String clientLibrary = HttpUtil.getClientLibrary(viewContext.getRequest()); - if (null != clientLibrary) - map.put(TransactionAuditProvider.TransactionDetail.ClientLibrary, clientLibrary); - else - { - String productName = HttpUtil.getProductNameFromReferer(viewContext.getRequest()); // app - if (null != productName) - map.put(TransactionAuditProvider.TransactionDetail.Product, productName); - else // LKS - { - String refererRelativeURL = HttpUtil.getRefererRelativeURL(viewContext.getRequest()); - map.put(TransactionAuditProvider.TransactionDetail.RequestSource, refererRelativeURL); - } - } - return map; - } - - /* for TableViewForm, uses BeanUtils to work with DynaBeans */ - static public class BeanUtilsPropertyBindingResult extends BeanPropertyBindingResult - { - public BeanUtilsPropertyBindingResult(Object target, String objectName) - { - super(target, objectName); - } - - @Override - protected BeanWrapper createBeanWrapper() - { - return new BeanUtilsWrapperImpl((DynaBean)getTarget()); - } - } - - static public class BeanUtilsWrapperImpl extends AbstractPropertyAccessor implements BeanWrapper - { - private Object object; - private boolean autoGrowNestedPaths = false; - private int autoGrowCollectionLimit = 0; - - public BeanUtilsWrapperImpl() - { - // registerDefaultEditors(); - } - - public BeanUtilsWrapperImpl(DynaBean target) - { - this(); - object = target; - } - - @Override - public Object getPropertyValue(String propertyName) throws BeansException - { - try - { - return PropertyUtils.getProperty(object, propertyName); - } - catch (Exception e) - { - throw new NotReadablePropertyException(object.getClass(), propertyName); - } - } - - @Override - public void setPropertyValue(String propertyName, Object value) throws BeansException - { - try - { - PropertyUtils.setProperty(object, propertyName, value); - } - catch (Exception e) - { - throw new NotWritablePropertyException(object.getClass(), propertyName); - } - } - - @Override - public boolean isReadableProperty(String propertyName) - { - return true; - } - - @Override - public boolean isWritableProperty(String propertyName) - { - return true; - } - - @Override - public TypeDescriptor getPropertyTypeDescriptor(String s) throws BeansException - { - return null; - } - - public void setWrappedInstance(Object obj) - { - object = obj; - } - - @Override - public Object getWrappedInstance() - { - return object; - } - - @Override - public Class getWrappedClass() - { - return object.getClass(); - } - - @Override - public PropertyDescriptor[] getPropertyDescriptors() - { - throw new UnsupportedOperationException(); - } - - @Override - public PropertyDescriptor getPropertyDescriptor(String propertyName) throws BeansException - { - throw new UnsupportedOperationException(); - } - - @Override - public void setAutoGrowNestedPaths(boolean b) - { - this.autoGrowNestedPaths = b; - } - - @Override - public boolean isAutoGrowNestedPaths() - { - return this.autoGrowNestedPaths; - } - - @Override - public void setAutoGrowCollectionLimit(int i) - { - this.autoGrowCollectionLimit = i; - } - - @Override - public int getAutoGrowCollectionLimit() - { - return this.autoGrowCollectionLimit; - } - - @Override - public T convertIfNecessary(Object value, Class requiredType) throws TypeMismatchException - { - if (value == null) - return null; - return (T)ConvertUtils.convert(String.valueOf(value), requiredType); - } - - @Override - public T convertIfNecessary(Object value, Class requiredType, MethodParameter methodParam) throws TypeMismatchException - { - return convertIfNecessary(value, requiredType); - } - } - - /** - * @return a map from form element name to uploaded files - */ - protected Map getFileMap() - { - if (getViewContext().getRequest() instanceof MultipartHttpServletRequest) - return ((MultipartHttpServletRequest)getViewContext().getRequest()).getFileMap(); - return Collections.emptyMap(); - } - - protected List getAttachmentFileList() - { - return SpringAttachmentFile.createList(getFileMap()); - } - - public boolean isRobot() - { - return _robot; - } - - public boolean isPrint() - { - return _print; - } - - public boolean isDebug() - { - return _debug; - } - - public @NotNull Class getCommandClass() - { - if (null == _commandClass) - throw new IllegalStateException("NULL _commandClass in " + getClass().getName()); - return _commandClass; - } - - public void setCommandClass(@NotNull Class commandClass) - { - _commandClass = commandClass; - } - - protected final @NotNull Object createCommand() - { - return BeanUtils.instantiateClass(getCommandClass()); - } - - public void setCommandName(String commandName) - { - _commandName = commandName; - } - - public String getCommandName() - { - return _commandName; - } - - /** - * Cacheable resources can calculate a last modified timestamp to send to the browser. - */ - protected long getLastModified(FORM form) - { - return Long.MIN_VALUE; - } - - /** - * Cacheable resources can calculate an ETag header to send to the browser. - */ - protected String getETag(FORM form) - { - return null; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.action; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.beanutils.DynaBean; +import org.apache.commons.beanutils.PropertyUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.SpringAttachmentFile; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.data.Container; +import org.labkey.api.data.ConvertHelper; +import org.labkey.api.security.User; +import org.labkey.api.util.HelpTopic; +import org.labkey.api.util.HttpUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.TransactionViewForm; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.ContainerUser; +import org.springframework.beans.AbstractPropertyAccessor; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeansException; +import org.springframework.beans.InvalidPropertyException; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.NotReadablePropertyException; +import org.springframework.beans.NotWritablePropertyException; +import org.springframework.beans.PropertyAccessException; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.beans.TypeMismatchException; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingErrorProcessor; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.validation.Validator; +import org.springframework.web.bind.ServletRequestDataBinder; +import org.springframework.web.bind.ServletRequestParameterPropertyValues; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.ModelAndView; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public abstract class BaseViewAction extends PermissionCheckableAction implements Validator, HasPageConfig, ContainerUser +{ + protected static final Logger logger = LogHelper.getLogger(BaseViewAction.class, "BaseViewAction"); + + private PageConfig _pageConfig = null; + private PropertyValues _pvs; + private boolean _robot = false; // Is this request from GoogleBot or some other crawler? + private boolean _debug = false; + + protected boolean _print = false; + protected Class _commandClass; + protected String _commandName = "form"; + + protected BaseViewAction() + { + String methodName = getCommandClassMethodName(); + + if (null == methodName) + return; + + // inspect the action's *public* methods to determine form class + Class typeBest = null; + for (Method m : this.getClass().getMethods()) + { + if (methodName.equals(m.getName())) + { + Class[] types = m.getParameterTypes(); + if (types.length < 1) + continue; + Class typeCurrent = types[0]; + assert null == _commandClass || typeCurrent.equals(_commandClass); + + // Using templated classes to extend a base action can lead to multiple + // versions of a method with acceptable types, so take the most extended + // type we can find. + if (typeBest == null || typeBest.isAssignableFrom(typeCurrent)) + typeBest = typeCurrent; + } + } + if (typeBest != null) + setCommandClass(typeBest); + } + + + protected abstract String getCommandClassMethodName(); + + + protected BaseViewAction(@NotNull Class commandClass) + { + setCommandClass(commandClass); + } + + + public void setProperties(PropertyValues pvs) + { + _pvs = pvs; + } + + + public void setProperties(Map m) + { + _pvs = new MutablePropertyValues(m); + } + + + /* Doesn't guarantee non-null, non-empty */ + public Object getProperty(String key, String d) + { + PropertyValue pv = _pvs.getPropertyValue(key); + return pv == null ? d : pv.getValue(); + } + + + public Object getProperty(Enum key) + { + PropertyValue pv = _pvs.getPropertyValue(key.name()); + return pv == null ? null : pv.getValue(); + } + + + public Object getProperty(String key) + { + PropertyValue pv = _pvs.getPropertyValue(key); + return pv == null ? null : pv.getValue(); + } + + public PropertyValues getPropertyValues() + { + return _pvs; + } + + + public static PropertyValues getPropertyValuesForFormBinding(PropertyValues pvs, @NotNull Predicate allowBind) + { + if (null == pvs) + return null; + MutablePropertyValues ret = new MutablePropertyValues(); + for (PropertyValue pv : pvs.getPropertyValues()) + { + if (allowBind.test(pv.getName())) + ret.addPropertyValue(pv); + } + return ret; + } + + static final String FORM_DATE_ENCODED_PARAM = "formDataEncoded"; + + /** + * When a double quote is encountered in a multipart/form-data context, it is encoded as %22 using URL-encoding by browsers. + * This process replaces the double quote with its hexadecimal equivalent in a URL-safe format, preventing it from being misinterpreted as the end of a value or a boundary. + * The consequence of such encoding is we can't distinguish '"' from the actual '%22' in parameter name. + * As a workaround, a client-side util `encodeFormDataQuote` is used to convert %22 to %2522 and " to %22 explicitly, while passing in an additional param formDataEncoded=true. + * This class converts those encoded param names back to its decoded form during PropertyValues binding. + * See Issue 52827, 52925 and 52119 for more information. + */ + static public class ViewActionParameterPropertyValues extends ServletRequestParameterPropertyValues + { + + public ViewActionParameterPropertyValues(ServletRequest request) { + this(request, null, null); + } + + public ViewActionParameterPropertyValues(ServletRequest request, @Nullable String prefix, @Nullable String prefixSeparator) + { + super(request, prefix, prefixSeparator); + if (isFormDataEncoded()) + { + for (int i = 0; i < getPropertyValues().length; i++) + { + PropertyValue formDataPropValue = getPropertyValues()[i]; + String propValueName = formDataPropValue.getName(); + String decoded = PageFlowUtil.decodeQuoteEncodedFormDataKey(propValueName); + if (!propValueName.equals(decoded)) + setPropertyValueAt(new PropertyValue(decoded, formDataPropValue.getValue()), i); + } + } + } + + private boolean isFormDataEncoded() + { + PropertyValue formDataPropValue = getPropertyValue(FORM_DATE_ENCODED_PARAM); + if (formDataPropValue != null) + { + Object v = formDataPropValue.getValue(); + String formDataPropValueStr = v == null ? null : String.valueOf(v); + if (StringUtils.isNotBlank(formDataPropValueStr)) + return (Boolean) ConvertUtils.convert(formDataPropValueStr, Boolean.class); + } + + return false; + } + } + + @Override + public ModelAndView handleRequest(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws Exception + { + if (null == getPropertyValues()) + setProperties(new ViewActionParameterPropertyValues(request)); + getViewContext().setBindPropertyValues(getPropertyValues()); + handleSpecialProperties(); + + return handleRequest(); + } + + + private void handleSpecialProperties() + { + _robot = PageFlowUtil.isRobotUserAgent(getViewContext().getRequest().getHeader("User-Agent")); + + // Special flag puts actions in "debug" mode, during which they should log extra information that would be + // helpful for testing or debugging problems + if (!_robot && hasStringValue("_debug")) + { + _debug = true; + } + + // SpringActionController.defaultPageConfig() has logic for isPrint, don't need to duplicate here + _print = PageConfig.Template.Print == HttpView.currentPageConfig().getTemplate(); + } + + private boolean hasStringValue(String propertyName) + { + Object o = getProperty(propertyName); + if (o == null) + { + return false; + } + if (o instanceof String s) + { + return !StringUtils.isBlank(s); + } + if (o instanceof String[] strings) + { + for (String s : strings) + { + if (!StringUtils.isBlank(s)) + { + return true; + } + } + } + return false; + } + + public abstract ModelAndView handleRequest() throws Exception; + + + @Override + public void setPageConfig(PageConfig page) + { + _pageConfig = page; + } + + + @Override + public Container getContainer() + { + return getViewContext().getContainer(); + } + + + @Override + public User getUser() + { + return getViewContext().getUser(); + } + + + @Override + public PageConfig getPageConfig() + { + return _pageConfig; + } + + + public void setTitle(String title) + { + assert null != getPageConfig() : "action not initialized property"; + getPageConfig().setTitle(title); + } + + + public void setHelpTopic(String topicName) + { + setHelpTopic(new HelpTopic(topicName)); + } + + + public void setHelpTopic(HelpTopic topic) + { + assert null != getPageConfig() : "action not initialized properly"; + getPageConfig().setHelpTopic(topic); + } + + + protected Object newInstance(Class c) + { + try + { + return c == null ? null : c.getConstructor().newInstance(); + } + catch (Exception x) + { + if (x instanceof RuntimeException) + throw ((RuntimeException)x); + else + throw new RuntimeException(x); + } + } + + + protected @NotNull FORM getCommand(HttpServletRequest request) throws Exception + { + FORM command = (FORM) createCommand(); + + if (command instanceof HasViewContext) + ((HasViewContext)command).setViewContext(getViewContext()); + + return command; + } + + + protected @NotNull FORM getCommand() throws Exception + { + return getCommand(getViewContext().getRequest()); + } + + + // + // PARAMETER BINDING + // + // don't assume parameters always come from a request, use PropertyValues interface + // + + public @NotNull BindException defaultBindParameters(FORM form, PropertyValues params) + { + return defaultBindParameters(form, getCommandName(), params); + } + + + public static @NotNull BindException defaultBindParameters(Object form, String commandName, PropertyValues params) + { + /* check for do-it-myself forms */ + if (form instanceof HasBindParameters) + { + return ((HasBindParameters)form).bindParameters(params); + } + + if (form instanceof DynaBean) + { + return simpleBindParameters(form, commandName, params); + } + else + { + return springBindParameters(form, commandName, params); + } + } + + public static @NotNull BindException springBindParameters(Object command, String commandName, PropertyValues params) + { + Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); + ServletRequestDataBinder binder = new ServletRequestDataBinder(command, commandName); + + String[] fields = binder.getDisallowedFields(); + List fieldList = new ArrayList<>(fields != null ? Arrays.asList(fields) : Collections.emptyList()); + fieldList.addAll(Arrays.asList("class.*", "Class.*", "*.class.*", "*.Class.*")); + binder.setDisallowedFields(fieldList.toArray(new String[] {})); + + ConvertHelper.getPropertyEditorRegistrar().registerCustomEditors(binder); + BindingErrorProcessor defaultBEP = binder.getBindingErrorProcessor(); + binder.setBindingErrorProcessor(getBindingErrorProcessor(defaultBEP)); + binder.setFieldMarkerPrefix(SpringActionController.FIELD_MARKER); + try + { + // most paths probably called getPropertyValuesForFormBinding() already, but this is a public static method, so call it again + binder.bind(getPropertyValuesForFormBinding(params, allow)); + BindException errors = new NullSafeBindException(binder.getBindingResult()); + return errors; + } + catch (InvalidPropertyException x) + { + // Maybe we should propagate exception and return SC_BAD_REQUEST (in ExceptionUtil.handleException()) + // most POST handlers check errors.hasErrors(), but not all GET handlers do + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding property: " + x.getPropertyName()); + return errors; + } + catch (NumberFormatException x) + { + // Malformed array parameter throws this exception, unfortunately. Just reject the request. #21931 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; invalid array index (" + x.getMessage() + ")"); + return errors; + } + catch (NegativeArraySizeException x) + { + // Another malformed array parameter throws this exception. #23929 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; negative array size (" + x.getMessage() + ")"); + return errors; + } + catch (IllegalArgumentException x) + { + // General bean binding problem. #23929 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding property; (" + x.getMessage() + ")"); + return errors; + } + } + + + static BindingErrorProcessor getBindingErrorProcessor(final BindingErrorProcessor defaultBEP) + { + return new BindingErrorProcessor() + { + @Override + public void processMissingFieldError(String missingField, BindingResult bindingResult) + { + defaultBEP.processMissingFieldError(missingField, bindingResult); + } + + @Override + public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) + { + Object newValue = ex.getPropertyChangeEvent().getNewValue(); + if (newValue instanceof String) + newValue = StringUtils.trimToNull((String)newValue); + + // convert NULL conversion errors to required errors + if (null == newValue) + defaultBEP.processMissingFieldError(ex.getPropertyChangeEvent().getPropertyName(), bindingResult); + else + defaultBEP.processPropertyAccessException(ex, bindingResult); + } + }; + } + + + /* + * This binder doesn't have much to offer over the standard spring data binding except that it will + * handle DynaBeans. + */ + public static @NotNull BindException simpleBindParameters(Object command, String commandName, PropertyValues params) + { + Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); + + BindException errors = new NullSafeBindException(command, "Form"); + + // unfortunately ObjectFactory and BeanObjectFactory are not good about reporting errors + // do this by hand + for (PropertyValue pv : params.getPropertyValues()) + { + String propertyName = pv.getName(); + Object value = pv.getValue(); + if (!allow.test(propertyName)) + continue; + + try + { + Object converted = value; + Class propClass = PropertyUtils.getPropertyType(command, propertyName); + if (null == propClass) + continue; + if (value == null) + { + /* */ + } + else if (propClass.isPrimitive()) + { + converted = ConvertUtils.convert(String.valueOf(value), propClass); + } + else if (propClass.isArray()) + { + if (value instanceof Collection) + value = ((Collection) value).toArray(new String[0]); + else if (!value.getClass().isArray()) + value = new String[] {String.valueOf(value)}; + converted = ConvertUtils.convert((String[])value, propClass); + } + PropertyUtils.setProperty(command, propertyName, converted); + } + catch (ConversionException x) + { + errors.addError(new FieldError(commandName, propertyName, value, true, new String[] {"ConversionError", "typeMismatch"}, null, "Could not convert to value: " + value)); + } + catch (Exception x) + { + errors.addError(new ObjectError(commandName, new String[]{"Error"}, new Object[] {value}, x.getMessage())); + logger.error("unexpected error", x); + } + } + return errors; + } + + @Override + public boolean supports(Class clazz) + { + return getCommandClass().isAssignableFrom(clazz); + } + + public Map getTransactionAuditDetails() + { + return getTransactionAuditDetails(getViewContext()); + } + + public Map getTransactionAuditDetails(TransactionViewForm form) + { + Map transactionAuditDetails = getTransactionAuditDetails(); + if (form.getRequestSource() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.RequestSource, form.getRequestSource()); + if (form.getEditMethod() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.EditMethod, form.getEditMethod()); + return getTransactionAuditDetails(getViewContext()); + } + + public static Map getTransactionAuditDetails(ViewContext viewContext) + { + Map map = new HashMap<>(); + map.put(TransactionAuditProvider.TransactionDetail.Action, viewContext.getActionURL().getController() + "-" + viewContext.getActionURL().getAction()); + String clientLibrary = HttpUtil.getClientLibrary(viewContext.getRequest()); + if (null != clientLibrary) + map.put(TransactionAuditProvider.TransactionDetail.ClientLibrary, clientLibrary); + else + { + String productName = HttpUtil.getProductNameFromReferer(viewContext.getRequest()); // app + if (null != productName) + map.put(TransactionAuditProvider.TransactionDetail.Product, productName); + else // LKS + { + String refererRelativeURL = HttpUtil.getRefererRelativeURL(viewContext.getRequest()); + map.put(TransactionAuditProvider.TransactionDetail.RequestSource, refererRelativeURL); + } + } + return map; + } + + /* for TableViewForm, uses BeanUtils to work with DynaBeans */ + static public class BeanUtilsPropertyBindingResult extends BeanPropertyBindingResult + { + public BeanUtilsPropertyBindingResult(Object target, String objectName) + { + super(target, objectName); + } + + @Override + protected BeanWrapper createBeanWrapper() + { + return new BeanUtilsWrapperImpl((DynaBean)getTarget()); + } + } + + static public class BeanUtilsWrapperImpl extends AbstractPropertyAccessor implements BeanWrapper + { + private Object object; + private boolean autoGrowNestedPaths = false; + private int autoGrowCollectionLimit = 0; + + public BeanUtilsWrapperImpl() + { + // registerDefaultEditors(); + } + + public BeanUtilsWrapperImpl(DynaBean target) + { + this(); + object = target; + } + + @Override + public Object getPropertyValue(String propertyName) throws BeansException + { + try + { + return PropertyUtils.getProperty(object, propertyName); + } + catch (Exception e) + { + throw new NotReadablePropertyException(object.getClass(), propertyName); + } + } + + @Override + public void setPropertyValue(String propertyName, Object value) throws BeansException + { + try + { + PropertyUtils.setProperty(object, propertyName, value); + } + catch (Exception e) + { + throw new NotWritablePropertyException(object.getClass(), propertyName); + } + } + + @Override + public boolean isReadableProperty(String propertyName) + { + return true; + } + + @Override + public boolean isWritableProperty(String propertyName) + { + return true; + } + + @Override + public TypeDescriptor getPropertyTypeDescriptor(String s) throws BeansException + { + return null; + } + + public void setWrappedInstance(Object obj) + { + object = obj; + } + + @Override + public Object getWrappedInstance() + { + return object; + } + + @Override + public Class getWrappedClass() + { + return object.getClass(); + } + + @Override + public PropertyDescriptor[] getPropertyDescriptors() + { + throw new UnsupportedOperationException(); + } + + @Override + public PropertyDescriptor getPropertyDescriptor(String propertyName) throws BeansException + { + throw new UnsupportedOperationException(); + } + + @Override + public void setAutoGrowNestedPaths(boolean b) + { + this.autoGrowNestedPaths = b; + } + + @Override + public boolean isAutoGrowNestedPaths() + { + return this.autoGrowNestedPaths; + } + + @Override + public void setAutoGrowCollectionLimit(int i) + { + this.autoGrowCollectionLimit = i; + } + + @Override + public int getAutoGrowCollectionLimit() + { + return this.autoGrowCollectionLimit; + } + + @Override + public T convertIfNecessary(Object value, Class requiredType) throws TypeMismatchException + { + if (value == null) + return null; + return (T)ConvertUtils.convert(String.valueOf(value), requiredType); + } + + @Override + public T convertIfNecessary(Object value, Class requiredType, MethodParameter methodParam) throws TypeMismatchException + { + return convertIfNecessary(value, requiredType); + } + } + + /** + * @return a map from form element name to uploaded files + */ + protected Map getFileMap() + { + if (getViewContext().getRequest() instanceof MultipartHttpServletRequest) + return ((MultipartHttpServletRequest)getViewContext().getRequest()).getFileMap(); + return Collections.emptyMap(); + } + + protected List getAttachmentFileList() + { + return SpringAttachmentFile.createList(getFileMap()); + } + + public boolean isRobot() + { + return _robot; + } + + public boolean isPrint() + { + return _print; + } + + public boolean isDebug() + { + return _debug; + } + + public @NotNull Class getCommandClass() + { + if (null == _commandClass) + throw new IllegalStateException("NULL _commandClass in " + getClass().getName()); + return _commandClass; + } + + public void setCommandClass(@NotNull Class commandClass) + { + _commandClass = commandClass; + } + + protected final @NotNull Object createCommand() + { + return BeanUtils.instantiateClass(getCommandClass()); + } + + public void setCommandName(String commandName) + { + _commandName = commandName; + } + + public String getCommandName() + { + return _commandName; + } + + /** + * Cacheable resources can calculate a last modified timestamp to send to the browser. + */ + protected long getLastModified(FORM form) + { + return Long.MIN_VALUE; + } + + /** + * Cacheable resources can calculate an ETag header to send to the browser. + */ + protected String getETag(FORM form) + { + return null; + } +} diff --git a/api/src/org/labkey/api/view/TransactionViewForm.java b/api/src/org/labkey/api/view/TransactionViewForm.java new file mode 100644 index 00000000000..c849f5aaad3 --- /dev/null +++ b/api/src/org/labkey/api/view/TransactionViewForm.java @@ -0,0 +1,40 @@ +package org.labkey.api.view; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.audit.TransactionAuditProvider; + +import java.util.Map; + +public class TransactionViewForm extends ViewForm +{ + private String _editMethod; + private String _requestSource; + + public String getRequestSource() + { + return _requestSource; + } + + public void setRequestSource(String requestSource) + { + _requestSource = requestSource; + } + + public String getEditMethod() + { + return _editMethod; + } + + public void setEditMethod(String editMethod) + { + _editMethod = editMethod; + } + + public void addTransactionAuditDetails(@NotNull Map transactionAuditDetails) + { + if (getRequestSource() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.RequestSource, getRequestSource()); + if (getEditMethod() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.EditMethod, getEditMethod()); + } +} From 67e059a4776213ad884d86bb10d1682f1d8dbd14 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 29 Dec 2025 08:19:34 -0800 Subject: [PATCH 2/2] crlf --- .../org/labkey/api/action/BaseViewAction.java | 1590 ++++++++--------- 1 file changed, 795 insertions(+), 795 deletions(-) diff --git a/api/src/org/labkey/api/action/BaseViewAction.java b/api/src/org/labkey/api/action/BaseViewAction.java index dff2a449544..0e9e69946d4 100644 --- a/api/src/org/labkey/api/action/BaseViewAction.java +++ b/api/src/org/labkey/api/action/BaseViewAction.java @@ -1,795 +1,795 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.action; - -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.beanutils.DynaBean; -import org.apache.commons.beanutils.PropertyUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.SpringAttachmentFile; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.data.Container; -import org.labkey.api.data.ConvertHelper; -import org.labkey.api.security.User; -import org.labkey.api.util.HelpTopic; -import org.labkey.api.util.HttpUtil; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.TransactionViewForm; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.ContainerUser; -import org.springframework.beans.AbstractPropertyAccessor; -import org.springframework.beans.BeanUtils; -import org.springframework.beans.BeanWrapper; -import org.springframework.beans.BeansException; -import org.springframework.beans.InvalidPropertyException; -import org.springframework.beans.MutablePropertyValues; -import org.springframework.beans.NotReadablePropertyException; -import org.springframework.beans.NotWritablePropertyException; -import org.springframework.beans.PropertyAccessException; -import org.springframework.beans.PropertyValue; -import org.springframework.beans.PropertyValues; -import org.springframework.beans.TypeMismatchException; -import org.springframework.core.MethodParameter; -import org.springframework.core.convert.TypeDescriptor; -import org.springframework.lang.Nullable; -import org.springframework.validation.BeanPropertyBindingResult; -import org.springframework.validation.BindException; -import org.springframework.validation.BindingErrorProcessor; -import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.validation.Validator; -import org.springframework.web.bind.ServletRequestDataBinder; -import org.springframework.web.bind.ServletRequestParameterPropertyValues; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.servlet.ModelAndView; - -import java.beans.PropertyDescriptor; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Predicate; - -public abstract class BaseViewAction extends PermissionCheckableAction implements Validator, HasPageConfig, ContainerUser -{ - protected static final Logger logger = LogHelper.getLogger(BaseViewAction.class, "BaseViewAction"); - - private PageConfig _pageConfig = null; - private PropertyValues _pvs; - private boolean _robot = false; // Is this request from GoogleBot or some other crawler? - private boolean _debug = false; - - protected boolean _print = false; - protected Class _commandClass; - protected String _commandName = "form"; - - protected BaseViewAction() - { - String methodName = getCommandClassMethodName(); - - if (null == methodName) - return; - - // inspect the action's *public* methods to determine form class - Class typeBest = null; - for (Method m : this.getClass().getMethods()) - { - if (methodName.equals(m.getName())) - { - Class[] types = m.getParameterTypes(); - if (types.length < 1) - continue; - Class typeCurrent = types[0]; - assert null == _commandClass || typeCurrent.equals(_commandClass); - - // Using templated classes to extend a base action can lead to multiple - // versions of a method with acceptable types, so take the most extended - // type we can find. - if (typeBest == null || typeBest.isAssignableFrom(typeCurrent)) - typeBest = typeCurrent; - } - } - if (typeBest != null) - setCommandClass(typeBest); - } - - - protected abstract String getCommandClassMethodName(); - - - protected BaseViewAction(@NotNull Class commandClass) - { - setCommandClass(commandClass); - } - - - public void setProperties(PropertyValues pvs) - { - _pvs = pvs; - } - - - public void setProperties(Map m) - { - _pvs = new MutablePropertyValues(m); - } - - - /* Doesn't guarantee non-null, non-empty */ - public Object getProperty(String key, String d) - { - PropertyValue pv = _pvs.getPropertyValue(key); - return pv == null ? d : pv.getValue(); - } - - - public Object getProperty(Enum key) - { - PropertyValue pv = _pvs.getPropertyValue(key.name()); - return pv == null ? null : pv.getValue(); - } - - - public Object getProperty(String key) - { - PropertyValue pv = _pvs.getPropertyValue(key); - return pv == null ? null : pv.getValue(); - } - - public PropertyValues getPropertyValues() - { - return _pvs; - } - - - public static PropertyValues getPropertyValuesForFormBinding(PropertyValues pvs, @NotNull Predicate allowBind) - { - if (null == pvs) - return null; - MutablePropertyValues ret = new MutablePropertyValues(); - for (PropertyValue pv : pvs.getPropertyValues()) - { - if (allowBind.test(pv.getName())) - ret.addPropertyValue(pv); - } - return ret; - } - - static final String FORM_DATE_ENCODED_PARAM = "formDataEncoded"; - - /** - * When a double quote is encountered in a multipart/form-data context, it is encoded as %22 using URL-encoding by browsers. - * This process replaces the double quote with its hexadecimal equivalent in a URL-safe format, preventing it from being misinterpreted as the end of a value or a boundary. - * The consequence of such encoding is we can't distinguish '"' from the actual '%22' in parameter name. - * As a workaround, a client-side util `encodeFormDataQuote` is used to convert %22 to %2522 and " to %22 explicitly, while passing in an additional param formDataEncoded=true. - * This class converts those encoded param names back to its decoded form during PropertyValues binding. - * See Issue 52827, 52925 and 52119 for more information. - */ - static public class ViewActionParameterPropertyValues extends ServletRequestParameterPropertyValues - { - - public ViewActionParameterPropertyValues(ServletRequest request) { - this(request, null, null); - } - - public ViewActionParameterPropertyValues(ServletRequest request, @Nullable String prefix, @Nullable String prefixSeparator) - { - super(request, prefix, prefixSeparator); - if (isFormDataEncoded()) - { - for (int i = 0; i < getPropertyValues().length; i++) - { - PropertyValue formDataPropValue = getPropertyValues()[i]; - String propValueName = formDataPropValue.getName(); - String decoded = PageFlowUtil.decodeQuoteEncodedFormDataKey(propValueName); - if (!propValueName.equals(decoded)) - setPropertyValueAt(new PropertyValue(decoded, formDataPropValue.getValue()), i); - } - } - } - - private boolean isFormDataEncoded() - { - PropertyValue formDataPropValue = getPropertyValue(FORM_DATE_ENCODED_PARAM); - if (formDataPropValue != null) - { - Object v = formDataPropValue.getValue(); - String formDataPropValueStr = v == null ? null : String.valueOf(v); - if (StringUtils.isNotBlank(formDataPropValueStr)) - return (Boolean) ConvertUtils.convert(formDataPropValueStr, Boolean.class); - } - - return false; - } - } - - @Override - public ModelAndView handleRequest(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws Exception - { - if (null == getPropertyValues()) - setProperties(new ViewActionParameterPropertyValues(request)); - getViewContext().setBindPropertyValues(getPropertyValues()); - handleSpecialProperties(); - - return handleRequest(); - } - - - private void handleSpecialProperties() - { - _robot = PageFlowUtil.isRobotUserAgent(getViewContext().getRequest().getHeader("User-Agent")); - - // Special flag puts actions in "debug" mode, during which they should log extra information that would be - // helpful for testing or debugging problems - if (!_robot && hasStringValue("_debug")) - { - _debug = true; - } - - // SpringActionController.defaultPageConfig() has logic for isPrint, don't need to duplicate here - _print = PageConfig.Template.Print == HttpView.currentPageConfig().getTemplate(); - } - - private boolean hasStringValue(String propertyName) - { - Object o = getProperty(propertyName); - if (o == null) - { - return false; - } - if (o instanceof String s) - { - return !StringUtils.isBlank(s); - } - if (o instanceof String[] strings) - { - for (String s : strings) - { - if (!StringUtils.isBlank(s)) - { - return true; - } - } - } - return false; - } - - public abstract ModelAndView handleRequest() throws Exception; - - - @Override - public void setPageConfig(PageConfig page) - { - _pageConfig = page; - } - - - @Override - public Container getContainer() - { - return getViewContext().getContainer(); - } - - - @Override - public User getUser() - { - return getViewContext().getUser(); - } - - - @Override - public PageConfig getPageConfig() - { - return _pageConfig; - } - - - public void setTitle(String title) - { - assert null != getPageConfig() : "action not initialized property"; - getPageConfig().setTitle(title); - } - - - public void setHelpTopic(String topicName) - { - setHelpTopic(new HelpTopic(topicName)); - } - - - public void setHelpTopic(HelpTopic topic) - { - assert null != getPageConfig() : "action not initialized properly"; - getPageConfig().setHelpTopic(topic); - } - - - protected Object newInstance(Class c) - { - try - { - return c == null ? null : c.getConstructor().newInstance(); - } - catch (Exception x) - { - if (x instanceof RuntimeException) - throw ((RuntimeException)x); - else - throw new RuntimeException(x); - } - } - - - protected @NotNull FORM getCommand(HttpServletRequest request) throws Exception - { - FORM command = (FORM) createCommand(); - - if (command instanceof HasViewContext) - ((HasViewContext)command).setViewContext(getViewContext()); - - return command; - } - - - protected @NotNull FORM getCommand() throws Exception - { - return getCommand(getViewContext().getRequest()); - } - - - // - // PARAMETER BINDING - // - // don't assume parameters always come from a request, use PropertyValues interface - // - - public @NotNull BindException defaultBindParameters(FORM form, PropertyValues params) - { - return defaultBindParameters(form, getCommandName(), params); - } - - - public static @NotNull BindException defaultBindParameters(Object form, String commandName, PropertyValues params) - { - /* check for do-it-myself forms */ - if (form instanceof HasBindParameters) - { - return ((HasBindParameters)form).bindParameters(params); - } - - if (form instanceof DynaBean) - { - return simpleBindParameters(form, commandName, params); - } - else - { - return springBindParameters(form, commandName, params); - } - } - - public static @NotNull BindException springBindParameters(Object command, String commandName, PropertyValues params) - { - Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); - ServletRequestDataBinder binder = new ServletRequestDataBinder(command, commandName); - - String[] fields = binder.getDisallowedFields(); - List fieldList = new ArrayList<>(fields != null ? Arrays.asList(fields) : Collections.emptyList()); - fieldList.addAll(Arrays.asList("class.*", "Class.*", "*.class.*", "*.Class.*")); - binder.setDisallowedFields(fieldList.toArray(new String[] {})); - - ConvertHelper.getPropertyEditorRegistrar().registerCustomEditors(binder); - BindingErrorProcessor defaultBEP = binder.getBindingErrorProcessor(); - binder.setBindingErrorProcessor(getBindingErrorProcessor(defaultBEP)); - binder.setFieldMarkerPrefix(SpringActionController.FIELD_MARKER); - try - { - // most paths probably called getPropertyValuesForFormBinding() already, but this is a public static method, so call it again - binder.bind(getPropertyValuesForFormBinding(params, allow)); - BindException errors = new NullSafeBindException(binder.getBindingResult()); - return errors; - } - catch (InvalidPropertyException x) - { - // Maybe we should propagate exception and return SC_BAD_REQUEST (in ExceptionUtil.handleException()) - // most POST handlers check errors.hasErrors(), but not all GET handlers do - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding property: " + x.getPropertyName()); - return errors; - } - catch (NumberFormatException x) - { - // Malformed array parameter throws this exception, unfortunately. Just reject the request. #21931 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; invalid array index (" + x.getMessage() + ")"); - return errors; - } - catch (NegativeArraySizeException x) - { - // Another malformed array parameter throws this exception. #23929 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; negative array size (" + x.getMessage() + ")"); - return errors; - } - catch (IllegalArgumentException x) - { - // General bean binding problem. #23929 - BindException errors = new BindException(command, commandName); - errors.reject(SpringActionController.ERROR_MSG, "Error binding property; (" + x.getMessage() + ")"); - return errors; - } - } - - - static BindingErrorProcessor getBindingErrorProcessor(final BindingErrorProcessor defaultBEP) - { - return new BindingErrorProcessor() - { - @Override - public void processMissingFieldError(String missingField, BindingResult bindingResult) - { - defaultBEP.processMissingFieldError(missingField, bindingResult); - } - - @Override - public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) - { - Object newValue = ex.getPropertyChangeEvent().getNewValue(); - if (newValue instanceof String) - newValue = StringUtils.trimToNull((String)newValue); - - // convert NULL conversion errors to required errors - if (null == newValue) - defaultBEP.processMissingFieldError(ex.getPropertyChangeEvent().getPropertyName(), bindingResult); - else - defaultBEP.processPropertyAccessException(ex, bindingResult); - } - }; - } - - - /* - * This binder doesn't have much to offer over the standard spring data binding except that it will - * handle DynaBeans. - */ - public static @NotNull BindException simpleBindParameters(Object command, String commandName, PropertyValues params) - { - Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); - - BindException errors = new NullSafeBindException(command, "Form"); - - // unfortunately ObjectFactory and BeanObjectFactory are not good about reporting errors - // do this by hand - for (PropertyValue pv : params.getPropertyValues()) - { - String propertyName = pv.getName(); - Object value = pv.getValue(); - if (!allow.test(propertyName)) - continue; - - try - { - Object converted = value; - Class propClass = PropertyUtils.getPropertyType(command, propertyName); - if (null == propClass) - continue; - if (value == null) - { - /* */ - } - else if (propClass.isPrimitive()) - { - converted = ConvertUtils.convert(String.valueOf(value), propClass); - } - else if (propClass.isArray()) - { - if (value instanceof Collection) - value = ((Collection) value).toArray(new String[0]); - else if (!value.getClass().isArray()) - value = new String[] {String.valueOf(value)}; - converted = ConvertUtils.convert((String[])value, propClass); - } - PropertyUtils.setProperty(command, propertyName, converted); - } - catch (ConversionException x) - { - errors.addError(new FieldError(commandName, propertyName, value, true, new String[] {"ConversionError", "typeMismatch"}, null, "Could not convert to value: " + value)); - } - catch (Exception x) - { - errors.addError(new ObjectError(commandName, new String[]{"Error"}, new Object[] {value}, x.getMessage())); - logger.error("unexpected error", x); - } - } - return errors; - } - - @Override - public boolean supports(Class clazz) - { - return getCommandClass().isAssignableFrom(clazz); - } - - public Map getTransactionAuditDetails() - { - return getTransactionAuditDetails(getViewContext()); - } - - public Map getTransactionAuditDetails(TransactionViewForm form) - { - Map transactionAuditDetails = getTransactionAuditDetails(); - if (form.getRequestSource() != null) - transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.RequestSource, form.getRequestSource()); - if (form.getEditMethod() != null) - transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.EditMethod, form.getEditMethod()); - return getTransactionAuditDetails(getViewContext()); - } - - public static Map getTransactionAuditDetails(ViewContext viewContext) - { - Map map = new HashMap<>(); - map.put(TransactionAuditProvider.TransactionDetail.Action, viewContext.getActionURL().getController() + "-" + viewContext.getActionURL().getAction()); - String clientLibrary = HttpUtil.getClientLibrary(viewContext.getRequest()); - if (null != clientLibrary) - map.put(TransactionAuditProvider.TransactionDetail.ClientLibrary, clientLibrary); - else - { - String productName = HttpUtil.getProductNameFromReferer(viewContext.getRequest()); // app - if (null != productName) - map.put(TransactionAuditProvider.TransactionDetail.Product, productName); - else // LKS - { - String refererRelativeURL = HttpUtil.getRefererRelativeURL(viewContext.getRequest()); - map.put(TransactionAuditProvider.TransactionDetail.RequestSource, refererRelativeURL); - } - } - return map; - } - - /* for TableViewForm, uses BeanUtils to work with DynaBeans */ - static public class BeanUtilsPropertyBindingResult extends BeanPropertyBindingResult - { - public BeanUtilsPropertyBindingResult(Object target, String objectName) - { - super(target, objectName); - } - - @Override - protected BeanWrapper createBeanWrapper() - { - return new BeanUtilsWrapperImpl((DynaBean)getTarget()); - } - } - - static public class BeanUtilsWrapperImpl extends AbstractPropertyAccessor implements BeanWrapper - { - private Object object; - private boolean autoGrowNestedPaths = false; - private int autoGrowCollectionLimit = 0; - - public BeanUtilsWrapperImpl() - { - // registerDefaultEditors(); - } - - public BeanUtilsWrapperImpl(DynaBean target) - { - this(); - object = target; - } - - @Override - public Object getPropertyValue(String propertyName) throws BeansException - { - try - { - return PropertyUtils.getProperty(object, propertyName); - } - catch (Exception e) - { - throw new NotReadablePropertyException(object.getClass(), propertyName); - } - } - - @Override - public void setPropertyValue(String propertyName, Object value) throws BeansException - { - try - { - PropertyUtils.setProperty(object, propertyName, value); - } - catch (Exception e) - { - throw new NotWritablePropertyException(object.getClass(), propertyName); - } - } - - @Override - public boolean isReadableProperty(String propertyName) - { - return true; - } - - @Override - public boolean isWritableProperty(String propertyName) - { - return true; - } - - @Override - public TypeDescriptor getPropertyTypeDescriptor(String s) throws BeansException - { - return null; - } - - public void setWrappedInstance(Object obj) - { - object = obj; - } - - @Override - public Object getWrappedInstance() - { - return object; - } - - @Override - public Class getWrappedClass() - { - return object.getClass(); - } - - @Override - public PropertyDescriptor[] getPropertyDescriptors() - { - throw new UnsupportedOperationException(); - } - - @Override - public PropertyDescriptor getPropertyDescriptor(String propertyName) throws BeansException - { - throw new UnsupportedOperationException(); - } - - @Override - public void setAutoGrowNestedPaths(boolean b) - { - this.autoGrowNestedPaths = b; - } - - @Override - public boolean isAutoGrowNestedPaths() - { - return this.autoGrowNestedPaths; - } - - @Override - public void setAutoGrowCollectionLimit(int i) - { - this.autoGrowCollectionLimit = i; - } - - @Override - public int getAutoGrowCollectionLimit() - { - return this.autoGrowCollectionLimit; - } - - @Override - public T convertIfNecessary(Object value, Class requiredType) throws TypeMismatchException - { - if (value == null) - return null; - return (T)ConvertUtils.convert(String.valueOf(value), requiredType); - } - - @Override - public T convertIfNecessary(Object value, Class requiredType, MethodParameter methodParam) throws TypeMismatchException - { - return convertIfNecessary(value, requiredType); - } - } - - /** - * @return a map from form element name to uploaded files - */ - protected Map getFileMap() - { - if (getViewContext().getRequest() instanceof MultipartHttpServletRequest) - return ((MultipartHttpServletRequest)getViewContext().getRequest()).getFileMap(); - return Collections.emptyMap(); - } - - protected List getAttachmentFileList() - { - return SpringAttachmentFile.createList(getFileMap()); - } - - public boolean isRobot() - { - return _robot; - } - - public boolean isPrint() - { - return _print; - } - - public boolean isDebug() - { - return _debug; - } - - public @NotNull Class getCommandClass() - { - if (null == _commandClass) - throw new IllegalStateException("NULL _commandClass in " + getClass().getName()); - return _commandClass; - } - - public void setCommandClass(@NotNull Class commandClass) - { - _commandClass = commandClass; - } - - protected final @NotNull Object createCommand() - { - return BeanUtils.instantiateClass(getCommandClass()); - } - - public void setCommandName(String commandName) - { - _commandName = commandName; - } - - public String getCommandName() - { - return _commandName; - } - - /** - * Cacheable resources can calculate a last modified timestamp to send to the browser. - */ - protected long getLastModified(FORM form) - { - return Long.MIN_VALUE; - } - - /** - * Cacheable resources can calculate an ETag header to send to the browser. - */ - protected String getETag(FORM form) - { - return null; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.action; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.beanutils.DynaBean; +import org.apache.commons.beanutils.PropertyUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.SpringAttachmentFile; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.data.Container; +import org.labkey.api.data.ConvertHelper; +import org.labkey.api.security.User; +import org.labkey.api.util.HelpTopic; +import org.labkey.api.util.HttpUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.TransactionViewForm; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.ContainerUser; +import org.springframework.beans.AbstractPropertyAccessor; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeansException; +import org.springframework.beans.InvalidPropertyException; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.NotReadablePropertyException; +import org.springframework.beans.NotWritablePropertyException; +import org.springframework.beans.PropertyAccessException; +import org.springframework.beans.PropertyValue; +import org.springframework.beans.PropertyValues; +import org.springframework.beans.TypeMismatchException; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingErrorProcessor; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.validation.Validator; +import org.springframework.web.bind.ServletRequestDataBinder; +import org.springframework.web.bind.ServletRequestParameterPropertyValues; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.ModelAndView; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public abstract class BaseViewAction extends PermissionCheckableAction implements Validator, HasPageConfig, ContainerUser +{ + protected static final Logger logger = LogHelper.getLogger(BaseViewAction.class, "BaseViewAction"); + + private PageConfig _pageConfig = null; + private PropertyValues _pvs; + private boolean _robot = false; // Is this request from GoogleBot or some other crawler? + private boolean _debug = false; + + protected boolean _print = false; + protected Class _commandClass; + protected String _commandName = "form"; + + protected BaseViewAction() + { + String methodName = getCommandClassMethodName(); + + if (null == methodName) + return; + + // inspect the action's *public* methods to determine form class + Class typeBest = null; + for (Method m : this.getClass().getMethods()) + { + if (methodName.equals(m.getName())) + { + Class[] types = m.getParameterTypes(); + if (types.length < 1) + continue; + Class typeCurrent = types[0]; + assert null == _commandClass || typeCurrent.equals(_commandClass); + + // Using templated classes to extend a base action can lead to multiple + // versions of a method with acceptable types, so take the most extended + // type we can find. + if (typeBest == null || typeBest.isAssignableFrom(typeCurrent)) + typeBest = typeCurrent; + } + } + if (typeBest != null) + setCommandClass(typeBest); + } + + + protected abstract String getCommandClassMethodName(); + + + protected BaseViewAction(@NotNull Class commandClass) + { + setCommandClass(commandClass); + } + + + public void setProperties(PropertyValues pvs) + { + _pvs = pvs; + } + + + public void setProperties(Map m) + { + _pvs = new MutablePropertyValues(m); + } + + + /* Doesn't guarantee non-null, non-empty */ + public Object getProperty(String key, String d) + { + PropertyValue pv = _pvs.getPropertyValue(key); + return pv == null ? d : pv.getValue(); + } + + + public Object getProperty(Enum key) + { + PropertyValue pv = _pvs.getPropertyValue(key.name()); + return pv == null ? null : pv.getValue(); + } + + + public Object getProperty(String key) + { + PropertyValue pv = _pvs.getPropertyValue(key); + return pv == null ? null : pv.getValue(); + } + + public PropertyValues getPropertyValues() + { + return _pvs; + } + + + public static PropertyValues getPropertyValuesForFormBinding(PropertyValues pvs, @NotNull Predicate allowBind) + { + if (null == pvs) + return null; + MutablePropertyValues ret = new MutablePropertyValues(); + for (PropertyValue pv : pvs.getPropertyValues()) + { + if (allowBind.test(pv.getName())) + ret.addPropertyValue(pv); + } + return ret; + } + + static final String FORM_DATE_ENCODED_PARAM = "formDataEncoded"; + + /** + * When a double quote is encountered in a multipart/form-data context, it is encoded as %22 using URL-encoding by browsers. + * This process replaces the double quote with its hexadecimal equivalent in a URL-safe format, preventing it from being misinterpreted as the end of a value or a boundary. + * The consequence of such encoding is we can't distinguish '"' from the actual '%22' in parameter name. + * As a workaround, a client-side util `encodeFormDataQuote` is used to convert %22 to %2522 and " to %22 explicitly, while passing in an additional param formDataEncoded=true. + * This class converts those encoded param names back to its decoded form during PropertyValues binding. + * See Issue 52827, 52925 and 52119 for more information. + */ + static public class ViewActionParameterPropertyValues extends ServletRequestParameterPropertyValues + { + + public ViewActionParameterPropertyValues(ServletRequest request) { + this(request, null, null); + } + + public ViewActionParameterPropertyValues(ServletRequest request, @Nullable String prefix, @Nullable String prefixSeparator) + { + super(request, prefix, prefixSeparator); + if (isFormDataEncoded()) + { + for (int i = 0; i < getPropertyValues().length; i++) + { + PropertyValue formDataPropValue = getPropertyValues()[i]; + String propValueName = formDataPropValue.getName(); + String decoded = PageFlowUtil.decodeQuoteEncodedFormDataKey(propValueName); + if (!propValueName.equals(decoded)) + setPropertyValueAt(new PropertyValue(decoded, formDataPropValue.getValue()), i); + } + } + } + + private boolean isFormDataEncoded() + { + PropertyValue formDataPropValue = getPropertyValue(FORM_DATE_ENCODED_PARAM); + if (formDataPropValue != null) + { + Object v = formDataPropValue.getValue(); + String formDataPropValueStr = v == null ? null : String.valueOf(v); + if (StringUtils.isNotBlank(formDataPropValueStr)) + return (Boolean) ConvertUtils.convert(formDataPropValueStr, Boolean.class); + } + + return false; + } + } + + @Override + public ModelAndView handleRequest(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) throws Exception + { + if (null == getPropertyValues()) + setProperties(new ViewActionParameterPropertyValues(request)); + getViewContext().setBindPropertyValues(getPropertyValues()); + handleSpecialProperties(); + + return handleRequest(); + } + + + private void handleSpecialProperties() + { + _robot = PageFlowUtil.isRobotUserAgent(getViewContext().getRequest().getHeader("User-Agent")); + + // Special flag puts actions in "debug" mode, during which they should log extra information that would be + // helpful for testing or debugging problems + if (!_robot && hasStringValue("_debug")) + { + _debug = true; + } + + // SpringActionController.defaultPageConfig() has logic for isPrint, don't need to duplicate here + _print = PageConfig.Template.Print == HttpView.currentPageConfig().getTemplate(); + } + + private boolean hasStringValue(String propertyName) + { + Object o = getProperty(propertyName); + if (o == null) + { + return false; + } + if (o instanceof String s) + { + return !StringUtils.isBlank(s); + } + if (o instanceof String[] strings) + { + for (String s : strings) + { + if (!StringUtils.isBlank(s)) + { + return true; + } + } + } + return false; + } + + public abstract ModelAndView handleRequest() throws Exception; + + + @Override + public void setPageConfig(PageConfig page) + { + _pageConfig = page; + } + + + @Override + public Container getContainer() + { + return getViewContext().getContainer(); + } + + + @Override + public User getUser() + { + return getViewContext().getUser(); + } + + + @Override + public PageConfig getPageConfig() + { + return _pageConfig; + } + + + public void setTitle(String title) + { + assert null != getPageConfig() : "action not initialized property"; + getPageConfig().setTitle(title); + } + + + public void setHelpTopic(String topicName) + { + setHelpTopic(new HelpTopic(topicName)); + } + + + public void setHelpTopic(HelpTopic topic) + { + assert null != getPageConfig() : "action not initialized properly"; + getPageConfig().setHelpTopic(topic); + } + + + protected Object newInstance(Class c) + { + try + { + return c == null ? null : c.getConstructor().newInstance(); + } + catch (Exception x) + { + if (x instanceof RuntimeException) + throw ((RuntimeException)x); + else + throw new RuntimeException(x); + } + } + + + protected @NotNull FORM getCommand(HttpServletRequest request) throws Exception + { + FORM command = (FORM) createCommand(); + + if (command instanceof HasViewContext) + ((HasViewContext)command).setViewContext(getViewContext()); + + return command; + } + + + protected @NotNull FORM getCommand() throws Exception + { + return getCommand(getViewContext().getRequest()); + } + + + // + // PARAMETER BINDING + // + // don't assume parameters always come from a request, use PropertyValues interface + // + + public @NotNull BindException defaultBindParameters(FORM form, PropertyValues params) + { + return defaultBindParameters(form, getCommandName(), params); + } + + + public static @NotNull BindException defaultBindParameters(Object form, String commandName, PropertyValues params) + { + /* check for do-it-myself forms */ + if (form instanceof HasBindParameters) + { + return ((HasBindParameters)form).bindParameters(params); + } + + if (form instanceof DynaBean) + { + return simpleBindParameters(form, commandName, params); + } + else + { + return springBindParameters(form, commandName, params); + } + } + + public static @NotNull BindException springBindParameters(Object command, String commandName, PropertyValues params) + { + Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); + ServletRequestDataBinder binder = new ServletRequestDataBinder(command, commandName); + + String[] fields = binder.getDisallowedFields(); + List fieldList = new ArrayList<>(fields != null ? Arrays.asList(fields) : Collections.emptyList()); + fieldList.addAll(Arrays.asList("class.*", "Class.*", "*.class.*", "*.Class.*")); + binder.setDisallowedFields(fieldList.toArray(new String[] {})); + + ConvertHelper.getPropertyEditorRegistrar().registerCustomEditors(binder); + BindingErrorProcessor defaultBEP = binder.getBindingErrorProcessor(); + binder.setBindingErrorProcessor(getBindingErrorProcessor(defaultBEP)); + binder.setFieldMarkerPrefix(SpringActionController.FIELD_MARKER); + try + { + // most paths probably called getPropertyValuesForFormBinding() already, but this is a public static method, so call it again + binder.bind(getPropertyValuesForFormBinding(params, allow)); + BindException errors = new NullSafeBindException(binder.getBindingResult()); + return errors; + } + catch (InvalidPropertyException x) + { + // Maybe we should propagate exception and return SC_BAD_REQUEST (in ExceptionUtil.handleException()) + // most POST handlers check errors.hasErrors(), but not all GET handlers do + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding property: " + x.getPropertyName()); + return errors; + } + catch (NumberFormatException x) + { + // Malformed array parameter throws this exception, unfortunately. Just reject the request. #21931 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; invalid array index (" + x.getMessage() + ")"); + return errors; + } + catch (NegativeArraySizeException x) + { + // Another malformed array parameter throws this exception. #23929 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding array property; negative array size (" + x.getMessage() + ")"); + return errors; + } + catch (IllegalArgumentException x) + { + // General bean binding problem. #23929 + BindException errors = new BindException(command, commandName); + errors.reject(SpringActionController.ERROR_MSG, "Error binding property; (" + x.getMessage() + ")"); + return errors; + } + } + + + static BindingErrorProcessor getBindingErrorProcessor(final BindingErrorProcessor defaultBEP) + { + return new BindingErrorProcessor() + { + @Override + public void processMissingFieldError(String missingField, BindingResult bindingResult) + { + defaultBEP.processMissingFieldError(missingField, bindingResult); + } + + @Override + public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) + { + Object newValue = ex.getPropertyChangeEvent().getNewValue(); + if (newValue instanceof String) + newValue = StringUtils.trimToNull((String)newValue); + + // convert NULL conversion errors to required errors + if (null == newValue) + defaultBEP.processMissingFieldError(ex.getPropertyChangeEvent().getPropertyName(), bindingResult); + else + defaultBEP.processPropertyAccessException(ex, bindingResult); + } + }; + } + + + /* + * This binder doesn't have much to offer over the standard spring data binding except that it will + * handle DynaBeans. + */ + public static @NotNull BindException simpleBindParameters(Object command, String commandName, PropertyValues params) + { + Predicate allow = command instanceof HasAllowBindParameter allowBP ? allowBP.allowBindParameter() : HasAllowBindParameter.getDefaultPredicate(); + + BindException errors = new NullSafeBindException(command, "Form"); + + // unfortunately ObjectFactory and BeanObjectFactory are not good about reporting errors + // do this by hand + for (PropertyValue pv : params.getPropertyValues()) + { + String propertyName = pv.getName(); + Object value = pv.getValue(); + if (!allow.test(propertyName)) + continue; + + try + { + Object converted = value; + Class propClass = PropertyUtils.getPropertyType(command, propertyName); + if (null == propClass) + continue; + if (value == null) + { + /* */ + } + else if (propClass.isPrimitive()) + { + converted = ConvertUtils.convert(String.valueOf(value), propClass); + } + else if (propClass.isArray()) + { + if (value instanceof Collection) + value = ((Collection) value).toArray(new String[0]); + else if (!value.getClass().isArray()) + value = new String[] {String.valueOf(value)}; + converted = ConvertUtils.convert((String[])value, propClass); + } + PropertyUtils.setProperty(command, propertyName, converted); + } + catch (ConversionException x) + { + errors.addError(new FieldError(commandName, propertyName, value, true, new String[] {"ConversionError", "typeMismatch"}, null, "Could not convert to value: " + value)); + } + catch (Exception x) + { + errors.addError(new ObjectError(commandName, new String[]{"Error"}, new Object[] {value}, x.getMessage())); + logger.error("unexpected error", x); + } + } + return errors; + } + + @Override + public boolean supports(Class clazz) + { + return getCommandClass().isAssignableFrom(clazz); + } + + public Map getTransactionAuditDetails() + { + return getTransactionAuditDetails(getViewContext()); + } + + public Map getTransactionAuditDetails(TransactionViewForm form) + { + Map transactionAuditDetails = getTransactionAuditDetails(); + if (form.getRequestSource() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.RequestSource, form.getRequestSource()); + if (form.getEditMethod() != null) + transactionAuditDetails.put(TransactionAuditProvider.TransactionDetail.EditMethod, form.getEditMethod()); + return getTransactionAuditDetails(getViewContext()); + } + + public static Map getTransactionAuditDetails(ViewContext viewContext) + { + Map map = new HashMap<>(); + map.put(TransactionAuditProvider.TransactionDetail.Action, viewContext.getActionURL().getController() + "-" + viewContext.getActionURL().getAction()); + String clientLibrary = HttpUtil.getClientLibrary(viewContext.getRequest()); + if (null != clientLibrary) + map.put(TransactionAuditProvider.TransactionDetail.ClientLibrary, clientLibrary); + else + { + String productName = HttpUtil.getProductNameFromReferer(viewContext.getRequest()); // app + if (null != productName) + map.put(TransactionAuditProvider.TransactionDetail.Product, productName); + else // LKS + { + String refererRelativeURL = HttpUtil.getRefererRelativeURL(viewContext.getRequest()); + map.put(TransactionAuditProvider.TransactionDetail.RequestSource, refererRelativeURL); + } + } + return map; + } + + /* for TableViewForm, uses BeanUtils to work with DynaBeans */ + static public class BeanUtilsPropertyBindingResult extends BeanPropertyBindingResult + { + public BeanUtilsPropertyBindingResult(Object target, String objectName) + { + super(target, objectName); + } + + @Override + protected BeanWrapper createBeanWrapper() + { + return new BeanUtilsWrapperImpl((DynaBean)getTarget()); + } + } + + static public class BeanUtilsWrapperImpl extends AbstractPropertyAccessor implements BeanWrapper + { + private Object object; + private boolean autoGrowNestedPaths = false; + private int autoGrowCollectionLimit = 0; + + public BeanUtilsWrapperImpl() + { + // registerDefaultEditors(); + } + + public BeanUtilsWrapperImpl(DynaBean target) + { + this(); + object = target; + } + + @Override + public Object getPropertyValue(String propertyName) throws BeansException + { + try + { + return PropertyUtils.getProperty(object, propertyName); + } + catch (Exception e) + { + throw new NotReadablePropertyException(object.getClass(), propertyName); + } + } + + @Override + public void setPropertyValue(String propertyName, Object value) throws BeansException + { + try + { + PropertyUtils.setProperty(object, propertyName, value); + } + catch (Exception e) + { + throw new NotWritablePropertyException(object.getClass(), propertyName); + } + } + + @Override + public boolean isReadableProperty(String propertyName) + { + return true; + } + + @Override + public boolean isWritableProperty(String propertyName) + { + return true; + } + + @Override + public TypeDescriptor getPropertyTypeDescriptor(String s) throws BeansException + { + return null; + } + + public void setWrappedInstance(Object obj) + { + object = obj; + } + + @Override + public Object getWrappedInstance() + { + return object; + } + + @Override + public Class getWrappedClass() + { + return object.getClass(); + } + + @Override + public PropertyDescriptor[] getPropertyDescriptors() + { + throw new UnsupportedOperationException(); + } + + @Override + public PropertyDescriptor getPropertyDescriptor(String propertyName) throws BeansException + { + throw new UnsupportedOperationException(); + } + + @Override + public void setAutoGrowNestedPaths(boolean b) + { + this.autoGrowNestedPaths = b; + } + + @Override + public boolean isAutoGrowNestedPaths() + { + return this.autoGrowNestedPaths; + } + + @Override + public void setAutoGrowCollectionLimit(int i) + { + this.autoGrowCollectionLimit = i; + } + + @Override + public int getAutoGrowCollectionLimit() + { + return this.autoGrowCollectionLimit; + } + + @Override + public T convertIfNecessary(Object value, Class requiredType) throws TypeMismatchException + { + if (value == null) + return null; + return (T)ConvertUtils.convert(String.valueOf(value), requiredType); + } + + @Override + public T convertIfNecessary(Object value, Class requiredType, MethodParameter methodParam) throws TypeMismatchException + { + return convertIfNecessary(value, requiredType); + } + } + + /** + * @return a map from form element name to uploaded files + */ + protected Map getFileMap() + { + if (getViewContext().getRequest() instanceof MultipartHttpServletRequest) + return ((MultipartHttpServletRequest)getViewContext().getRequest()).getFileMap(); + return Collections.emptyMap(); + } + + protected List getAttachmentFileList() + { + return SpringAttachmentFile.createList(getFileMap()); + } + + public boolean isRobot() + { + return _robot; + } + + public boolean isPrint() + { + return _print; + } + + public boolean isDebug() + { + return _debug; + } + + public @NotNull Class getCommandClass() + { + if (null == _commandClass) + throw new IllegalStateException("NULL _commandClass in " + getClass().getName()); + return _commandClass; + } + + public void setCommandClass(@NotNull Class commandClass) + { + _commandClass = commandClass; + } + + protected final @NotNull Object createCommand() + { + return BeanUtils.instantiateClass(getCommandClass()); + } + + public void setCommandName(String commandName) + { + _commandName = commandName; + } + + public String getCommandName() + { + return _commandName; + } + + /** + * Cacheable resources can calculate a last modified timestamp to send to the browser. + */ + protected long getLastModified(FORM form) + { + return Long.MIN_VALUE; + } + + /** + * Cacheable resources can calculate an ETag header to send to the browser. + */ + protected String getETag(FORM form) + { + return null; + } +}