Reorder config imports so that instance config is dead last - and can override everything else.
Updated docker-compose for running a demo. run_pyl fixes
This commit is contained in:
parent
b9fbedc63c
commit
f01cd57d24
|
@ -6,46 +6,46 @@ services:
|
|||
platform: linux/amd64
|
||||
cap_add:
|
||||
- SYS_NICE
|
||||
restart: "${SPIFFWORKFLOW_BACKEND_DATABASE_DOCKER_RESTART_POLICY:-no}"
|
||||
restart: "no"
|
||||
environment:
|
||||
- MYSQL_DATABASE=${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development}
|
||||
- MYSQL_ROOT_PASSWORD=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw}
|
||||
- MYSQL_DATABASE=spiffworkflow_backend_development
|
||||
- MYSQL_ROOT_PASSWORD=my-secret-pw
|
||||
- MYSQL_TCP_PORT=7003
|
||||
ports:
|
||||
- "7003"
|
||||
volumes:
|
||||
- spiffworkflow_backend:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: mysql --user=root --password=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw} -e 'select 1' ${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development}
|
||||
test: mysql --user=root --password=my-secret-pw -e 'select 1' spiffworkflow_backend_development
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
spiffworkflow-backend:
|
||||
container_name: spiffworkflow-backend
|
||||
image: ghcr.io/sartography/spiffworkflow-backend
|
||||
image: ghcr.io/sartography/spiffworkflow-backend:latest
|
||||
depends_on:
|
||||
spiffworkflow-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- APPLICATION_ROOT=/
|
||||
- SPIFFWORKFLOW_BACKEND_ENV=${SPIFFWORKFLOW_BACKEND_ENV:-development}
|
||||
- SPIFFWORKFLOW_BACKEND_ENV=development
|
||||
- FLASK_DEBUG=0
|
||||
- FLASK_SESSION_SECRET_KEY=${FLASK_SESSION_SECRET_KEY:-super_secret_key}
|
||||
- OPEN_ID_SERVER_URL=${OPEN_ID_SERVER_URL:-http://spiffworkflow-openid:7002}
|
||||
- SPIFFWORKFLOW_FRONTEND_URL=${SPIFFWORKFLOW_FRONTEND_URL:-http://spiffworkflow-frontend:7001}
|
||||
- SPIFFWORKFLOW_BACKEND_URL=${SPIFFWORKFLOW_BACKEND_URL:-http://spiffworkflow-backend:7000}
|
||||
- FLASK_SESSION_SECRET_KEY=super_secret_key
|
||||
- OPEN_ID_SERVER_URL=http://localhost:7000/openid
|
||||
- SPIFFWORKFLOW_FRONTEND_URL=http://localhost:7001
|
||||
- SPIFFWORKFLOW_BACKEND_URL=http://localhost:7000
|
||||
- SPIFFWORKFLOW_BACKEND_PORT=7000
|
||||
- SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true
|
||||
- SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw}@spiffworkflow-db:7003/${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development}
|
||||
- SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:my-secret-pw@spiffworkflow-db:7003/spiffworkflow_backend_development
|
||||
- BPMN_SPEC_ABSOLUTE_DIR=/app/process_models
|
||||
- SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=${SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA:-false}
|
||||
- SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=${SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME:-acceptance_tests.yml}
|
||||
- SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=false
|
||||
- SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=demo.yml
|
||||
- RUN_BACKGROUND_SCHEDULER=true
|
||||
- OPEN_ID_CLIENT_ID=spiffworkflow-backend
|
||||
- OPEN_ID_CLIENT_SECRET_KEY=my_open_id_secret_key
|
||||
ports:
|
||||
- "7000:7000"
|
||||
volumes:
|
||||
- ${BPMN_SPEC_ABSOLUTE_DIR:-./../sample-process-models}:/app/process_models
|
||||
- ./process_models:/app/process_models
|
||||
- ./log:/app/log
|
||||
healthcheck:
|
||||
test: curl localhost:7000/v1.0/status --fail
|
||||
|
|
|
@ -19,7 +19,9 @@ from werkzeug.exceptions import NotFound
|
|||
import spiffworkflow_backend.load_database_models # noqa: F401
|
||||
from spiffworkflow_backend.config import setup_config
|
||||
from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint
|
||||
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint
|
||||
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import (
|
||||
openid_blueprint,
|
||||
)
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import process_api_blueprint
|
||||
from spiffworkflow_backend.routes.user import verify_token
|
||||
from spiffworkflow_backend.routes.user_blueprint import user_blueprint
|
||||
|
|
|
@ -52,12 +52,6 @@ def setup_config(app: Flask) -> None:
|
|||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config.from_object("spiffworkflow_backend.config.default")
|
||||
|
||||
# This allows config/testing.py or instance/config.py to override the default config
|
||||
if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing":
|
||||
app.config.from_pyfile("config/testing.py", silent=True)
|
||||
else:
|
||||
app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True)
|
||||
|
||||
env_config_prefix = "spiffworkflow_backend.config."
|
||||
env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"]
|
||||
try:
|
||||
|
@ -73,6 +67,12 @@ def setup_config(app: Flask) -> None:
|
|||
f"Cannot find config module: {env_config_module}"
|
||||
) from exception
|
||||
|
||||
# This allows config/testing.py or instance/config.py to override the default config
|
||||
if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing":
|
||||
app.config.from_pyfile("config/testing.py", silent=True)
|
||||
else:
|
||||
app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True)
|
||||
|
||||
setup_database_uri(app)
|
||||
setup_logger(app)
|
||||
|
||||
|
|
|
@ -30,7 +30,11 @@ CONNECTOR_PROXY_URL = environ.get(
|
|||
GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true"
|
||||
|
||||
# Open ID server
|
||||
OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow")
|
||||
OPEN_ID_SERVER_URL = environ.get(
|
||||
"OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow"
|
||||
)
|
||||
# Replace above line with this to use the built-in Open ID Server.
|
||||
# OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7000/openid")
|
||||
OPEN_ID_CLIENT_ID = environ.get("OPEN_ID_CLIENT_ID", default="spiffworkflow-backend")
|
||||
OPEN_ID_CLIENT_SECRET_KEY = environ.get(
|
||||
"OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"
|
||||
|
|
|
@ -30,7 +30,8 @@ class UserModel(SpiffworkflowBaseDBModel):
|
|||
__table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(255), nullable=False, unique=False) # server and service id must be unique, not username.
|
||||
# server and service id must be unique, not username.
|
||||
username = db.Column(db.String(255), nullable=False, unique=False)
|
||||
uid = db.Column(db.String(50), unique=True)
|
||||
service = db.Column(db.String(50), nullable=False, unique=False)
|
||||
service_id = db.Column(db.String(255), nullable=False, unique=False)
|
||||
|
|
|
@ -9,7 +9,12 @@ from urllib.parse import urlencode
|
|||
|
||||
import jwt
|
||||
import yaml
|
||||
from flask import Blueprint, render_template, request, current_app, redirect, url_for
|
||||
from flask import Blueprint
|
||||
from flask import current_app
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import url_for
|
||||
|
||||
openid_blueprint = Blueprint(
|
||||
"openid", __name__, template_folder="templates", static_folder="static"
|
||||
|
@ -21,8 +26,8 @@ MY_SECRET_CODE = ":this_is_not_secure_do_not_use_in_production"
|
|||
@openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"])
|
||||
def well_known():
|
||||
"""OpenID Discovery endpoint -- as these urls can be very different from system to system,
|
||||
this is just a small subset."""
|
||||
host_url = request.host_url.strip('/')
|
||||
this is just a small subset."""
|
||||
host_url = request.host_url.strip("/")
|
||||
return {
|
||||
"issuer": f"{host_url}/openid",
|
||||
"authorization_endpoint": f"{host_url}{url_for('openid.auth')}",
|
||||
|
@ -34,82 +39,90 @@ def well_known():
|
|||
@openid_blueprint.route("/auth", methods=["GET"])
|
||||
def auth():
|
||||
"""Accepts a series of parameters"""
|
||||
return render_template('login.html',
|
||||
state=request.args.get('state'),
|
||||
response_type=request.args.get('response_type'),
|
||||
client_id=request.args.get('client_id'),
|
||||
scope=request.args.get('scope'),
|
||||
redirect_uri=request.args.get('redirect_uri'),
|
||||
error_message=request.args.get('error_message', ''))
|
||||
return render_template(
|
||||
"login.html",
|
||||
state=request.args.get("state"),
|
||||
response_type=request.args.get("response_type"),
|
||||
client_id=request.args.get("client_id"),
|
||||
scope=request.args.get("scope"),
|
||||
redirect_uri=request.args.get("redirect_uri"),
|
||||
error_message=request.args.get("error_message", ""),
|
||||
)
|
||||
|
||||
|
||||
@openid_blueprint.route("/form_submit", methods=["POST"])
|
||||
def form_submit():
|
||||
users = get_users()
|
||||
if request.values['Uname'] in users and request.values['Pass'] == users[request.values['Uname']]["password"]:
|
||||
if (
|
||||
request.values["Uname"] in users
|
||||
and request.values["Pass"] == users[request.values["Uname"]]["password"]
|
||||
):
|
||||
# Redirect back to the end user with some detailed information
|
||||
state = request.values.get('state')
|
||||
state = request.values.get("state")
|
||||
data = {
|
||||
"state": state,
|
||||
"code": request.values['Uname'] + MY_SECRET_CODE,
|
||||
"session_state": ""
|
||||
"code": request.values["Uname"] + MY_SECRET_CODE,
|
||||
"session_state": "",
|
||||
}
|
||||
url = request.values.get('redirect_uri') + "?" + urlencode(data)
|
||||
url = request.values.get("redirect_uri") + "?" + urlencode(data)
|
||||
return redirect(url)
|
||||
else:
|
||||
return render_template('login.html',
|
||||
state=request.values.get('state'),
|
||||
response_type=request.values.get('response_type'),
|
||||
client_id=request.values.get('client_id'),
|
||||
scope=request.values.get('scope'),
|
||||
redirect_uri=request.values.get('redirect_uri'),
|
||||
error_message="Login failed. Please try again.")
|
||||
return render_template(
|
||||
"login.html",
|
||||
state=request.values.get("state"),
|
||||
response_type=request.values.get("response_type"),
|
||||
client_id=request.values.get("client_id"),
|
||||
scope=request.values.get("scope"),
|
||||
redirect_uri=request.values.get("redirect_uri"),
|
||||
error_message="Login failed. Please try again.",
|
||||
)
|
||||
|
||||
|
||||
@openid_blueprint.route("/token", methods=["POST"])
|
||||
def token():
|
||||
"""Url that will return a valid token, given the super secret sauce"""
|
||||
grant_type = request.values.get('grant_type')
|
||||
code = request.values.get('code')
|
||||
redirect_uri = request.values.get('redirect_uri')
|
||||
request.values.get("grant_type")
|
||||
code = request.values.get("code")
|
||||
request.values.get("redirect_uri")
|
||||
|
||||
"""We just stuffed the user name on the front of the code, so grab it."""
|
||||
user_name, secret_hash = code.split(":")
|
||||
user_details = get_users()[user_name]
|
||||
|
||||
"""Get authentication from headers."""
|
||||
authorization = request.headers.get('Authorization')
|
||||
authorization = request.headers.get("Authorization")
|
||||
authorization = authorization[6:] # Remove "Basic"
|
||||
authorization = base64.b64decode(authorization).decode('utf-8')
|
||||
authorization = base64.b64decode(authorization).decode("utf-8")
|
||||
client_id, client_secret = authorization.split(":")
|
||||
|
||||
base_url = request.host_url + "openid"
|
||||
access_token = user_name + ":" + "always_good_demo_access_token"
|
||||
refresh_token = user_name + ":" + "always_good_demo_refresh_token"
|
||||
|
||||
id_token = jwt.encode({
|
||||
"iss": base_url,
|
||||
"aud": [client_id, "account"],
|
||||
"iat": time.time(),
|
||||
"exp": time.time() + 86400, # Expire after a day.
|
||||
"sub": user_name,
|
||||
"preferred_username": user_details.get('preferred_username', user_name)
|
||||
},
|
||||
id_token = jwt.encode(
|
||||
{
|
||||
"iss": base_url,
|
||||
"aud": [client_id, "account"],
|
||||
"iat": time.time(),
|
||||
"exp": time.time() + 86400, # Expire after a day.
|
||||
"sub": user_name,
|
||||
"preferred_username": user_details.get("preferred_username", user_name),
|
||||
},
|
||||
client_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
response = {
|
||||
"access_token": id_token,
|
||||
"id_token": id_token,
|
||||
"refresh_token": id_token
|
||||
"refresh_token": id_token,
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@openid_blueprint.route("/end_session", methods=["GET"])
|
||||
def end_session():
|
||||
redirect_url = request.args.get('post_logout_redirect_uri')
|
||||
id_token_hint = request.args.get('id_token_hint')
|
||||
redirect_url = request.args.get("post_logout_redirect_uri")
|
||||
request.args.get("id_token_hint")
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
|
|
|
@ -33,4 +33,4 @@
|
|||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -15,12 +15,9 @@ from flask import request
|
|||
from flask_bpmn.api.api_error import ApiError
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from spiffworkflow_backend.models.group import GroupModel
|
||||
from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel
|
||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.services.authentication_service import (
|
||||
AuthenticationService, AuthenticationProviderTypes,
|
||||
AuthenticationService,
|
||||
)
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.user_service import UserService
|
||||
|
@ -91,7 +88,7 @@ def verify_token(
|
|||
if auth_token and "error" not in auth_token:
|
||||
# We have the user, but this code is a bit convoluted, and will later demand
|
||||
# a user_info object so it can look up the user. Sorry to leave this crap here.
|
||||
user_info = {"sub": user.service_id }
|
||||
user_info = {"sub": user.service_id}
|
||||
else:
|
||||
raise ae
|
||||
else:
|
||||
|
@ -200,16 +197,18 @@ def login(redirect_url: str = "/") -> Response:
|
|||
)
|
||||
return redirect(login_redirect_url)
|
||||
|
||||
|
||||
def parse_id_token(token: str) -> dict:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise Exception("Incorrect id token format")
|
||||
|
||||
payload = parts[1]
|
||||
padded = payload + '=' * (4 - len(payload) % 4)
|
||||
padded = payload + "=" * (4 - len(payload) % 4)
|
||||
decoded = base64.b64decode(padded)
|
||||
return json.loads(decoded)
|
||||
|
||||
|
||||
def login_return(code: str, state: str, session_state: str) -> Optional[Response]:
|
||||
"""Login_return."""
|
||||
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
|
||||
|
|
|
@ -3,7 +3,6 @@ import base64
|
|||
import enum
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
|
@ -26,7 +25,10 @@ class AuthenticationProviderTypes(enum.Enum):
|
|||
|
||||
class AuthenticationService:
|
||||
"""AuthenticationService."""
|
||||
ENDPOINT_CACHE = {} # We only need to find the openid endpoints once, then we can cache them.
|
||||
|
||||
ENDPOINT_CACHE = (
|
||||
{}
|
||||
) # We only need to find the openid endpoints once, then we can cache them.
|
||||
|
||||
@staticmethod
|
||||
def client_id():
|
||||
|
@ -40,7 +42,6 @@ class AuthenticationService:
|
|||
def secret_key():
|
||||
return current_app.config["OPEN_ID_CLIENT_SECRET_KEY"]
|
||||
|
||||
|
||||
@classmethod
|
||||
def open_id_endpoint_for_name(cls, name: str) -> None:
|
||||
"""All openid systems provide a mapping of static names to the full path of that endpoint."""
|
||||
|
|
|
@ -6,9 +6,10 @@ from typing import Union
|
|||
|
||||
import jwt
|
||||
import yaml
|
||||
from flask import current_app, scaffold
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from flask import request
|
||||
from flask import scaffold
|
||||
from flask_bpmn.api.api_error import ApiError
|
||||
from flask_bpmn.models.db import db
|
||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||
|
@ -127,7 +128,6 @@ class AuthorizationService:
|
|||
db.session.add(user_group_assignemnt)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@classmethod
|
||||
def import_permissions_from_yaml_file(
|
||||
cls, raise_if_missing_user: bool = False
|
||||
|
@ -448,7 +448,6 @@ class AuthorizationService:
|
|||
email=email,
|
||||
)
|
||||
|
||||
|
||||
# this may eventually get too slow.
|
||||
# when it does, be careful about backgrounding, because
|
||||
# the user will immediately need permissions to use the site.
|
||||
|
|
|
@ -1,59 +1,58 @@
|
|||
"""Test_authentication."""
|
||||
import ast
|
||||
import base64
|
||||
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||
|
||||
from spiffworkflow_backend.services.authentication_service import (
|
||||
AuthenticationService,
|
||||
)
|
||||
|
||||
|
||||
class TestFaskOpenId(BaseTest):
|
||||
"""An integrated Open ID that responds to openID requests
|
||||
by referencing a build in YAML file. Useful for
|
||||
local development, testing, demos etc..."""
|
||||
by referencing a build in YAML file. Useful for
|
||||
local development, testing, demos etc..."""
|
||||
|
||||
def test_discovery_of_endpoints(self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,) -> None:
|
||||
def test_discovery_of_endpoints(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
) -> None:
|
||||
response = client.get("/openid/.well-known/openid-configuration")
|
||||
discovered_urls = response.json
|
||||
assert "http://localhost/openid" == discovered_urls["issuer"]
|
||||
assert "http://localhost/openid/auth" == discovered_urls["authorization_endpoint"]
|
||||
assert (
|
||||
"http://localhost/openid/auth" == discovered_urls["authorization_endpoint"]
|
||||
)
|
||||
assert "http://localhost/openid/token" == discovered_urls["token_endpoint"]
|
||||
|
||||
def test_get_login_page(self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,) -> None:
|
||||
def test_get_login_page(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
) -> None:
|
||||
# It should be possible to get to a login page
|
||||
data = {
|
||||
"state": {"bubblegum":1, "daydream":2}
|
||||
}
|
||||
data = {"state": {"bubblegum": 1, "daydream": 2}}
|
||||
response = client.get("/openid/auth", query_string=data)
|
||||
assert b"<h2>Login to SpiffWorkflow</h2>" in response.data
|
||||
assert b"bubblegum" in response.data
|
||||
|
||||
def test_get_token(self,
|
||||
def test_get_token(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,) -> None:
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
) -> None:
|
||||
|
||||
code = "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx"
|
||||
code = (
|
||||
"c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx"
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": f"Basic {code}",
|
||||
}
|
||||
data = {
|
||||
"grant_type": 'authorization_code',
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_url": 'http://localhost:7000/v1.0/login_return'
|
||||
"redirect_url": "http://localhost:7000/v1.0/login_return",
|
||||
}
|
||||
response = client.post("/openid/token", data=data, headers=headers)
|
||||
assert response
|
||||
|
||||
|
|
Loading…
Reference in New Issue