Browse Source

Support local accounts with LdapUserService and RedmineUserService (issue-183)

tags/v1.2.1
James Moger 11 years ago
parent
commit
4e3c152fa7

+ 1
- 0
docs/04_releases.mkd View File

@@ -12,6 +12,7 @@
#### additions
- Support for locally and remotely authenticated accounts in LdapUserService and RedmineUserService (issue 183)
- Added Dutch translation (github/kwoot)
#### changes

+ 4
- 0
src/com/gitblit/ConfigUserService.java View File

@@ -409,6 +409,10 @@ public class ConfigUserService implements IUserService {
// Read realm file
read();
UserModel model = users.remove(username.toLowerCase());
if (model == null) {
// user does not exist
return false;
}
// remove user from team
for (TeamModel team : model.teams) {
TeamModel t = teams.get(team.name);

+ 8
- 0
src/com/gitblit/Constants.java View File

@@ -405,6 +405,14 @@ public class Constants {
return ordinal() <= COOKIE.ordinal();
}
}
public static enum AccountType {
LOCAL, LDAP, REDMINE;
public boolean isLocal() {
return this == LOCAL;
}
}
@Documented
@Retention(RetentionPolicy.RUNTIME)

+ 24
- 8
src/com/gitblit/GitBlit.java View File

@@ -471,36 +471,48 @@ public class GitBlit implements ServletContextListener {
this.userService.setup(settings);
}
public boolean supportsAddUser() {
return supportsCredentialChanges(new UserModel(""));
}
/**
* Returns true if the user's credentials can be changed.
*
* @param user
* @return true if the user service supports credential changes
*/
public boolean supportsCredentialChanges() {
return userService.supportsCredentialChanges();
public boolean supportsCredentialChanges(UserModel user) {
return (user != null && user.isLocalAccount()) || userService.supportsCredentialChanges();
}
/**
* Returns true if the user's display name can be changed.
*
* @param user
* @return true if the user service supports display name changes
*/
public boolean supportsDisplayNameChanges() {
return userService.supportsDisplayNameChanges();
public boolean supportsDisplayNameChanges(UserModel user) {
return (user != null && user.isLocalAccount()) || userService.supportsDisplayNameChanges();
}
/**
* Returns true if the user's email address can be changed.
*
* @param user
* @return true if the user service supports email address changes
*/
public boolean supportsEmailAddressChanges() {
return userService.supportsEmailAddressChanges();
public boolean supportsEmailAddressChanges(UserModel user) {
return (user != null && user.isLocalAccount()) || userService.supportsEmailAddressChanges();
}
/**
* Returns true if the user's team memberships can be changed.
*
* @param user
* @return true if the user service supports team membership changes
*/
public boolean supportsTeamMembershipChanges() {
return userService.supportsTeamMembershipChanges();
public boolean supportsTeamMembershipChanges(UserModel user) {
return (user != null && user.isLocalAccount()) || userService.supportsTeamMembershipChanges();
}
/**
@@ -789,6 +801,10 @@ public class GitBlit implements ServletContextListener {
* @return the effective list of permissions for the user
*/
public List<RegistrantAccessPermission> getUserAccessPermissions(UserModel user) {
if (StringUtils.isEmpty(user.username)) {
// new user
return new ArrayList<RegistrantAccessPermission>();
}
Set<RegistrantAccessPermission> set = new LinkedHashSet<RegistrantAccessPermission>();
set.addAll(user.getRepositoryPermissions());
// Flag missing repositories

+ 42
- 7
src/com/gitblit/GitblitUserService.java View File

@@ -23,9 +23,11 @@ import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.Constants.AccountType;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.StringUtils;
/**
* This class wraps the default user service and is recommended as the starting
@@ -48,6 +50,8 @@ import com.gitblit.utils.DeepCopier;
public class GitblitUserService implements IUserService {
protected IUserService serviceImpl;
protected final String ExternalAccount = "#externalAccount";
private final Logger logger = LoggerFactory.getLogger(GitblitUserService.class);
@@ -144,12 +148,16 @@ public class GitblitUserService implements IUserService {
@Override
public UserModel authenticate(char[] cookie) {
return serviceImpl.authenticate(cookie);
UserModel user = serviceImpl.authenticate(cookie);
setAccountType(user);
return user;
}
@Override
public UserModel authenticate(String username, char[] password) {
return serviceImpl.authenticate(username, password);
UserModel user = serviceImpl.authenticate(username, password);
setAccountType(user);
return user;
}
@Override
@@ -159,7 +167,9 @@ public class GitblitUserService implements IUserService {
@Override
public UserModel getUserModel(String username) {
return serviceImpl.getUserModel(username);
UserModel user = serviceImpl.getUserModel(username);
setAccountType(user);
return user;
}
@Override
@@ -174,8 +184,8 @@ public class GitblitUserService implements IUserService {
@Override
public boolean updateUserModel(String username, UserModel model) {
if (supportsCredentialChanges()) {
if (!supportsTeamMembershipChanges()) {
if (model.isLocalAccount() || supportsCredentialChanges()) {
if (!model.isLocalAccount() && !supportsTeamMembershipChanges()) {
// teams are externally controlled - copy from original model
UserModel existingModel = getUserModel(username);
@@ -188,7 +198,7 @@ public class GitblitUserService implements IUserService {
if (model.username.equals(username)) {
// passwords are not persisted by the backing user service
model.password = null;
if (!supportsTeamMembershipChanges()) {
if (!model.isLocalAccount() && !supportsTeamMembershipChanges()) {
// teams are externally controlled- copy from original model
UserModel existingModel = getUserModel(username);
@@ -218,7 +228,11 @@ public class GitblitUserService implements IUserService {
@Override
public List<UserModel> getAllUsers() {
return serviceImpl.getAllUsers();
List<UserModel> users = serviceImpl.getAllUsers();
for (UserModel user : users) {
setAccountType(user);
}
return users;
}
@Override
@@ -300,4 +314,25 @@ public class GitblitUserService implements IUserService {
public boolean deleteRepositoryRole(String role) {
return serviceImpl.deleteRepositoryRole(role);
}
protected boolean isLocalAccount(String username) {
UserModel user = getUserModel(username);
return user != null && user.isLocalAccount();
}
protected void setAccountType(UserModel user) {
if (user != null) {
if (!StringUtils.isEmpty(user.password)
&& !ExternalAccount.equalsIgnoreCase(user.password)
&& !"StoredInLDAP".equalsIgnoreCase(user.password)) {
user.accountType = AccountType.LOCAL;
} else {
user.accountType = getAccountType();
}
}
}
protected AccountType getAccountType() {
return AccountType.LOCAL;
}
}

+ 15
- 3
src/com/gitblit/LdapUserService.java View File

@@ -25,6 +25,7 @@ import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.Constants.AccountType;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.ArrayUtils;
@@ -50,9 +51,9 @@ import com.unboundid.util.ssl.TrustAllTrustManager;
public class LdapUserService extends GitblitUserService {
public static final Logger logger = LoggerFactory.getLogger(LdapUserService.class);
private IStoredSettings settings;
private IStoredSettings settings;
public LdapUserService() {
super();
}
@@ -155,9 +156,19 @@ public class LdapUserService extends GitblitUserService {
public boolean supportsTeamMembershipChanges() {
return !settings.getBoolean(Keys.realm.ldap.maintainTeams, false);
}
@Override
protected AccountType getAccountType() {
return AccountType.LDAP;
}
@Override
public UserModel authenticate(String username, char[] password) {
if (isLocalAccount(username)) {
// local account, bypass LDAP authentication
return super.authenticate(username, password);
}
String simpleUsername = getSimpleUsername(username);
LDAPConnection ldapConnection = getLdapConnection();
@@ -239,7 +250,8 @@ public class LdapUserService extends GitblitUserService {
setAdminAttribute(user);
// Don't want visibility into the real password, make up a dummy
user.password = "StoredInLDAP";
user.password = ExternalAccount;
user.accountType = getAccountType();
// Get full name Attribute
String displayName = settings.getString(Keys.realm.ldap.displayName, "");

+ 40
- 11
src/com/gitblit/RedmineUserService.java View File

@@ -9,7 +9,9 @@ import org.apache.wicket.util.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.Constants.AccountType;
import com.gitblit.models.UserModel;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.ConnectionUtils;
import com.gitblit.utils.StringUtils;
import com.google.gson.Gson;
@@ -71,9 +73,19 @@ public class RedmineUserService extends GitblitUserService {
public boolean supportsTeamMembershipChanges() {
return false;
}
@Override
protected AccountType getAccountType() {
return AccountType.REDMINE;
}
@Override
public UserModel authenticate(String username, char[] password) {
if (isLocalAccount(username)) {
// local account, bypass Redmine authentication
return super.authenticate(username, password);
}
String urlText = this.settings.getString(Keys.realm.redmine.url, "");
if (!urlText.endsWith("/")) {
urlText.concat("/");
@@ -87,19 +99,37 @@ public class RedmineUserService extends GitblitUserService {
String login = current.user.login;
boolean canAdmin = true;
// non admin user can not get login name
if (StringUtils.isEmpty(login)) {
canAdmin = false;
login = current.user.mail;
// non admin user can not get login name
// TODO review this assumption, if it is true, it is undocumented
canAdmin = false;
}
UserModel userModel = new UserModel(login);
userModel.canAdmin = canAdmin;
userModel.displayName = current.user.firstname + " " + current.user.lastname;
userModel.emailAddress = current.user.mail;
userModel.cookie = StringUtils.getSHA1(userModel.username + new String(password));
return userModel;
UserModel user = getUserModel(login);
if (user == null) // create user object for new authenticated user
user = new UserModel(login);
// create a user cookie
if (StringUtils.isEmpty(user.cookie) && !ArrayUtils.isEmpty(password)) {
user.cookie = StringUtils.getSHA1(user.username + new String(password));
}
// update user attributes from Redmine
user.accountType = getAccountType();
user.canAdmin = canAdmin;
user.displayName = current.user.firstname + " " + current.user.lastname;
user.emailAddress = current.user.mail;
user.password = ExternalAccount;
// TODO Redmine group mapping for administration & teams
// http://www.redmine.org/projects/redmine/wiki/Rest_Users
// push the changes to the backing user service
super.updateUserModel(user);
return user;
} catch (IOException e) {
logger.error("authenticate", e);
}
@@ -126,5 +156,4 @@ public class RedmineUserService extends GitblitUserService {
public void setTestingCurrentUserAsJson(String json) {
this.testingJson = json;
}
}

+ 2
- 2
src/com/gitblit/client/UsersPanel.java View File

@@ -112,8 +112,8 @@ public abstract class UsersPanel extends JPanel {
String name = table.getColumnName(UsersTableModel.Columns.Name.ordinal());
table.getColumn(name).setCellRenderer(nameRenderer);
int w = 125;
name = table.getColumnName(UsersTableModel.Columns.AccessLevel.ordinal());
int w = 130;
name = table.getColumnName(UsersTableModel.Columns.Type.ordinal());
table.getColumn(name).setMinWidth(w);
table.getColumn(name).setMaxWidth(w);
name = table.getColumnName(UsersTableModel.Columns.Teams.ordinal());

+ 13
- 6
src/com/gitblit/client/UsersTableModel.java View File

@@ -36,7 +36,7 @@ public class UsersTableModel extends AbstractTableModel {
List<UserModel> list;
enum Columns {
Name, Display_Name, AccessLevel, Teams, Repositories;
Name, Display_Name, Type, Teams, Repositories;
@Override
public String toString() {
@@ -71,8 +71,8 @@ public class UsersTableModel extends AbstractTableModel {
return Translation.get("gb.name");
case Display_Name:
return Translation.get("gb.displayName");
case AccessLevel:
return Translation.get("gb.accessLevel");
case Type:
return Translation.get("gb.type");
case Teams:
return Translation.get("gb.teamMemberships");
case Repositories:
@@ -101,11 +101,18 @@ public class UsersTableModel extends AbstractTableModel {
return model.username;
case Display_Name:
return model.displayName;
case AccessLevel:
case Type:
StringBuilder sb = new StringBuilder();
if (model.accountType != null) {
sb.append(model.accountType.name());
}
if (model.canAdmin()) {
return "administrator";
if (sb.length() > 0) {
sb.append(", ");
}
sb.append("admin");
}
return "";
return sb.toString();
case Teams:
return (model.teams == null || model.teams.size() == 0) ? "" : String
.valueOf(model.teams.size());

+ 8
- 0
src/com/gitblit/models/UserModel.java View File

@@ -29,6 +29,7 @@ import java.util.TreeSet;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.Constants.AccessRestrictionType;
import com.gitblit.Constants.AccountType;
import com.gitblit.Constants.AuthorizationControl;
import com.gitblit.Constants.PermissionType;
import com.gitblit.Constants.RegistrantType;
@@ -73,15 +74,22 @@ public class UserModel implements Principal, Serializable, Comparable<UserModel>
// non-persisted fields
public boolean isAuthenticated;
public AccountType accountType;
public UserModel(String username) {
this.username = username;
this.isAuthenticated = true;
this.accountType = AccountType.LOCAL;
}
private UserModel() {
this.username = "$anonymous";
this.isAuthenticated = false;
this.accountType = AccountType.LOCAL;
}
public boolean isLocalAccount() {
return accountType.isLocal();
}
/**

+ 1
- 1
src/com/gitblit/wicket/pages/BasePage.java View File

@@ -433,7 +433,7 @@ public abstract class BasePage extends WebPage {
GitBlitWebSession session = GitBlitWebSession.get();
if (session.isLoggedIn()) {
UserModel user = session.getUser();
boolean editCredentials = GitBlit.self().supportsCredentialChanges();
boolean editCredentials = GitBlit.self().supportsCredentialChanges(user);
boolean standardLogin = session.authenticationType.isStandard();
// username, logout, and change password

+ 3
- 2
src/com/gitblit/wicket/pages/ChangePasswordPage.java View File

@@ -51,12 +51,13 @@ public class ChangePasswordPage extends RootSubPage {
throw new RestartResponseException(getApplication().getHomePage());
}
if (!GitBlit.self().supportsCredentialChanges()) {
UserModel user = GitBlitWebSession.get().getUser();
if (!GitBlit.self().supportsCredentialChanges(user)) {
error(MessageFormat.format(getString("gb.userServiceDoesNotPermitPasswordChanges"),
GitBlit.getString(Keys.realm.userService, "users.conf")), true);
}
setupPage(getString("gb.changePassword"), GitBlitWebSession.get().getUsername());
setupPage(getString("gb.changePassword"), user.username);
StatelessForm<Void> form = new StatelessForm<Void>("passwordForm") {

+ 1
- 1
src/com/gitblit/wicket/pages/EditTeamPage.java View File

@@ -212,7 +212,7 @@ public class EditTeamPage extends RootSubPage {
form.add(new SimpleAttributeModifier("autocomplete", "off"));
// not all user services support manipulating team memberships
boolean editMemberships = GitBlit.self().supportsTeamMembershipChanges();
boolean editMemberships = GitBlit.self().supportsTeamMembershipChanges(null);
// field names reflective match TeamModel fields
form.add(new TextField<String>("name"));

+ 6
- 6
src/com/gitblit/wicket/pages/EditUserPage.java View File

@@ -55,7 +55,7 @@ public class EditUserPage extends RootSubPage {
public EditUserPage() {
// create constructor
super();
if (!GitBlit.self().supportsCredentialChanges()) {
if (!GitBlit.self().supportsAddUser()) {
error(MessageFormat.format(getString("gb.userServiceDoesNotPermitAddUser"),
GitBlit.getString(Keys.realm.userService, "users.conf")), true);
}
@@ -134,7 +134,7 @@ public class EditUserPage extends RootSubPage {
}
boolean rename = !StringUtils.isEmpty(oldName)
&& !oldName.equalsIgnoreCase(username);
if (GitBlit.self().supportsCredentialChanges()) {
if (GitBlit.self().supportsCredentialChanges(userModel)) {
if (!userModel.password.equals(confirmPassword.getObject())) {
error(getString("gb.passwordsDoNotMatch"));
return;
@@ -210,16 +210,16 @@ public class EditUserPage extends RootSubPage {
form.add(new SimpleAttributeModifier("autocomplete", "off"));
// not all user services support manipulating username and password
boolean editCredentials = GitBlit.self().supportsCredentialChanges();
boolean editCredentials = GitBlit.self().supportsCredentialChanges(userModel);
// not all user services support manipulating display name
boolean editDisplayName = GitBlit.self().supportsDisplayNameChanges();
boolean editDisplayName = GitBlit.self().supportsDisplayNameChanges(userModel);
// not all user services support manipulating email address
boolean editEmailAddress = GitBlit.self().supportsEmailAddressChanges();
boolean editEmailAddress = GitBlit.self().supportsEmailAddressChanges(userModel);
// not all user services support manipulating team memberships
boolean editTeams = GitBlit.self().supportsTeamMembershipChanges();
boolean editTeams = GitBlit.self().supportsTeamMembershipChanges(userModel);
// field names reflective match UserModel fields
form.add(new TextField<String>("username").setEnabled(editCredentials));

+ 1
- 1
src/com/gitblit/wicket/panels/TeamsPanel.java View File

@@ -40,7 +40,7 @@ public class TeamsPanel extends BasePanel {
Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this);
adminLinks.add(new BookmarkablePageLink<Void>("newTeam", EditTeamPage.class));
add(adminLinks.setVisible(showAdmin && GitBlit.self().supportsTeamMembershipChanges()));
add(adminLinks.setVisible(showAdmin && GitBlit.self().supportsTeamMembershipChanges(null)));
final List<TeamModel> teams = GitBlit.self().getAllTeams();
DataView<TeamModel> teamsView = new DataView<TeamModel>("teamRow",

+ 2
- 2
src/com/gitblit/wicket/panels/UsersPanel.html View File

@@ -17,7 +17,7 @@
</th>
<th class="hidden-phone hidden-tablet left"><wicket:message key="gb.displayName">[display name]</wicket:message></th>
<th class="hidden-phone hidden-tablet left"><wicket:message key="gb.emailAddress">[email address]</wicket:message></th>
<th class="hidden-phone" style="width:120px;"><wicket:message key="gb.accessLevel">[access level]</wicket:message></th>
<th class="hidden-phone" style="width:140px;"><wicket:message key="gb.type">[type]</wicket:message></th>
<th class="hidden-phone" style="width:140px;"><wicket:message key="gb.teamMemberships">[team memberships]</wicket:message></th>
<th class="hidden-phone" style="width:100px;"><wicket:message key="gb.repositories">[repositories]</wicket:message></th>
<th style="width:80px;" class="right"></th>
@@ -27,7 +27,7 @@
<td class="left" ><span class="list" wicket:id="username">[username]</span></td>
<td class="hidden-phone hidden-tablet left" ><span class="list" wicket:id="displayName">[display name]</span></td>
<td class="hidden-phone hidden-tablet left" ><span class="list" wicket:id="emailAddress">[email address]</span></td>
<td class="hidden-phone left" ><span class="list" wicket:id="accesslevel">[access level]</span></td>
<td class="hidden-phone left" ><span style="font-size: 0.8em;" wicket:id="accountType">[account type]</span></td>
<td class="hidden-phone left" ><span class="list" wicket:id="teams">[team memberships]</span></td>
<td class="hidden-phone left" ><span class="list" wicket:id="repositories">[repositories]</span></td>
<td class="rightAlign"><span wicket:id="userLinks"></span></td>

+ 2
- 2
src/com/gitblit/wicket/panels/UsersPanel.java View File

@@ -41,7 +41,7 @@ public class UsersPanel extends BasePanel {
Fragment adminLinks = new Fragment("adminPanel", "adminLinks", this);
adminLinks.add(new BookmarkablePageLink<Void>("newUser", EditUserPage.class)
.setVisible(GitBlit.self().supportsCredentialChanges()));
.setVisible(GitBlit.self().supportsAddUser()));
add(adminLinks.setVisible(showAdmin));
final List<UserModel> users = GitBlit.self().getAllUsers();
@@ -81,7 +81,7 @@ public class UsersPanel extends BasePanel {
item.add(editLink);
}
item.add(new Label("accesslevel", entry.canAdmin() ? "administrator" : ""));
item.add(new Label("accountType", entry.accountType.name() + (entry.canAdmin() ? ", admin":"")));
item.add(new Label("teams", entry.teams.size() > 0 ? ("" + entry.teams.size()) : ""));
item.add(new Label("repositories",
entry.permissions.size() > 0 ? ("" + entry.permissions.size()) : ""));

+ 16
- 0
tests/com/gitblit/tests/LdapUserServiceTest.java View File

@@ -31,6 +31,7 @@ import org.junit.Test;
import com.gitblit.LdapUserService;
import com.gitblit.models.UserModel;
import com.gitblit.tests.mock.MemorySettings;
import com.gitblit.utils.StringUtils;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
@@ -154,5 +155,20 @@ public class LdapUserServiceTest {
UserModel userOneModel = ldapUserService.authenticate("*)(userPassword=userOnePassword", "userOnePassword".toCharArray());
assertNull(userOneModel);
}
@Test
public void testLocalAccount() {
UserModel localAccount = new UserModel("bruce");
localAccount.displayName = "Bruce Campbell";
localAccount.password = StringUtils.MD5_TYPE + StringUtils.getMD5("gimmesomesugar");
ldapUserService.deleteUser(localAccount.username);
assertTrue("Failed to add local account",
ldapUserService.updateUserModel(localAccount));
assertEquals("Accounts are not equal!",
localAccount,
ldapUserService.authenticate(localAccount.username, "gimmesomesugar".toCharArray()));
assertTrue("Failed to delete local account!",
ldapUserService.deleteUser(localAccount.username));
}

}

+ 22
- 2
tests/com/gitblit/tests/RedmineUserServiceTest.java View File

@@ -1,9 +1,10 @@
package com.gitblit.tests;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import java.util.HashMap;
@@ -12,6 +13,7 @@ import org.junit.Test;
import com.gitblit.RedmineUserService;
import com.gitblit.models.UserModel;
import com.gitblit.tests.mock.MemorySettings;
import com.gitblit.utils.StringUtils;
public class RedmineUserServiceTest {
@@ -29,7 +31,7 @@ public class RedmineUserServiceTest {
redmineUserService.setup(new MemorySettings(new HashMap<String, Object>()));
redmineUserService.setTestingCurrentUserAsJson(JSON);
UserModel userModel = redmineUserService.authenticate("RedmineUserId", "RedmineAPIKey".toCharArray());
assertThat(userModel.getName(), is("RedmineUserId"));
assertThat(userModel.getName(), is("redmineuserid"));
assertThat(userModel.getDisplayName(), is("baz foo"));
assertThat(userModel.emailAddress, is("baz@example.com"));
assertNotNull(userModel.cookie);
@@ -48,5 +50,23 @@ public class RedmineUserServiceTest {
assertNotNull(userModel.cookie);
assertThat(userModel.canAdmin, is(false));
}
@Test
public void testLocalAccount() {
RedmineUserService redmineUserService = new RedmineUserService();
redmineUserService.setup(new MemorySettings(new HashMap<String, Object>()));
UserModel localAccount = new UserModel("bruce");
localAccount.displayName = "Bruce Campbell";
localAccount.password = StringUtils.MD5_TYPE + StringUtils.getMD5("gimmesomesugar");
redmineUserService.deleteUser(localAccount.username);
assertTrue("Failed to add local account",
redmineUserService.updateUserModel(localAccount));
assertEquals("Accounts are not equal!",
localAccount,
redmineUserService.authenticate(localAccount.username, "gimmesomesugar".toCharArray()));
assertTrue("Failed to delete local account!",
redmineUserService.deleteUser(localAccount.username));
}
}

Loading…
Cancel
Save