Any ideas how I would hide or show a dialog tab panel depending on which user group the user belongs to?
I tried to do this through the CRX content explorers (ACL's). But I'm not getting much luck with it.
Cheers
As noted by anthonyh, the ACL approach is the way to go (if such a behavior is really necessary).
For example, to hide the "image" tab of the base page component:
edit acls for /libs/foundation/components/page/dialog/items/tabs/items/image
add deny jcr:read for author
login as author
go to http://localhost:4502/content/geometrixx/en.html and open the page properties
image tab should be gone
Note that in case of tabs included with xtype=cqinclude you have to set it on the include itself, not the included definition. Because at runtime it would complain over the missing target of the include and not render the dialog at all.
This can be accomplished with a custom servlet and a dialog event listener.
The listener function makes a request to the servlet, passing the current user ID and the desired group. The dialog tab can then be hidden based on the servlet response.
Here is an example dialog.xml for a CQ5 component:
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:Dialog"
xtype="dialog">
<listeners jcr:primaryType="nt:unstructured"
loadcontent="function(dialog) {
var url = '/bin/member.json';
// check if current user belongs to administrators group
url = CQ.HTTP.addParameter(url, 'userId', CQ.User.getUserID());
url = CQ.HTTP.addParameter(url, 'groupId', 'administrators');
var result = CQ.HTTP.eval(url);
if (!result.isMember) {
// hide "tab2" if user is not an administrator
dialog.findByType('tabpanel')[0].hideTabStripItem(1);
}
}" />
<items jcr:primaryType="cq:WidgetCollection">
<tab1 jcr:primaryType="cq:Widget" title="Text" xtype="panel">
<items jcr:primaryType="cq:WidgetCollection">
...
</items>
</tab1>
<tab2 jcr:primaryType="cq:Widget" title="Image" xtype="panel">
<items jcr:primaryType="cq:WidgetCollection">
...
</items>
</tab2>
</items>
</jcr:root>
And here is the corresponding servlet:
import java.io.IOException;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Properties;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Service;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.codehaus.jackson.JsonFactory;
import org.codehaus.jackson.JsonGenerationException;
import org.codehaus.jackson.JsonGenerator;
import org.codehaus.jackson.JsonGenerator.Feature;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.day.cq.security.Group;
import com.day.cq.security.User;
import com.day.cq.security.UserManager;
import com.google.common.collect.ImmutableMap;
#Component(immediate = true)
#Service
#Properties({
#Property(name = "service.description", value = "Group Member servlet checks if a user is a member of a group."),
#Property(name = "sling.servlet.paths", value = "/bin/member")
})
public class GroupMemberServlet extends SlingAllMethodsServlet {
/** Required. */
private static final long serialVersionUID = 1L;
/** Logger */
private static final Logger LOG = LoggerFactory.getLogger(GroupMemberServlet.class);
private static final JsonFactory FACTORY = new JsonFactory().disable(Feature.AUTO_CLOSE_TARGET);
private static final ObjectMapper MAPPER = new ObjectMapper();
#Override
protected void doGet(final SlingHttpServletRequest request, final SlingHttpServletResponse response) {
final UserManager userManager = request.getResourceResolver().adaptTo(UserManager.class);
final String userId = request.getRequestParameter("userId").getString();
final String groupId = request.getRequestParameter("groupId").getString();
final Group group = (Group) userManager.get(groupId);
final User user = (User) userManager.get(userId);
writeJsonResponse(response, ImmutableMap.of("isMember", group.isMember(user)));
}
private void writeJsonResponse(final SlingHttpServletResponse response, final Object object) {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
try {
final JsonGenerator generator = FACTORY.createJsonGenerator(response.getWriter());
MAPPER.writeValue(generator, object);
} catch (final JsonGenerationException jge) {
LOG.error("error generating JSON response", jge);
} catch (final JsonMappingException jme) {
LOG.error("error mapping JSON response", jme);
} catch (final IOException ioe) {
LOG.error("error writing JSON response", ioe);
}
}
}
One question spring to mind...Why do you want to limit control of an authoring dialog and remove a tab?
There's no reason why ACLs wouldn't work for this. Did you set them to be restrictive enough for the tab? Were you testing with a non-admin user? I would be cautious using something so code-heavy to solve an access issue.
Personally if ACLs don't work as well as desired I'd explore creating an new widget based on the tabpanel xtype rather than a code solution which may end up as being specific to one version of CQ5.
My Answer: Use ACLs.
Please have a look at these vaguely related official documents - same principle but different objective:
http://dev.day.com/content/kb/home/cq5/CQ5SystemAdministration/CQ53HowToHideCQNavigationButtons.html
and
http://dev.day.com/docs/en/cq/current/administering/security.html
Related
we are very new to Infinispan and also quite new to Apache Karaf. Installing Infinispan in Karaf was easy, we did write two OSGi Bundles to form a cluster with two nodes that run on one host. We tried it with the tutorial for a distributed cache from the Infinispan website (tutorial). Unfortunately the cluster seems not to be build and we can't determine why. Any help or push in the right direction would be very appreciated.
The code of the bundle that writes something in the cache looks like that:
import org.infinispan.Cache;
import org.infinispan.configuration.cache.CacheMode;
import org.infinispan.configuration.cache.ConfigurationBuilder;
import org.infinispan.configuration.global.GlobalConfigurationBuilder;
import org.infinispan.manager.DefaultCacheManager;
import org.infinispan.context.Flag;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CacheProducer implements BundleActivator{
private static Logger LOG = LoggerFactory.getLogger(CacheProducer.class );
private static DefaultCacheManager cacheManager;
#Override
public void start( BundleContext context ) throws Exception{
LOG.info( "Start Producer" );
GlobalConfigurationBuilder global = GlobalConfigurationBuilder.defaultClusteredBuilder();
global.transport().clusterName("ClusterTest");
// Make the default cache a distributed synchronous one
ConfigurationBuilder builder = new ConfigurationBuilder();
builder.clustering().cacheMode(CacheMode.DIST_SYNC);
// Initialize the cache manager
cacheManager = new DefaultCacheManager(global.build(), builder.build());
// Obtain the default cache
Cache<String, String> cache = cacheManager.getCache();
cache.put( "message", "Hello World!" );
LOG.info( "Producer: whole cluster content!" );
cache.entrySet().forEach(entry -> LOG.info(entry.getKey()+ ": " + entry.getValue()));
LOG.info( "Producer: current cache content!" );
cache.getAdvancedCache().withFlags(Flag.SKIP_REMOTE_LOOKUP)
.entrySet().forEach(entry -> LOG.info(entry.getKey()+ ": " + entry.getValue()));
}
#Override
public void stop( BundleContext context ) throws Exception{
cacheManager.stop();
}
}
And the one that tries to print out what is in the cache like that:
package metdoc81.listener;
import org.infinispan.configuration.cache.CacheMode;
import org.infinispan.configuration.cache.ConfigurationBuilder;
import org.infinispan.configuration.global.GlobalConfigurationBuilder;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.infinispan.Cache;
import org.infinispan.manager.DefaultCacheManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Activator implements BundleActivator{
private static Logger LOG = LoggerFactory.getLogger(Activator.class);
private static DefaultCacheManager cacheManager;
public void start( BundleContext bundleContext ) throws Exception{
LOG.info("start cluster listener");
GlobalConfigurationBuilder global = GlobalConfigurationBuilder.defaultClusteredBuilder();
global.transport().clusterName("ClusterTest");
// Make the default cache a distributed synchronous one
ConfigurationBuilder builder = new ConfigurationBuilder();
builder.clustering().cacheMode(CacheMode.DIST_SYNC);
// Initialize the cache manager
cacheManager = new DefaultCacheManager(global.build(), builder.build());
// Obtain the default cache
Cache<String, String> cache = cacheManager.getCache();
LOG.info("After configuration");
cache.entrySet().forEach(entry -> LOG.info(entry.getKey()+ ": " + entry.getValue()));
LOG.info("After logging");
}
public void stop( BundleContext bundleContext ) throws Exception{
}
}
The printing from the CacheProducer works, printing from the Listener does not.
We found the solution ourselves.
The problem just occurs when you try to run the code on MacOS, on Windows it's working. According to a discussion at JBossDeveloper there was a problem with the multicast routing on MacOS. Even though they added a workaround into the example code, you still have to add the -Djava.net.preferIPv4Stack=true Flag when running it or you have to add these two lines of code:
Properties properties = System.getProperties();
properties.setProperty( "java.net.preferIPv4Stack", "true" );
I am trying to create a custom folder in Create menu of Document Library called 'Confidential Folder' in Alfresco. I've attempted the following procedure,
I have used the Alfresco Maven SDK to create a project that will package up my customizations in two AMPs (Alfresco Module Packages). One AMP is for the Alfresco web application (the "repo" tier) and the other is for the Alfresco Share web application (the "Share" tier).
I have added the following code in share-config-custom.xml:
XML:
<config evaluator="string-compare" condition="DocumentLibrary">
<create-content>
<content id="confidentialFolder" mimetype="text/plain"
label="Confidential Folder" itemid="cm:folder" icon="finalize">
<param name="action">confidential-folder</param>
</content>
</create-content>
</config>
In service-context.xml I registered the bean:
XML:
<bean id="confidential-folder" class="com.finalize.action.executer.ConfidentialFolder"
parent="action-executer">
<property name="nodeService">
<ref bean="NodeService" />
</property>
</bean>
And wrote the Java class for execute the action which will Off the Inheritance Permission.
Action Executer:
package com.finalize.action.executer;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.alfresco.repo.action.ParameterDefinitionImpl;
import org.alfresco.repo.action.executer.ActionExecuterAbstractBase;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ParameterDefinition;
import org.alfresco.service.cmr.dictionary.DataTypeDefinition;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.repo.security.permissions;
import org.alfresco.repo.policy.ClassPolicy;
//import org.alfresco.repo.events.EventsService;
public class ConfidentialFolder extends ActionExecuterAbstractBase {
public static final QName DISABLE = QName.createQName(NamespaceService.ALFRESCO_URI, "onInheritPermissionsDisabled");
// protected EventsService eventsService;
protected NodeService nodeService;
/* public void setEventsService(EventsService eventsService)
{
this.eventsService = eventsService;
}*/
public void setNodeService(NodeService nodeService)
{
this.nodeService = nodeService;
}
/*public void onInheritPermissionsDisabled(NodeRef nodeRef, boolean async)
{
inheritPermissionsDisabled(nodeRef, async);
}*/
#Override
protected void executeImpl(Action action, NodeRef actionedUponNodeRef) {
onInheritPermissionsDisabled(actionedUponNodeRef,false);
// private static final QName POLICY_ON_INHERIT_PERMISSIONS_DISABLED = QName.createQName(NamespaceService.ALFRESCO_URI, "onInheritPermissionsDisabled");
// nodeService.addAspect(actionedUponNodeRef, QName.createQName(FinalizeModel.NAMESPACE_FINALIZE_CONTENT_MODEL, FinalizeModel.ASPECT_FIN_WEBABLE), properties);
}
#Override
protected void addParameterDefinitions(List<ParameterDefinition> paramList) {
paramList.add(
new ParameterDefinitionImpl( // Create a new parameter definition to add to the list
"active", // The name used to identify the parameter
DataTypeDefinition.BOOLEAN, // The parameter value type
false, // Indicates whether the parameter is mandatory
getParamDisplayLabel("active"))); // The parameters display label
}
}
I wanted to Off or Disable the Inheritance Permission from Confidential folder menu but I am not able to achieve.
This is cross-posted on the Alfresco forums here: https://community.alfresco.com/thread/238511-off-disable-inheritance-permission-from-custom-folder and is being discussed, so no need to duplicate it here.
I have an issue with #RepositoryRestResource returning a 500 error due to a null pointer.
I have an abstract class named Resource which is uses InheritanceType.JOINED and #JsonSubTypes to provide about 5 subclasses of Resource such as ServerResource. The specific Resource is returned fine when referenced in another entity that it joins with. The PagingAndSortingRepository also works fine when called in code. The problem is the endpoint exposed by #RepositoryRestResource for the Resource entity returns a 500 error (due to NPE) when called directly.
After debugging, I found the issue is due to the line PersistentEntityResourceAssembler:154. The mapping object is null because the framework could not map ServerResource.class to the parent Resource.class. The cache is a map, and uses .getClass().
Are there any ways I can get one endpoint for the Resource entity working for these separate types? This almost seems like a bug.
To correct this issue i overrode the resourceMappings() method, and injected the mappings I needed. This fixed the issue, and now the /resources uri is working as intended.
package com.environment.config;
import java.lang.reflect.Field;
import java.util.Map;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.type.filter.AssignableTypeFilter;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.RepositoryResourceMappings;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.core.mapping.ResourceMetadata;
import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration;
import com.environment.domain.Resource;
import com.environment.util.Tuple;
#Configuration
public class RepositoryConfig extends RepositoryRestMvcConfiguration {
#Override
public ResourceMappings resourceMappings() {
RepositoryResourceMappings rm = RepositoryResourceMappings)super.resourceMappings();
try {
Field cacheField = rm.getClass().getDeclaredField("cache");
cacheField.setAccessible(true);
Map<Class<?>, ResourceMetadata> cache = (Map<Class<?>, ResourceMetadata>) cacheField.get(rm);
//Get metadata for parent object
ResourceMetadata resourceMetadata = cache.entrySet().stream()
.map(Tuple<Class<?>,ResourceMetadata>::new)
.filter(t -> Resource.class.equals(t.t))
.map(t -> t.u)
.findFirst()
.get();
ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
provider.addIncludeFilter(new AssignableTypeFilter(Resource.class));
provider.findCandidateComponents("com/environment")
.stream()
.map(beanDef -> {
try {
return Class.forName(beanDef.getBeanClassName());
} catch (Exception e) {
e.printStackTrace();
}
return null;
})
.filter(c -> !Resource.class.equals(c))
.filter(c -> Resource.class.isAssignableFrom(c))
.peek(c -> System.out.println("Mapping: " + c)
.forEach(c -> cache.put((Class<?>) c, resourceMetadata));
} catch (Exception e) {
e.printStackTrace();
}
return rm;
}
}
I have a simple web socket application that uses stomp. when a user visits a page it will automatically make a stomp connection to the server. The user is authenticated via spring security. When the user closes the browser i want the user to automatically logout. To do this I create a listener to listen for SessionDisconnectEvent. The problem is I don't have a handle to the httpSession associated with the websocket session? Is there a want to get the httpsession from the websocket session?
here's my code:
<websocket:message-broker application-destination-prefix="/test">
<websocket:stomp-endpoint path="/sbapp">
<websocket:handshake-interceptors>
<bean class="com.sample.HttpSessionIdHandshakeInterceptor"></bean>
</websocket:handshake-interceptors>
<websocket:sockjs />
</websocket:stomp-endpoint>
<websocket:stomp-broker-relay prefix="/topic,/queue,/user" relay-host="localhost" relay-port="61613"/>
</websocket:message-broker>
here's my websocket session listener:
#Component
public class StompDisconnectListener implements ApplicationListener<SessionDisconnectEvent>{
#Override
public void onApplicationEvent(SessionDisconnectEvent event) {
System.out.println("Stomp disconnect: " + event.getSessionId());
}
}
I need a way such that when i get get a disconnect event I get the corresponding HttpSession then manually logout the HttpSession. Is this possible?
Now Spring Session supports WebSockets. Follow this guide to add it to your project. Then, in your SessionDisconnectEvent listener, you can get your HttpSessionID this way:
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
public class WebSocketDisconnectHandler<S>
implements ApplicationListener<SessionDisconnectEvent> {
public void onApplicationEvent(SessionDisconnectEvent event) {
String session = SimpMessageHeaderAccessor.getSessionAttributes(event.getMessage().getHeaders()).get("HTTP.SESSION.ID").toString();
}
}
(Other headers values you can access:)
headers {simpMessageType=CONNECT, stompCommand=CONNECT, nativeHeaders={X-XSRF-TOKEN=[cb73273e-bff3-4eb7-965d-4c696e22c25a], accept-version=[1.1,1.0], heart-beat=[10000,10000]}, simpSessionAttributes={HTTP.SESSION.ID=6dd63204-d5ec-4362-8f37-29af5605298d, org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN=org.springframework.security.web.csrf.DefaultCsrfToken#10b64145, org.springframework.security.web.csrf.CsrfToken=org.springframework.security.web.csrf.DefaultCsrfToken#10b64145}, simpHeartbeat=[J#3955ead4, simpSessionId=3x25c1e5}
Be sure to register this as a bean in a WebSocket config file that extends ExpiringSession:
import your.package.WebSocketDisconnectHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.ExpiringSession;
#Configuration
public class WebSocketHandlersConfig<S extends ExpiringSession> {
#Bean
public WebSocketDisconnectHandler<S> webSocketDisconnectHandler() {
return new WebSocketDisconnectHandler<S>();
}
}
I think you need a interceptor, you will get empty sessionAttributes without it.
You need to add HttpSessionHandshakeInterceptor in the dispatcher-servlet.xml:
<websocket:message-broker
application-destination-prefix="/test">
<websocket:stomp-endpoint path="/sbapp">
<websocket:handshake-interceptors>
<bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor"/>
</websocket:handshake-interceptors>
<websocket:sockjs session-cookie-needed="true" />
</websocket:stomp-endpoint>
<websocket:simple-broker prefix="/topic, /message" />
</websocket:message-broker>
And then you will be able to get session in the controller:
#MessageMapping("/authorization.action")
public Message authorizationAction(
SimpMessageHeaderAccessor headerAccessor, Message message) {
String sessionId = headerAccessor.getSessionId();
Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
System.out.println(sessionId);
System.out.println(sessionAttributes);
// Do something with session
return new Message(...);
}
This worked for me.
Take a look at the below post.
https://spring.io/blog/2014/09/16/preview-spring-security-websocket-support-sessions
I am trying to bind a text field (a comma-separated list of Tag names, Tag being my domain object) to a variable which is a List of Tag objects, in another domain object called Expense. So, a user enters a list of tags for an expense item in the form and this gets bound to a Collection of Tag items in the Expense domain object. So far, what I have done in my code is this:
my jsp file has:
<tr>
<td>Tags</td>
<td><form:input path="tags" type="text" name="tags" id="tags_formfield" /></td>
</tr>
my domain object has:
//bi-directional many-to-many association to Tag
#ManyToMany()
#JoinTable(name="EXPENSES_X_TAGS",
joinColumns= #JoinColumn(name="EXPENSE_ID", referencedColumnName="ID"),
inverseJoinColumns= #JoinColumn(name="TAG_ID", referencedColumnName="ID"))
private List<Tag> tags;
my controller has:
#InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(List.class, new TagsEditor());
}
I have also tried:
#InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(Tag.class, new TagsEditor());
}
And
my property editor is:
package com.transience.sandbox.converters;
import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import com.transience.sandbox.domain.Tag;
import com.transience.sandbox.services.ITagService;
public class TagsEditor extends PropertyEditorSupport {
#Autowired
ITagService tagService;
protected final Log logger = LogFactory.getLog(getClass());
#Override
// Converts a comma separated String of tagNames to a List<Tag> (when submitting form)
public void setAsText(String stringOfTagNames) {
List<Tag> tags = null;
String[] tagNames = stringOfTagNames.split(",");
logger.info("Looping through tagNames now...");
for(String tagName : tagNames) {
logger.info("tag name: " + tagName);
Tag tag = tagService.findByTagName(tagName);
tags.add(tag);
}
logger.info("Trying to setValue now...");
this.setValue(tags);
logger.info("Value of List of Tags set successfully...");
}
}
But I am getting the following exception in form submission:
Error 500--Internal Server Error
org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'expense' on field 'tags': rejected value [booze,lunch]; codes [methodInvocation.expense.tags,methodInvocation.tags,methodInvocation.java.util.List,methodInvocation]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [expense.tags,tags]; arguments []; default message [tags]]; default message [Property 'tags' threw exception; nested exception is java.lang.NullPointerException]
at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:110)
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:74)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:155)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:117)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:96)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:617)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:578)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:80)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:900)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:827)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:882)
I am pretty sure I am doing something fundamentally incorrect, but what am I doing wrong? I am aware of the Converters approach, but I am curious to know how this can be done using PropertyEditors
Thanks,
Sanjay
I solved it by adding valueOf(String) to the domain object (also toString is important for backward conversion).
Somehow Spring is already able to convert csv to List.
Found answer here: How to bind a comma separated string to a Java collection (List) in Spring form?