Merge pull request #350 from stianst/constraints
Enforce that realm name is unique in model
This commit is contained in:
commit
1fa019e9dd
21 changed files with 193 additions and 67 deletions
|
@ -7,6 +7,8 @@ public interface AuditProvider extends AuditListener {
|
|||
|
||||
public EventQuery createQuery();
|
||||
|
||||
public void clear();
|
||||
|
||||
public void clear(String realmId);
|
||||
|
||||
public void clear(String realmId, long olderThan);
|
||||
|
|
|
@ -28,6 +28,12 @@ public class JpaAuditProvider implements AuditProvider {
|
|||
return new JpaEventQuery(em);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
beginTx();
|
||||
em.createQuery("delete from EventEntity").executeUpdate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(String realmId) {
|
||||
beginTx();
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package org.keycloak.audit.mongo;
|
||||
|
||||
import com.mongodb.BasicDBList;
|
||||
import com.mongodb.BasicDBObject;
|
||||
import com.mongodb.DBCollection;
|
||||
import com.mongodb.DBObject;
|
||||
|
@ -27,6 +26,11 @@ public class MongoAuditProvider implements AuditProvider {
|
|||
return new MongoEventQuery(audit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
audit.remove(new BasicDBObject());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(String realmId) {
|
||||
audit.remove(new BasicDBObject("realmId", realmId));
|
||||
|
|
|
@ -34,8 +34,7 @@ public abstract class AbstractAuditProviderTest {
|
|||
|
||||
@After
|
||||
public void after() {
|
||||
provider.clear("realmId");
|
||||
provider.clear("realmId2");
|
||||
provider.clear();
|
||||
provider.close();
|
||||
factory.close();
|
||||
}
|
||||
|
|
|
@ -39,6 +39,4 @@ public interface ApplicationModel extends RoleContainerModel, ClientModel {
|
|||
boolean isBearerOnly();
|
||||
void setBearerOnly(boolean only);
|
||||
|
||||
void addScope(RoleModel role);
|
||||
|
||||
}
|
||||
|
|
|
@ -245,11 +245,6 @@ public class ApplicationAdapter extends ClientAdapter implements ApplicationMode
|
|||
em.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addScope(RoleModel role) {
|
||||
realm.addScopeMapping(this, role);
|
||||
}
|
||||
|
||||
public boolean equals(Object o) {
|
||||
if (o == null) return false;
|
||||
if (o == this) return true;
|
||||
|
|
|
@ -4,6 +4,7 @@ import org.hibernate.exception.ConstraintViolationException;
|
|||
import org.keycloak.models.ModelException;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
|
||||
import javax.persistence.EntityExistsException;
|
||||
import javax.persistence.EntityManager;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
|
@ -33,6 +34,8 @@ public class PersistenceExceptionConverter implements InvocationHandler {
|
|||
Throwable c = e.getCause();
|
||||
if (c.getCause() != null && c.getCause() instanceof ConstraintViolationException) {
|
||||
throw new ModelDuplicateException(c);
|
||||
} if (c instanceof EntityExistsException) {
|
||||
throw new ModelDuplicateException(c);
|
||||
} else {
|
||||
throw new ModelException(c);
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ public class RealmEntity {
|
|||
@Id
|
||||
protected String id;
|
||||
|
||||
//@Column(unique = true)
|
||||
@Column(unique = true)
|
||||
protected String name;
|
||||
|
||||
protected boolean enabled;
|
||||
|
|
|
@ -179,17 +179,21 @@ public class MongoStoreImpl implements MongoStore {
|
|||
try {
|
||||
dbCollection.insert(dbObject);
|
||||
} catch (MongoException e) {
|
||||
if (e instanceof MongoException.DuplicateKey) {
|
||||
throw new ModelDuplicateException(e);
|
||||
} else {
|
||||
throw new ModelException(e);
|
||||
}
|
||||
throw convertException(e);
|
||||
}
|
||||
|
||||
// Treat object as created in this transaction (It is already submited to transaction)
|
||||
context.addCreatedEntity(entity);
|
||||
}
|
||||
|
||||
public static ModelException convertException(MongoException e) {
|
||||
if (e instanceof MongoException.DuplicateKey) {
|
||||
return new ModelDuplicateException(e);
|
||||
} else {
|
||||
return new ModelException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateEntity(final MongoIdentifiableEntity entity, MongoStoreInvocationContext context) {
|
||||
MongoTask fullUpdateTask = new MongoTask() {
|
||||
|
|
|
@ -123,6 +123,7 @@ public class ApplicationAdapter extends ClientAdapter<ApplicationEntity> impleme
|
|||
roleEntity.setApplicationId(getId());
|
||||
|
||||
getMongoStore().insertEntity(roleEntity, invocationContext);
|
||||
|
||||
return new RoleAdapter(getRealm(), roleEntity, this, invocationContext);
|
||||
}
|
||||
|
||||
|
@ -159,11 +160,6 @@ public class ApplicationAdapter extends ClientAdapter<ApplicationEntity> impleme
|
|||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addScope(RoleModel role) {
|
||||
getMongoStore().pushItemToList(getMongoEntity(), "scopeIds", role.getId(), true, invocationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<RoleModel> getApplicationScopeMappings(ClientModel client) {
|
||||
Set<RoleModel> result = new HashSet<RoleModel>();
|
||||
|
|
|
@ -46,10 +46,6 @@ public class MongoKeycloakSession implements KeycloakSession {
|
|||
|
||||
@Override
|
||||
public RealmModel createRealm(String id, String name) {
|
||||
if (getRealm(id) != null) {
|
||||
throw new IllegalStateException("Realm with id '" + id + "' already exists");
|
||||
}
|
||||
|
||||
RealmEntity newRealm = new RealmEntity();
|
||||
newRealm.setId(id);
|
||||
newRealm.setName(name);
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package org.keycloak.models.mongo.keycloak.adapters;
|
||||
|
||||
import com.mongodb.MongoException;
|
||||
import org.keycloak.models.KeycloakTransaction;
|
||||
import org.keycloak.models.mongo.api.context.MongoStoreInvocationContext;
|
||||
import org.keycloak.models.mongo.impl.MongoStoreImpl;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
|
@ -35,7 +37,11 @@ public class MongoKeycloakTransaction implements KeycloakTransaction {
|
|||
throw new IllegalStateException("Can't commit as transaction marked for rollback");
|
||||
}
|
||||
|
||||
try {
|
||||
invocationContext.commit();
|
||||
} catch (MongoException e) {
|
||||
throw MongoStoreImpl.convertException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -541,6 +541,7 @@ public class RealmAdapter extends AbstractMongoAdapter<RealmEntity> implements R
|
|||
roleEntity.setRealmId(getId());
|
||||
|
||||
getMongoStore().insertEntity(roleEntity, invocationContext);
|
||||
|
||||
return new RoleAdapter(this, roleEntity, this, invocationContext);
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ import java.util.Set;
|
|||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
@MongoCollection(collectionName = "realms")
|
||||
//@MongoIndex(fields = { "name" }, unique = true)
|
||||
@MongoIndex(fields = { "name" }, unique = true)
|
||||
public class RealmEntity extends AbstractMongoIdentifiableEntity implements MongoEntity {
|
||||
|
||||
private String name;
|
||||
|
@ -422,5 +422,8 @@ public class RealmEntity extends AbstractMongoIdentifiableEntity implements Mong
|
|||
|
||||
// Remove all applications of this realm
|
||||
context.getMongoStore().removeEntities(ApplicationEntity.class, query, context);
|
||||
|
||||
// Remove all clients of this realm
|
||||
context.getMongoStore().removeEntities(OAuthClientEntity.class, query, context);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,11 +6,15 @@ import java.io.InputStream;
|
|||
import java.util.Set;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.BeforeClass;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.Config;
|
||||
import org.keycloak.provider.ProviderSession;
|
||||
import org.keycloak.provider.ProviderSessionFactory;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
|
@ -24,23 +28,41 @@ import org.keycloak.util.JsonSerialization;
|
|||
*/
|
||||
public class AbstractModelTest {
|
||||
|
||||
protected KeycloakSessionFactory factory;
|
||||
protected static KeycloakSessionFactory factory;
|
||||
protected static ProviderSessionFactory providerSessionFactory;
|
||||
|
||||
protected KeycloakSession identitySession;
|
||||
protected RealmManager realmManager;
|
||||
protected ProviderSessionFactory providerSessionFactory;
|
||||
protected ProviderSession providerSession;
|
||||
|
||||
@BeforeClass
|
||||
public static void beforeClass() {
|
||||
factory = KeycloakApplication.createSessionFactory();
|
||||
providerSessionFactory = KeycloakApplication.createProviderSessionFactory();
|
||||
|
||||
KeycloakSession identitySession = factory.createSession();
|
||||
try {
|
||||
identitySession.getTransaction().begin();
|
||||
new ApplianceBootstrap().bootstrap(identitySession, "/auth");
|
||||
identitySession.getTransaction().commit();
|
||||
} finally {
|
||||
identitySession.close();
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void afterClass() {
|
||||
providerSessionFactory.close();
|
||||
factory.close();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void before() throws Exception {
|
||||
factory = KeycloakApplication.createSessionFactory();
|
||||
identitySession = factory.createSession();
|
||||
identitySession.getTransaction().begin();
|
||||
realmManager = new RealmManager(identitySession);
|
||||
|
||||
providerSessionFactory = KeycloakApplication.createProviderSessionFactory();
|
||||
providerSession = providerSessionFactory.createSession();
|
||||
|
||||
new ApplianceBootstrap().bootstrap(identitySession, "/auth");
|
||||
}
|
||||
|
||||
@After
|
||||
|
@ -48,12 +70,35 @@ public class AbstractModelTest {
|
|||
identitySession.getTransaction().commit();
|
||||
providerSession.close();
|
||||
identitySession.close();
|
||||
providerSessionFactory.close();
|
||||
factory.close();
|
||||
|
||||
identitySession = factory.createSession();
|
||||
try {
|
||||
identitySession.getTransaction().begin();
|
||||
|
||||
RealmManager rm = new RealmManager(identitySession);
|
||||
for (RealmModel realm : identitySession.getRealms()) {
|
||||
if (!realm.getName().equals(Config.getAdminRealm())) {
|
||||
rm.removeRealm(realm);
|
||||
}
|
||||
}
|
||||
|
||||
identitySession.getTransaction().commit();
|
||||
} finally {
|
||||
identitySession.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void commit() {
|
||||
commit(false);
|
||||
}
|
||||
|
||||
protected void commit(boolean rollback) {
|
||||
if (rollback) {
|
||||
identitySession.getTransaction().rollback();
|
||||
} else {
|
||||
identitySession.getTransaction().commit();
|
||||
}
|
||||
identitySession.close();
|
||||
identitySession = factory.createSession();
|
||||
identitySession.getTransaction().begin();
|
||||
|
@ -68,8 +113,6 @@ public class AbstractModelTest {
|
|||
os.write(c);
|
||||
}
|
||||
byte[] bytes = os.toByteArray();
|
||||
System.out.println(new String(bytes));
|
||||
|
||||
return JsonSerialization.readValue(bytes, RealmRepresentation.class);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import org.junit.Test;
|
|||
import org.junit.runners.MethodSorters;
|
||||
import org.keycloak.models.ApplicationModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
import org.keycloak.models.OAuthClientModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RequiredCredentialModel;
|
||||
|
@ -44,7 +45,6 @@ public class AdapterTest extends AbstractModelTest {
|
|||
realmModel.setUpdateProfileOnInitialSocialLogin(true);
|
||||
realmModel.addDefaultRole("foo");
|
||||
|
||||
System.out.println(realmModel.getId());
|
||||
realmModel = realmManager.getRealm(realmModel.getId());
|
||||
Assert.assertNotNull(realmModel);
|
||||
Assert.assertEquals(realmModel.getAccessCodeLifespan(), 100);
|
||||
|
@ -302,7 +302,7 @@ public class AdapterTest extends AbstractModelTest {
|
|||
}
|
||||
String[] usernames = users.toArray(new String[users.size()]);
|
||||
Arrays.sort(usernames);
|
||||
Assert.assertArrayEquals(new String[] { "doublefirst", "doublelast"}, usernames);
|
||||
Assert.assertArrayEquals(new String[]{"doublefirst", "doublelast"}, usernames);
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -472,5 +472,67 @@ public class AdapterTest extends AbstractModelTest {
|
|||
Assert.assertFalse(realmModel.hasRole(user, appBarRole));
|
||||
}
|
||||
|
||||
// TODO: test scopes
|
||||
@Test
|
||||
public void testScopes() throws Exception {
|
||||
test1CreateRealm();
|
||||
RoleModel realmRole = realmModel.addRole("realm");
|
||||
|
||||
ApplicationModel app1 = realmModel.addApplication("app1");
|
||||
RoleModel appRole = app1.addRole("app");
|
||||
|
||||
ApplicationModel app2 = realmModel.addApplication("app2");
|
||||
realmModel.addScopeMapping(app2, realmRole);
|
||||
realmModel.addScopeMapping(app2, appRole);
|
||||
|
||||
OAuthClientModel client = realmModel.addOAuthClient("client");
|
||||
realmModel.addScopeMapping(client, realmRole);
|
||||
realmModel.addScopeMapping(client, appRole);
|
||||
|
||||
commit();
|
||||
|
||||
realmModel = identitySession.getRealmByName("JUGGLER");
|
||||
app1 = realmModel.getApplicationByName("app1");
|
||||
app2 = realmModel.getApplicationByName("app2");
|
||||
client = realmModel.getOAuthClient("client");
|
||||
|
||||
Set<RoleModel> scopeMappings = realmModel.getScopeMappings(app2);
|
||||
Assert.assertEquals(2, scopeMappings.size());
|
||||
Assert.assertTrue(scopeMappings.contains(realmModel.getRole("realm")));
|
||||
Assert.assertTrue(scopeMappings.contains(app1.getRole("app")));
|
||||
|
||||
scopeMappings = realmModel.getScopeMappings(client);
|
||||
Assert.assertEquals(2, scopeMappings.size());
|
||||
Assert.assertTrue(scopeMappings.contains(realmModel.getRole("realm")));
|
||||
Assert.assertTrue(scopeMappings.contains(app1.getRole("app")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRealmNameCollisions() throws Exception {
|
||||
test1CreateRealm();
|
||||
|
||||
commit();
|
||||
|
||||
// Try to create realm with duplicate name
|
||||
try {
|
||||
test1CreateRealm();
|
||||
commit();
|
||||
Assert.fail("Expected exception");
|
||||
} catch (ModelDuplicateException e) {
|
||||
}
|
||||
commit(true);
|
||||
|
||||
// Ty to rename realm to duplicate name
|
||||
realmModel = realmManager.createRealm("JUGGLER2");
|
||||
commit();
|
||||
|
||||
realmModel = realmManager.getRealmByName("JUGGLER2");
|
||||
try {
|
||||
realmModel.setName("JUGGLER");
|
||||
commit();
|
||||
Assert.fail("Expected exception");
|
||||
} catch (ModelDuplicateException e) {
|
||||
}
|
||||
commit(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,8 +7,10 @@ import javax.ws.rs.core.MultivaluedMap;
|
|||
|
||||
import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
||||
import org.junit.After;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Test;
|
||||
import org.junit.runners.MethodSorters;
|
||||
|
@ -29,22 +31,38 @@ import org.keycloak.authentication.AuthenticationProviderManager;
|
|||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
public class AuthProvidersLDAPTest extends AbstractModelTest {
|
||||
|
||||
private static LDAPEmbeddedServer embeddedServer;
|
||||
|
||||
private RealmModel realm;
|
||||
private AuthenticationManager am;
|
||||
private LDAPEmbeddedServer embeddedServer;
|
||||
|
||||
@Before
|
||||
@Override
|
||||
public void before() throws Exception {
|
||||
super.before();
|
||||
@BeforeClass
|
||||
public static void beforeClass() {
|
||||
AbstractModelTest.beforeClass();
|
||||
|
||||
try {
|
||||
this.embeddedServer = new LDAPEmbeddedServer();
|
||||
this.embeddedServer.setup();
|
||||
this.embeddedServer.importLDIF("ldap/users.ldif");
|
||||
embeddedServer = new LDAPEmbeddedServer();
|
||||
embeddedServer.setup();
|
||||
embeddedServer.importLDIF("ldap/users.ldif");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error starting Embedded LDAP server.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void afterClass() {
|
||||
AbstractModelTest.afterClass();
|
||||
|
||||
try {
|
||||
embeddedServer.tearDown();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error starting Embedded LDAP server.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
public void before() throws Exception {
|
||||
super.before();
|
||||
|
||||
// Create realm and configure ldap
|
||||
realm = realmManager.createRealm("realm");
|
||||
|
@ -55,17 +73,6 @@ public class AuthProvidersLDAPTest extends AbstractModelTest {
|
|||
am = new AuthenticationManager(providerSession);
|
||||
}
|
||||
|
||||
@After
|
||||
@Override
|
||||
public void after() throws Exception {
|
||||
super.after();
|
||||
try {
|
||||
this.embeddedServer.tearDown();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error starting Embedded LDAP server.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLdapAuthentication() {
|
||||
MultivaluedMap<String, String> formData = AuthProvidersExternalModelTest.createFormData("john", "password");
|
||||
|
|
|
@ -70,6 +70,7 @@ public class ModelTest extends AbstractModelTest {
|
|||
|
||||
private RealmModel importExport(RealmModel src, String copyName) {
|
||||
RealmRepresentation representation = ModelToRepresentation.toRepresentation(src);
|
||||
representation.setRealm(copyName);
|
||||
RealmModel copy = realmManager.createRealm(copyName);
|
||||
realmManager.importRealm(representation, copy);
|
||||
return realmManager.getRealm(copy.getId());
|
||||
|
|
|
@ -22,8 +22,8 @@ public class MultipleRealmsTest extends AbstractModelTest {
|
|||
@Override
|
||||
public void before() throws Exception {
|
||||
super.before();
|
||||
realm1 = identitySession.createRealm("id1", "realm1");
|
||||
realm2 = identitySession.createRealm("id2", "realm2");
|
||||
realm1 = realmManager.createRealm("id1", "realm1");
|
||||
realm2 = realmManager.createRealm("id2", "realm2");
|
||||
|
||||
createObjects(realm1);
|
||||
createObjects(realm2);
|
||||
|
@ -93,7 +93,7 @@ public class MultipleRealmsTest extends AbstractModelTest {
|
|||
realm.addRole("role2");
|
||||
|
||||
app1.addRole("app1Role1");
|
||||
app1.addScope(realm.getRole("role1"));
|
||||
realm.addScopeMapping(app1, realm.getRole("role1"));
|
||||
|
||||
realm.addOAuthClient("cl1");
|
||||
}
|
||||
|
|
|
@ -71,7 +71,7 @@ public class ApplianceBootstrap {
|
|||
|
||||
RoleModel adminRole = realm.getRole(AdminRoles.ADMIN);
|
||||
|
||||
adminConsole.addScope(adminRole);
|
||||
realm.addScopeMapping(adminConsole, adminRole);
|
||||
|
||||
UserModel adminUser = realm.addUser("admin");
|
||||
adminUser.setEnabled(true);
|
||||
|
|
|
@ -87,14 +87,14 @@ public class CompositeRoleTest {
|
|||
|
||||
final ApplicationModel realmComposite1Application = new ApplicationManager(manager).createApplication(realm, "REALM_COMPOSITE_1_APPLICATION");
|
||||
realmComposite1Application.setEnabled(true);
|
||||
realmComposite1Application.addScope(realmComposite1);
|
||||
realm.addScopeMapping(realmComposite1Application, realmComposite1);
|
||||
realmComposite1Application.setBaseUrl("http://localhost:8081/app");
|
||||
realmComposite1Application.setManagementUrl("http://localhost:8081/app/logout");
|
||||
realmComposite1Application.setSecret("password");
|
||||
|
||||
final ApplicationModel realmRole1Application = new ApplicationManager(manager).createApplication(realm, "REALM_ROLE_1_APPLICATION");
|
||||
realmRole1Application.setEnabled(true);
|
||||
realmRole1Application.addScope(realmRole1);
|
||||
realm.addScopeMapping(realmRole1Application, realmRole1);
|
||||
realmRole1Application.setBaseUrl("http://localhost:8081/app");
|
||||
realmRole1Application.setManagementUrl("http://localhost:8081/app/logout");
|
||||
realmRole1Application.setSecret("password");
|
||||
|
@ -127,7 +127,7 @@ public class CompositeRoleTest {
|
|||
appCompositeApplication.setManagementUrl("http://localhost:8081/app/logout");
|
||||
appCompositeApplication.setSecret("password");
|
||||
final RoleModel appCompositeRole = appCompositeApplication.addRole("APP_COMPOSITE_ROLE");
|
||||
appCompositeApplication.addScope(appRole2);
|
||||
realm.addScopeMapping(appCompositeApplication, appRole2);
|
||||
appCompositeRole.addCompositeRole(realmRole1);
|
||||
appCompositeRole.addCompositeRole(realmRole2);
|
||||
appCompositeRole.addCompositeRole(realmRole3);
|
||||
|
|
Loading…
Reference in a new issue