finalize 1.3.2
This commit is contained in:
parent
2aa5501b03
commit
216589537c
6 changed files with 441 additions and 1 deletions
|
|
@ -134,6 +134,7 @@
|
|||
<version>${org.imixs.office.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
|
|
@ -142,7 +143,7 @@
|
|||
<webResources>
|
||||
<resource>
|
||||
<filtering>true</filtering>
|
||||
<directory>oidc/</directory>
|
||||
<directory>src/oidc/</directory>
|
||||
<includes>
|
||||
<include>**/*</include>
|
||||
</includes>
|
||||
|
|
@ -457,5 +458,13 @@
|
|||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-authz-client</artifactId>
|
||||
<version>26.0.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"client-id" : "imixs",
|
||||
"provider-url" : "https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow",
|
||||
"ssl-required" : "EXTERNAL",
|
||||
"bearer-only" : "true",
|
||||
"credentials" : {
|
||||
"secret" : "O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"client-id" : "imixs",
|
||||
"provider-url" : "https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow",
|
||||
"bearer-only" : "true",
|
||||
"credentials" : {
|
||||
"secret" : "O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V"
|
||||
}
|
||||
}
|
||||
143
office-alexander-logistics-app/src/oidc/WEB-INF/web.xml
Normal file
143
office-alexander-logistics-app/src/oidc/WEB-INF/web.xml
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
|
||||
version="6.0">
|
||||
<display-name>imixs-office-workflow</display-name>
|
||||
<welcome-file-list>
|
||||
<welcome-file>index.xhtml</welcome-file>
|
||||
</welcome-file-list>
|
||||
|
||||
<!-- -->
|
||||
<context-param>
|
||||
<param-name>jakarta.faces.STATE_SAVING_METHOD</param-name>
|
||||
<param-value>server</param-value>
|
||||
</context-param>
|
||||
<context-param>
|
||||
<param-name>jakarta.faces.CONFIG_FILES</param-name>
|
||||
<param-value>
|
||||
/WEB-INF/faces-config-office.xml,
|
||||
/WEB-INF/faces-config-custom.xml
|
||||
</param-value>
|
||||
</context-param>
|
||||
|
||||
|
||||
<!-- project stage is computed by the maven profile settings -->
|
||||
<context-param>
|
||||
<param-name>jakarta.faces.PROJECT_STAGE</param-name>
|
||||
<param-value>Production</param-value>
|
||||
</context-param>
|
||||
|
||||
<!-- Session Timeout 180 = 3h -->
|
||||
<session-config>
|
||||
<session-timeout>60</session-timeout>
|
||||
</session-config>
|
||||
|
||||
|
||||
<!-- Facelets -->
|
||||
<servlet>
|
||||
<servlet-name>Faces Servlet</servlet-name>
|
||||
<servlet-class>jakarta.faces.webapp.FacesServlet</servlet-class>
|
||||
<load-on-startup>0</load-on-startup>
|
||||
<multipart-config>
|
||||
<location>/tmp</location>
|
||||
<!-- 20MB (20848820) *2MB (2097152)-->
|
||||
<max-file-size>2097152</max-file-size>
|
||||
<!-- 400MB (418018841) *20MB (20848820)-->
|
||||
<max-request-size>20848820</max-request-size>
|
||||
<file-size-threshold>1048576</file-size-threshold>
|
||||
</multipart-config>
|
||||
</servlet>
|
||||
<servlet-mapping>
|
||||
<servlet-name>Faces Servlet</servlet-name>
|
||||
<url-pattern>*.jsf</url-pattern>
|
||||
</servlet-mapping>
|
||||
<servlet-mapping>
|
||||
<servlet-name>Faces Servlet</servlet-name>
|
||||
<url-pattern>*.xhtml</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
|
||||
<!-- Error handling -->
|
||||
<error-page>
|
||||
<error-code>401</error-code>
|
||||
<location>/loginerror.jsf</location>
|
||||
</error-page>
|
||||
<error-page>
|
||||
<error-code>403</error-code>
|
||||
<location>/authorizationerror.jsf</location>
|
||||
</error-page>
|
||||
<error-page>
|
||||
<error-code>404</error-code>
|
||||
<location>/error.jsf</location>
|
||||
</error-page>
|
||||
<error-page>
|
||||
<error-code>500</error-code>
|
||||
<location>/error.jsf</location>
|
||||
</error-page>
|
||||
|
||||
|
||||
<!-- Login Modul - Form-Login
|
||||
<login-config>
|
||||
<auth-method>FORM</auth-method>
|
||||
<realm-name>imixsrealm</realm-name>
|
||||
<form-login-config>
|
||||
<form-login-page>/login.jsf</form-login-page>
|
||||
<form-error-page>/loginerror.jsf</form-error-page>
|
||||
</form-login-config>
|
||||
</login-config>
|
||||
-->
|
||||
<!--
|
||||
<login-config>
|
||||
<auth-method>OIDC</auth-method>
|
||||
</login-config>
|
||||
-->
|
||||
|
||||
<!-- Security Roles -->
|
||||
<security-constraint>
|
||||
<display-name>Access Manager Security Constraint</display-name>
|
||||
<web-resource-collection>
|
||||
<web-resource-name>AUTHENTICATED_RESOURCE</web-resource-name>
|
||||
<url-pattern>/pages/*</url-pattern>
|
||||
<url-pattern>/api/workflow/*</url-pattern>
|
||||
<url-pattern>/api/model/*</url-pattern>
|
||||
<url-pattern>/api/documents/*</url-pattern>
|
||||
<url-pattern>/api/report/*</url-pattern>
|
||||
<url-pattern>/api/snapshot/*</url-pattern>
|
||||
<url-pattern>/api/adminp/*</url-pattern>
|
||||
<url-pattern>/api/eventlog/*</url-pattern>
|
||||
<url-pattern>/api/logout</url-pattern>
|
||||
<!-- exclude /api/metrics and /api/wopi -->
|
||||
</web-resource-collection>
|
||||
<auth-constraint>
|
||||
<role-name>org.imixs.ACCESSLEVEL.READERACCESS</role-name>
|
||||
<role-name>org.imixs.ACCESSLEVEL.AUTHORACCESS</role-name>
|
||||
<role-name>org.imixs.ACCESSLEVEL.EDITORACCESS</role-name>
|
||||
<role-name>org.imixs.ACCESSLEVEL.MANAGERACCESS</role-name>
|
||||
</auth-constraint>
|
||||
</security-constraint>
|
||||
<security-constraint>
|
||||
<web-resource-collection>
|
||||
<web-resource-name>AUTHENTICATED_ADMIN_RESOURCE</web-resource-name>
|
||||
<url-pattern>/pages/admin/*</url-pattern>
|
||||
</web-resource-collection>
|
||||
<auth-constraint>
|
||||
<role-name>org.imixs.ACCESSLEVEL.MANAGERACCESS</role-name>
|
||||
</auth-constraint>
|
||||
</security-constraint>
|
||||
<security-role>
|
||||
<role-name>org.imixs.ACCESSLEVEL.NOACCESS</role-name>
|
||||
</security-role>
|
||||
<security-role>
|
||||
<role-name>org.imixs.ACCESSLEVEL.READERACCESS</role-name>
|
||||
</security-role>
|
||||
<security-role>
|
||||
<role-name>org.imixs.ACCESSLEVEL.AUTHORACCESS</role-name>
|
||||
</security-role>
|
||||
<security-role>
|
||||
<role-name>org.imixs.ACCESSLEVEL.EDITORACCESS</role-name>
|
||||
</security-role>
|
||||
<security-role>
|
||||
<role-name>org.imixs.ACCESSLEVEL.MANAGERACCESS</role-name>
|
||||
</security-role>
|
||||
</web-app>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.alexanderlogistics.rest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.imixs.melman.RestAPIException;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.jose.jws.JWSInputException;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
|
||||
import jakarta.ws.rs.client.ClientRequestContext;
|
||||
import jakarta.ws.rs.client.ClientRequestFilter;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
/**
|
||||
* This RequestFilter performs a keycloak authentication.
|
||||
*
|
||||
*
|
||||
*
|
||||
* The client configuration is defined in a keycloak.json file as follows:
|
||||
*
|
||||
* {
|
||||
* "realm": "imixs-office-workflow",
|
||||
* "auth-server-url" : "https://my.keycloak.host/",
|
||||
* "resource" : "the clientid",
|
||||
* "credentials": {
|
||||
* "secret": "xxx"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
*
|
||||
* @author rsoika
|
||||
*
|
||||
*/
|
||||
public class KeycloakAuthenticator implements ClientRequestFilter {
|
||||
|
||||
private String token = null;
|
||||
|
||||
public static final String KEYCLOAK_ENDPOINT = "keycloak.endpoint";
|
||||
public static final String KEYCLOAK_REALM = "keycloak.realm";
|
||||
public static final String KEYCLOAK_CLIENTID = "keycloak.clientid";
|
||||
public static final String KEYCLOAK_SECRET = "keycloak.secret";
|
||||
|
||||
private final static Logger logger = Logger.getLogger(KeycloakAuthenticator.class.getName());
|
||||
|
||||
// Keycloak
|
||||
String keycloakServer;
|
||||
String keycloakRealm;
|
||||
String keycloakClientId;
|
||||
String keycloakSecret;
|
||||
|
||||
/**
|
||||
* Creates a new FormAuthenticator based on a baseUri and a username, password.
|
||||
* The constructor post the user credentials to the endpoint /j_security_check
|
||||
* to receive a JSESSIONID.
|
||||
*
|
||||
* @param _baseUri
|
||||
* @param username
|
||||
* @param password
|
||||
* @throws RestAPIException
|
||||
*/
|
||||
public KeycloakAuthenticator(String token) throws RestAPIException {
|
||||
|
||||
this.token = token;
|
||||
|
||||
logger.info("token=" + token);
|
||||
|
||||
AccessToken accessToken;
|
||||
try {
|
||||
accessToken = new JWSInput(token).readJsonContent(AccessToken.class);
|
||||
logger.info("---email=" + accessToken.getEmail());
|
||||
|
||||
} catch (JWSInputException cause) {
|
||||
throw new IllegalArgumentException("Failed to deserialize token", cause);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* This filter method is called for each request. The method adds teh bearer
|
||||
* token into the header.
|
||||
*
|
||||
*/
|
||||
public void filter(ClientRequestContext requestContext) throws IOException {
|
||||
if (token != null && !"".equals(token)) {
|
||||
logger.info(" add authroization header....v2");
|
||||
// requestContext.getHeaders().add("Bearer", getToken());
|
||||
|
||||
// Authorization: bearer $TOKEN
|
||||
// requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, getToken());
|
||||
|
||||
requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + getToken());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.alexanderlogistics.rest;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.imixs.melman.RestAPIException;
|
||||
import org.imixs.melman.WorkflowClient;
|
||||
import org.imixs.workflow.ItemCollection;
|
||||
import org.imixs.workflow.exceptions.PluginException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.authorization.client.AuthzClient;
|
||||
import org.keycloak.authorization.client.Configuration;
|
||||
|
||||
import com.oracle.truffle.api.library.ExportMessage.Ignore;
|
||||
|
||||
import jakarta.ws.rs.client.Invocation;
|
||||
import jakarta.ws.rs.client.WebTarget;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
/**
|
||||
* Test to request data via keycloak authentication....
|
||||
*
|
||||
*
|
||||
* Curl Beispiel:
|
||||
* https://stackoverflow.com/questions/50256433/obtaining-an-access-token-via-curl
|
||||
*
|
||||
*
|
||||
* Beispiel:
|
||||
*
|
||||
* curl \
|
||||
* -d "client_id=imixs" \
|
||||
* -d "client_secret=O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V" \
|
||||
* -d "grant_type=client_credentials" \
|
||||
* "https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow/protocol/openid-connect/token"
|
||||
*
|
||||
*
|
||||
*
|
||||
* curl -X GET
|
||||
* "https://test.alexander-logistics.office-workflow.de/api/documents/xxxxxx" \
|
||||
* -H "Authorization: Bearer
|
||||
* eyJhbGciOiJSUzI1NiIsInR5cCIxxxxxxxxxxxxxlvPDoPf4l5w"
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* curl -c cookies.txt -b cookies.txt -v -H "Authorization: Bearer
|
||||
* eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJQT0g5Q0RPOUJjdGxEZFBVc3RlSzR4blVCM0VQNThKOENqaXdJb2hIUFRFIn0.eyJleHAiOjE3NDE2ODAwNDEsImlhdCI6MTc0MTY3OTc0MSwianRpIjoiMTJhZWMzZmItMDZlYy00ODY1LWI2MTAtYzBhNDczNjk1MWViIiwiaXNzIjoiaHR0cHM6Ly9zc28uYWxleGFuZGVyLWxvZ2lzdGljcy5vZmZpY2Utd29ya2Zsb3cuZGUvcmVhbG1zL2ltaXhzLW9mZmljZS13b3JrZmxvdyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiIyYjAyMTJiMy0wMTE4LTQ1ZmMtYjJiOS05ZjM4MmUyODlkMDgiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJpbWl4cyIsInNlc3Npb25fc3RhdGUiOiI2MWVlZWEwYy01ODRiLTRhMTctYjg3ZC0zNGE0NzY5Mzg3YmYiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHBzOi8vdGVzdC5hbGV4YW5kZXItbG9naXN0aWNzLm9mZmljZS13b3JrZmxvdy5kZSJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1pbWl4cy1vZmZpY2Utd29ya2Zsb3ciLCJvZmZsaW5lX2FjY2VzcyIsIm9yZy5pbWl4cy5BQ0NFU1NMRVZFTC5BVVRIT1JBQ0NFU1MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsInNpZCI6IjYxZWVlYTBjLTU4NGItNGExNy1iODdkLTM0YTQ3NjkzODdiZiIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiQW5uYSBNdXN0ZXJtYW5uIiwiZ3JvdXBzIjpbImRlZmF1bHQtcm9sZXMtaW1peHMtb2ZmaWNlLXdvcmtmbG93Iiwib2ZmbGluZV9hY2Nlc3MiLCJvcmcuaW1peHMuQUNDRVNTTEVWRUwuQVVUSE9SQUNDRVNTIiwidW1hX2F1dGhvcml6YXRpb24iXSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYW5uYSIsImdpdmVuX25hbWUiOiJBbm5hIiwiZmFtaWx5X25hbWUiOiJNdXN0ZXJtYW5uIiwiZW1haWwiOiJhbm5hQGltaXhzLmNvbSJ9.oitC9iBQSBEtZl3b65nWOSCwJ3MQjS0ES_YYJ4faL4IKZ3hN2XLFOL1kjW_guDF7Rg1k7dYm2WRAyI-QH1oXit7ANW-Z4DBpC0lbwepOsE5a5trjospYFtiLX5STO0WZuosa3b8XV7-F97Ru8Atox2eOqbJ4M-E_swnBjVxHvwLYIJXUBkQM_VXiF0acmwmcIjgolOl-mTC0NnnBClsZzbPfWxz0Ds2lc2lsQZQVjEaJ0mrMw3XCmf7rNC1LmSu6oJ8yiZ2UPMkkXDIRq0KV-hQ2sBqdfZdr4yFdbszXHLL0bDJixeS8xkBRkB7c0JSAxAgDmML1Y8Iuc7NuV6mg8Q"
|
||||
* "https://test.alexander-logistics.office-workflow.de/api/documents/4eea4e54-be93-4297-9574-3a61e826b4f4"
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* curl POST \
|
||||
* -H "Content-Type: application/x-www-form-urlencoded" \
|
||||
* -d "grant_type=password" \
|
||||
* -d "client_id=imixs" \
|
||||
* -d "client_secret=O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V" \
|
||||
* -d "username=anna" \
|
||||
* -d "password=anna" \
|
||||
* "https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow/protocol/openid-connect/token"
|
||||
*
|
||||
*
|
||||
* curl -d 'client_id=imixs' -d 'username=anna' -d 'password=anna' -d
|
||||
* 'grant_type=password' -d 'client_secret=O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V' \
|
||||
* 'https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow/protocol/openid-connect/token'
|
||||
* | \
|
||||
* python -m json.tool
|
||||
*/
|
||||
@Ignore
|
||||
public class TestKeycloakClient {
|
||||
|
||||
private static Logger logger = Logger.getLogger(TestKeycloakClient.class.getName());
|
||||
|
||||
private static final String KEYCLOAK_TOKEN_URL = "https://sso.alexander-logistics.office-workflow.de/auth/realms/imixs-office-workflow/protocol/openid-connect/token";
|
||||
private static final String CLIENT_ID = "imixs";
|
||||
private static final String CLIENT_SECRET = "O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V";
|
||||
private static final String USERNAME = "anna";
|
||||
private static final String PASSWORD = "anna";
|
||||
|
||||
private String token = null;
|
||||
|
||||
@Before
|
||||
public void setup() throws PluginException {
|
||||
try {
|
||||
final Configuration configuration = new Configuration(
|
||||
"https://sso.alexander-logistics.office-workflow.de/",
|
||||
"imixs-office-workflow", // keycloak realm
|
||||
"imixs", // keycloak client
|
||||
Collections.singletonMap("secret", "O8YjWcoW2nxb7jIgBePBKMvoDLUruZ3V"),
|
||||
HttpClients.createDefault());
|
||||
|
||||
token = AuthzClient.create(configuration).obtainAccessToken("anna", "anna").getToken();
|
||||
} catch (Exception e) {
|
||||
System.out.println("Error: " + e.getMessage());
|
||||
|
||||
// "Could not obtain configuration from server
|
||||
// [https://sso.alexander-logistics.office-workflow.de/auth/realms/imixs-office-workflow/.well-known/uma2-configuration]."
|
||||
|
||||
// https://sso.alexander-logistics.office-workflow.de/realms/imixs-office-workflow/.well-known/openid-configuration
|
||||
|
||||
throw new IllegalArgumentException("Token can't be obtained", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test token....
|
||||
*
|
||||
*/
|
||||
@Test
|
||||
public void testToken() {
|
||||
|
||||
System.out.println("Token = " + token);
|
||||
|
||||
String instanceEndpoint = "https://test.alexander-logistics.office-workflow.de/api/";
|
||||
|
||||
String requestUri = "documents/6df8a656-9188-48a3-a0e0-c3a6274a586a";
|
||||
|
||||
WorkflowClient documentClient = new WorkflowClient(instanceEndpoint);
|
||||
|
||||
KeycloakAuthenticator keycloakAuth;
|
||||
try {
|
||||
keycloakAuth = new KeycloakAuthenticator(token);
|
||||
|
||||
documentClient.registerClientRequestFilter(keycloakAuth);
|
||||
|
||||
WebTarget target = documentClient.getWebTarget(requestUri);
|
||||
|
||||
// Erstelle eine Invocation von der WebTarget
|
||||
Invocation.Builder invocationBuilder = target.request();
|
||||
Invocation invocation = invocationBuilder.buildGet();
|
||||
|
||||
// Sende die Anfrage und erhalte die Antwort
|
||||
Response response = invocation.invoke();
|
||||
|
||||
// Überprüfe den Status der Antwort
|
||||
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
|
||||
// Extrahiere den Inhalt der Antwort als String
|
||||
String responseBody = response.readEntity(String.class);
|
||||
|
||||
// Gib den Inhalt aus (z.B. auf der Konsole)
|
||||
System.out.println("Response Body: " + responseBody);
|
||||
} else {
|
||||
System.err.println("Fehler bei der Anfrage: " + response.getStatus());
|
||||
}
|
||||
|
||||
// Schließe die Response, um Ressourcen freizugeben
|
||||
response.close();
|
||||
|
||||
List<ItemCollection> result = documentClient.getCustomResource(requestUri);
|
||||
|
||||
logger.info(" result size = " + result.size());
|
||||
} catch (RestAPIException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
Assert.fail();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue