2018-11-30 17:49:35 +01:00
|
|
|
package org.gcube.data.access.storagehub.services;
|
|
|
|
|
|
|
|
import java.util.ArrayList;
|
2018-12-17 14:55:43 +01:00
|
|
|
import java.util.Collections;
|
2021-03-15 11:57:46 +01:00
|
|
|
import java.util.HashSet;
|
2018-11-30 17:49:35 +01:00
|
|
|
import java.util.Iterator;
|
|
|
|
import java.util.List;
|
2021-01-11 18:07:57 +01:00
|
|
|
import java.util.Set;
|
2021-03-12 10:24:45 +01:00
|
|
|
import java.util.function.Predicate;
|
2021-03-16 00:04:54 +01:00
|
|
|
import java.util.stream.Collectors;
|
2018-11-30 17:49:35 +01:00
|
|
|
|
|
|
|
import javax.inject.Inject;
|
|
|
|
import javax.jcr.Node;
|
2020-01-22 16:41:12 +01:00
|
|
|
import javax.jcr.PathNotFoundException;
|
|
|
|
import javax.jcr.RepositoryException;
|
2018-11-30 17:49:35 +01:00
|
|
|
import javax.servlet.ServletContext;
|
2019-04-11 18:30:43 +02:00
|
|
|
import javax.ws.rs.Consumes;
|
2018-11-30 17:49:35 +01:00
|
|
|
import javax.ws.rs.DELETE;
|
2019-04-11 18:30:43 +02:00
|
|
|
import javax.ws.rs.FormParam;
|
2018-11-30 17:49:35 +01:00
|
|
|
import javax.ws.rs.GET;
|
|
|
|
import javax.ws.rs.POST;
|
|
|
|
import javax.ws.rs.Path;
|
|
|
|
import javax.ws.rs.PathParam;
|
|
|
|
import javax.ws.rs.Produces;
|
|
|
|
import javax.ws.rs.core.Context;
|
|
|
|
import javax.ws.rs.core.MediaType;
|
2020-01-22 16:41:12 +01:00
|
|
|
import javax.ws.rs.core.Response;
|
2018-11-30 17:49:35 +01:00
|
|
|
|
|
|
|
import org.apache.jackrabbit.api.JackrabbitSession;
|
|
|
|
import org.apache.jackrabbit.api.security.user.Authorizable;
|
2021-03-16 00:04:54 +01:00
|
|
|
import org.apache.jackrabbit.api.security.user.Group;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.apache.jackrabbit.api.security.user.Query;
|
|
|
|
import org.apache.jackrabbit.api.security.user.QueryBuilder;
|
|
|
|
import org.apache.jackrabbit.api.security.user.User;
|
2018-12-17 14:55:43 +01:00
|
|
|
import org.apache.jackrabbit.core.security.principal.PrincipalImpl;
|
2019-10-09 11:52:48 +02:00
|
|
|
import org.gcube.common.authorization.control.annotations.AuthorizationControl;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.gcube.common.gxrest.response.outbound.GXOutboundErrorResponse;
|
2020-03-05 15:39:34 +01:00
|
|
|
import org.gcube.common.storagehub.model.Excludes;
|
2021-03-16 00:04:54 +01:00
|
|
|
import org.gcube.common.storagehub.model.Paths;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.gcube.common.storagehub.model.exceptions.BackendGenericError;
|
2021-02-08 12:30:58 +01:00
|
|
|
import org.gcube.common.storagehub.model.exceptions.IdNotFoundException;
|
2020-01-22 16:41:12 +01:00
|
|
|
import org.gcube.common.storagehub.model.exceptions.StorageHubException;
|
2021-03-12 10:24:45 +01:00
|
|
|
import org.gcube.common.storagehub.model.exceptions.UserNotAuthorizedException;
|
2020-03-05 15:39:34 +01:00
|
|
|
import org.gcube.common.storagehub.model.items.Item;
|
2021-03-12 10:24:45 +01:00
|
|
|
import org.gcube.common.storagehub.model.items.SharedFolder;
|
|
|
|
import org.gcube.data.access.storagehub.AuthorizationChecker;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.gcube.data.access.storagehub.Constants;
|
2020-03-16 16:55:26 +01:00
|
|
|
import org.gcube.data.access.storagehub.StorageHubAppllicationManager;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.gcube.data.access.storagehub.Utils;
|
2019-10-09 11:52:48 +02:00
|
|
|
import org.gcube.data.access.storagehub.exception.MyAuthException;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.gcube.data.access.storagehub.handlers.CredentialHandler;
|
2021-03-16 00:04:54 +01:00
|
|
|
import org.gcube.data.access.storagehub.handlers.GroupHandler;
|
2020-03-05 15:39:34 +01:00
|
|
|
import org.gcube.data.access.storagehub.handlers.TrashHandler;
|
2018-12-17 14:55:43 +01:00
|
|
|
import org.gcube.data.access.storagehub.handlers.UnshareHandler;
|
2020-03-16 16:55:26 +01:00
|
|
|
import org.gcube.smartgears.annotations.ManagedBy;
|
2020-01-22 16:41:12 +01:00
|
|
|
import org.gcube.smartgears.utils.InnerMethodName;
|
2018-11-30 17:49:35 +01:00
|
|
|
import org.slf4j.Logger;
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
|
|
|
@Path("users")
|
2020-03-16 16:55:26 +01:00
|
|
|
@ManagedBy(StorageHubAppllicationManager.class)
|
2018-11-30 17:49:35 +01:00
|
|
|
public class UserManager {
|
|
|
|
|
2020-01-22 16:41:12 +01:00
|
|
|
private static final String INFRASTRUCTURE_MANAGER_ROLE = "Infrastructure-Manager";
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
@Context ServletContext context;
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
private static final Logger log = LoggerFactory.getLogger(UserManager.class);
|
|
|
|
|
2020-03-16 16:55:26 +01:00
|
|
|
RepositoryInitializer repository = StorageHubAppllicationManager.repository;
|
2018-11-30 17:49:35 +01:00
|
|
|
|
2018-12-17 14:55:43 +01:00
|
|
|
@Inject
|
|
|
|
UnshareHandler unshareHandler;
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2020-03-05 15:39:34 +01:00
|
|
|
@Inject
|
|
|
|
TrashHandler trashHandler;
|
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
@Inject
|
|
|
|
GroupHandler groupHandler;
|
|
|
|
|
2021-03-12 10:24:45 +01:00
|
|
|
@Inject
|
|
|
|
AuthorizationChecker authChecker;
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
@GET
|
|
|
|
@Path("")
|
|
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
|
|
public List<String> getUsers(){
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2020-01-22 16:41:12 +01:00
|
|
|
InnerMethodName.instance.set("getUsers");
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
JackrabbitSession session = null;
|
2021-03-15 11:57:46 +01:00
|
|
|
List<String> users = null;
|
2018-11-30 17:49:35 +01:00
|
|
|
try {
|
|
|
|
session = (JackrabbitSession) repository.getRepository().login(CredentialHandler.getAdminCredentials(context));
|
|
|
|
|
2021-03-15 11:57:46 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
Iterator<Authorizable> result = session.getUserManager().findAuthorizables(new Query() {
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public <T> void build(QueryBuilder<T> builder) {
|
|
|
|
builder.setSelector(User.class);
|
|
|
|
}
|
|
|
|
});
|
2021-03-15 11:57:46 +01:00
|
|
|
|
|
|
|
Set<String> usersSet= new HashSet<>();
|
|
|
|
String adminUser = context.getInitParameter(Constants.ADMIN_PARAM_NAME);
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
while (result.hasNext()) {
|
|
|
|
Authorizable user = result.next();
|
2018-12-17 14:55:43 +01:00
|
|
|
log.debug("user {} found",user.getPrincipal().getName());
|
2021-03-15 11:57:46 +01:00
|
|
|
if (user.getPrincipal().getName().equals(adminUser)) continue;
|
|
|
|
usersSet.add(user.getPrincipal().getName());
|
2018-11-30 17:49:35 +01:00
|
|
|
}
|
2021-03-15 11:57:46 +01:00
|
|
|
|
|
|
|
users = new ArrayList<>(usersSet);
|
|
|
|
Collections.sort(users);
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
}catch(Exception e) {
|
|
|
|
log.error("jcr error getting users", e);
|
|
|
|
GXOutboundErrorResponse.throwException(new BackendGenericError(e));
|
|
|
|
} finally {
|
|
|
|
if (session!=null)
|
|
|
|
session.logout();
|
|
|
|
}
|
|
|
|
return users;
|
|
|
|
}
|
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
@GET
|
|
|
|
@Path("{user}")
|
|
|
|
public String getUser(@PathParam("user") String user){
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
InnerMethodName.instance.set("getUser");
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
JackrabbitSession session = null;
|
|
|
|
try {
|
|
|
|
session = (JackrabbitSession) repository.getRepository().login(CredentialHandler.getAdminCredentials(context));
|
|
|
|
|
|
|
|
org.apache.jackrabbit.api.security.user.UserManager usrManager = session.getUserManager();
|
|
|
|
Authorizable authorizable = usrManager.getAuthorizable(user);
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
if (authorizable != null && !authorizable.isGroup())
|
|
|
|
return authorizable.getPrincipal().getName();
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
log.debug("user {} not found", user);
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
}catch(Exception e) {
|
|
|
|
log.error("jcr error getting user", e);
|
|
|
|
GXOutboundErrorResponse.throwException(new BackendGenericError(e));
|
|
|
|
} finally {
|
|
|
|
if (session!=null)
|
|
|
|
session.logout();
|
|
|
|
}
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
GXOutboundErrorResponse.throwException(new IdNotFoundException(user));
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2021-02-08 12:30:58 +01:00
|
|
|
return null;
|
|
|
|
}
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
@POST
|
|
|
|
@Path("")
|
2019-04-11 18:30:43 +02:00
|
|
|
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
2020-01-22 16:41:12 +01:00
|
|
|
@AuthorizationControl(allowedRoles={INFRASTRUCTURE_MANAGER_ROLE}, exception=MyAuthException.class)
|
2019-04-11 18:30:43 +02:00
|
|
|
public String createUser(@FormParam("user") String user, @FormParam("password") String password){
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2020-01-22 16:41:12 +01:00
|
|
|
InnerMethodName.instance.set("createUser");
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
JackrabbitSession session = null;
|
|
|
|
String userId = null;
|
|
|
|
try {
|
|
|
|
session = (JackrabbitSession) repository.getRepository().login(CredentialHandler.getAdminCredentials(context));
|
2021-03-12 10:24:45 +01:00
|
|
|
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
org.apache.jackrabbit.api.security.user.UserManager usrManager = session.getUserManager();
|
|
|
|
|
|
|
|
User createdUser = usrManager.createUser(user, password);
|
2021-03-15 11:57:46 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
userId = createdUser.getID();
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
Node homeNode = session.getNode("/Home");
|
|
|
|
Node userHome = homeNode.addNode(user, "nthl:home");
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2018-12-17 14:55:43 +01:00
|
|
|
//creating workspace folder
|
2018-11-30 17:49:35 +01:00
|
|
|
Node workspaceFolder = Utils.createFolderInternally(session, userHome, Constants.WORKSPACE_ROOT_FOLDER_NAME, "workspace of "+user, false, user, null);
|
2018-12-17 14:55:43 +01:00
|
|
|
//creating thrash folder
|
|
|
|
Utils.createFolderInternally(session, workspaceFolder, Constants.TRASH_ROOT_FOLDER_NAME, "trash of "+user, false, user, null);
|
|
|
|
//creating Vre container folder
|
|
|
|
Utils.createFolderInternally(session, workspaceFolder, Constants.VRE_FOLDER_PARENT_NAME, "special folder container of "+user, false, user, null);
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
session.save();
|
2020-01-22 16:41:12 +01:00
|
|
|
}catch(StorageHubException she ){
|
|
|
|
log.error(she.getErrorMessage(), she);
|
|
|
|
GXOutboundErrorResponse.throwException(she, Response.Status.fromStatusCode(she.getStatus()));
|
|
|
|
}catch(RepositoryException re ){
|
|
|
|
log.error("jcr error creating item", re);
|
|
|
|
GXOutboundErrorResponse.throwException(new BackendGenericError("jcr error creating item", re));
|
2018-11-30 17:49:35 +01:00
|
|
|
} finally {
|
|
|
|
if (session!=null)
|
|
|
|
session.logout();
|
|
|
|
}
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
return userId;
|
|
|
|
}
|
2019-03-26 17:09:26 +01:00
|
|
|
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
@DELETE
|
2020-01-22 16:41:12 +01:00
|
|
|
@Path("{user}")
|
|
|
|
@AuthorizationControl(allowedRoles={INFRASTRUCTURE_MANAGER_ROLE}, exception=MyAuthException.class)
|
2021-03-12 10:24:45 +01:00
|
|
|
public String deleteUser(@PathParam("user") final String user){
|
|
|
|
|
2020-01-22 16:41:12 +01:00
|
|
|
InnerMethodName.instance.set("deleteUser");
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
JackrabbitSession session = null;
|
|
|
|
try {
|
2021-03-12 10:24:45 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
session = (JackrabbitSession) repository.getRepository().login(CredentialHandler.getAdminCredentials(context));
|
|
|
|
|
|
|
|
org.apache.jackrabbit.api.security.user.UserManager usrManager = session.getUserManager();
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
User authorizable = (User) usrManager.getAuthorizable(new PrincipalImpl(user));
|
|
|
|
|
|
|
|
if (authorizable!=null)
|
|
|
|
removeUserFromBelongingGroup(session, authorizable, usrManager);
|
|
|
|
else log.warn("user was already deleted from jackrabbit, trying to delete folders");
|
|
|
|
|
|
|
|
unshareUsersFolders(session, user);
|
2018-11-30 17:49:35 +01:00
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
removeUserHomeAndDeleteFiles(session, user);
|
2021-03-15 16:01:14 +01:00
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
//FINALIZE user removal
|
2021-03-15 16:01:14 +01:00
|
|
|
if (authorizable!=null && !authorizable.isGroup()) {
|
|
|
|
log.info("removing user {}", user);
|
|
|
|
authorizable.remove();
|
2021-03-16 00:04:54 +01:00
|
|
|
} else log.warn("the user {} was already deleted, it should never happen", user);
|
2021-03-15 16:01:14 +01:00
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
session.save();
|
2020-01-22 16:41:12 +01:00
|
|
|
}catch(StorageHubException she ){
|
|
|
|
log.error(she.getErrorMessage(), she);
|
|
|
|
GXOutboundErrorResponse.throwException(she, Response.Status.fromStatusCode(she.getStatus()));
|
|
|
|
}catch(RepositoryException re ){
|
|
|
|
log.error("jcr error creating item", re);
|
|
|
|
GXOutboundErrorResponse.throwException(new BackendGenericError("jcr error creating item", re));
|
2018-11-30 17:49:35 +01:00
|
|
|
} finally {
|
|
|
|
if (session!=null)
|
|
|
|
session.logout();
|
|
|
|
}
|
2019-03-26 17:09:26 +01:00
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
|
|
|
private void removeUserFromBelongingGroup(JackrabbitSession session, User authorizable, org.apache.jackrabbit.api.security.user.UserManager usrManager) throws RepositoryException, StorageHubException {
|
|
|
|
Iterator<Authorizable> groups = session.getUserManager().findAuthorizables(new Query() {
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public <T> void build(QueryBuilder<T> builder) {
|
|
|
|
builder.setSelector(Group.class);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String user = authorizable.getPrincipal().getName();
|
|
|
|
while(groups.hasNext()) {
|
|
|
|
Authorizable group = groups.next();
|
|
|
|
log.info("group found {}", group.getPrincipal().getName() );
|
|
|
|
if (group.isGroup() && ((Group)group).isMember(authorizable)) {
|
|
|
|
|
|
|
|
boolean success = groupHandler.removeUserFromGroup(group.getPrincipal().getName(), user, session);
|
|
|
|
log.warn("user {} {} removed from vre {}",user,success?"":"not" ,group.getPrincipal().getName());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private void unshareUsersFolders(JackrabbitSession session, String user){
|
|
|
|
try {
|
|
|
|
|
|
|
|
Node sharedFolderNode = session.getNode(Constants.SHARED_FOLDER_PATH);
|
|
|
|
|
|
|
|
Predicate<Node> sharedWithUserChecker = new Predicate<Node>() {
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean test(Node t) {
|
|
|
|
try {
|
|
|
|
authChecker.checkReadAuthorizationControl(t.getSession(), user, t.getIdentifier());
|
|
|
|
return true;
|
|
|
|
} catch (UserNotAuthorizedException | BackendGenericError | RepositoryException e) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
List<SharedFolder> items = Utils.getItemList(sharedWithUserChecker, sharedFolderNode, Excludes.ALL, null, false, SharedFolder.class);
|
|
|
|
|
|
|
|
log.debug(" Shared folder to unshare found are {}", items.size());
|
|
|
|
|
|
|
|
for (SharedFolder item: items) {
|
|
|
|
String title = item.getTitle();
|
|
|
|
log.debug("in list folder name {} with title {} and path {} ",item.getName(), title, item.getPath());
|
|
|
|
if (item.isPublicItem() && !item.getUsers().getMap().containsKey(user)) continue;
|
|
|
|
if (item.isVreFolder()) continue;
|
|
|
|
|
|
|
|
log.info("removing sharing for folder name {} with title {} and path {} ",item.getName(), title, item.getPath());
|
|
|
|
String owner = item.getOwner();
|
|
|
|
|
|
|
|
Set<String> usersToUnshare= owner.equals(user)? Collections.emptySet():Collections.singleton(user);
|
|
|
|
|
|
|
|
try {
|
|
|
|
unshareHandler.unshareForRemoval(session, usersToUnshare, session.getNodeByIdentifier(item.getId()), user);
|
|
|
|
}catch (Throwable e) {
|
|
|
|
log.warn("error unsharing folder with title '{}' and id {} ", title, item.getId(), e);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (Throwable t) {
|
|
|
|
log.warn("error getting folder shared with {}",user, t);
|
|
|
|
}
|
2018-11-30 17:49:35 +01:00
|
|
|
}
|
|
|
|
|
2021-03-16 00:04:54 +01:00
|
|
|
private void removeUserHomeAndDeleteFiles(JackrabbitSession session, String user) throws RepositoryException, StorageHubException {
|
|
|
|
org.gcube.common.storagehub.model.Path homePath = Utils.getHome(user);
|
|
|
|
org.gcube.common.storagehub.model.Path workspacePath = Utils.getWorkspacePath(user);
|
|
|
|
org.gcube.common.storagehub.model.Path trashPath = Paths.append(workspacePath, Constants.TRASH_ROOT_FOLDER_NAME);
|
|
|
|
|
|
|
|
try {
|
|
|
|
Node workspaceNode = session.getNode(workspacePath.toPath());
|
|
|
|
List<Item> workspaceItems = Utils.getItemList(workspaceNode, Excludes.GET_ONLY_CONTENT, null, true, null).stream().filter(i -> !i.isShared()).collect(Collectors.toList());
|
|
|
|
trashHandler.removeOnlyNodesContent(session, workspaceItems);
|
2021-03-16 13:03:27 +01:00
|
|
|
} catch (PathNotFoundException e) {
|
|
|
|
log.warn("{} workspace dir {} was already deleted", user, homePath.toPath());
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
Node trashNode = session.getNode(trashPath.toPath());
|
|
|
|
List<Item> trashItems = Utils.getItemList(trashNode, Excludes.ALL, null, true, null);
|
|
|
|
trashHandler.removeOnlyNodesContent(session, trashItems);
|
|
|
|
} catch (PathNotFoundException e) {
|
|
|
|
log.warn("{} trash dir {} was already deleted", user, homePath.toPath());
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
Node homeNode = session.getNode(homePath.toPath());
|
2021-03-16 00:04:54 +01:00
|
|
|
homeNode.remove();
|
|
|
|
} catch (PathNotFoundException e) {
|
2021-03-16 13:03:27 +01:00
|
|
|
log.warn("{} home dir {} was already deleted", user, homePath.toPath());
|
2021-03-16 00:04:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-11-30 17:49:35 +01:00
|
|
|
}
|