2021-04-30 10:15:03 +02:00
|
|
|
|
"""This module handles requests originating from the ussd service provider.
|
2021-02-06 16:13:47 +01:00
|
|
|
|
"""
|
2021-04-30 10:15:03 +02:00
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
# standard imports
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
2021-05-11 12:58:00 +02:00
|
|
|
|
from urllib.parse import parse_qs
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
|
|
|
|
# third-party imports
|
2021-04-30 10:15:03 +02:00
|
|
|
|
import celery
|
|
|
|
|
import i18n
|
|
|
|
|
import redis
|
2021-03-04 17:47:13 +01:00
|
|
|
|
from chainlib.chain import ChainSpec
|
2021-04-30 10:15:03 +02:00
|
|
|
|
from confini import Config
|
2021-10-18 05:52:28 +02:00
|
|
|
|
from cic_types.ext.metadata import Metadata
|
2021-10-17 18:32:08 +02:00
|
|
|
|
from cic_types.ext.metadata.signer import Signer
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
|
|
|
|
# local imports
|
2021-08-06 18:29:01 +02:00
|
|
|
|
from cic_ussd.account.chain import Chain
|
|
|
|
|
from cic_ussd.account.tokens import query_default_token
|
|
|
|
|
from cic_ussd.cache import cache_data, cache_data_key, Cache
|
2021-02-06 16:13:47 +01:00
|
|
|
|
from cic_ussd.db import dsn_from_config
|
|
|
|
|
from cic_ussd.db.models.base import SessionBase
|
|
|
|
|
from cic_ussd.encoder import PasswordEncoder
|
2021-05-01 16:14:20 +02:00
|
|
|
|
from cic_ussd.error import InitializationError
|
2021-02-06 16:13:47 +01:00
|
|
|
|
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser
|
2021-08-06 18:29:01 +02:00
|
|
|
|
from cic_ussd.http.requests import get_request_endpoint, get_request_method
|
|
|
|
|
from cic_ussd.http.responses import with_content_headers
|
2021-02-06 16:13:47 +01:00
|
|
|
|
from cic_ussd.menu.ussd_menu import UssdMenu
|
2021-06-23 08:44:01 +02:00
|
|
|
|
from cic_ussd.phone_number import process_phone_number, Support, E164Format
|
2021-08-06 18:29:01 +02:00
|
|
|
|
from cic_ussd.processor.ussd import handle_menu_operations
|
2021-04-30 10:15:03 +02:00
|
|
|
|
from cic_ussd.runnable.server_base import exportable_parser, logg
|
2021-02-06 16:13:47 +01:00
|
|
|
|
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
|
|
|
|
from cic_ussd.state_machine import UssdStateMachine
|
2021-08-06 18:29:01 +02:00
|
|
|
|
from cic_ussd.translation import translation_for
|
2021-05-11 12:58:00 +02:00
|
|
|
|
from cic_ussd.validator import check_ip, check_request_content_length, validate_phone_number, validate_presence
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-04-30 10:15:03 +02:00
|
|
|
|
args = exportable_parser.parse_args()
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
|
|
|
|
# define log levels
|
|
|
|
|
if args.vv:
|
|
|
|
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
|
|
|
elif args.v:
|
|
|
|
|
logging.getLogger().setLevel(logging.INFO)
|
|
|
|
|
|
2021-04-16 22:24:07 +02:00
|
|
|
|
# parse config
|
2021-07-15 00:54:48 +02:00
|
|
|
|
config = Config(args.c, env_prefix=args.env_prefix)
|
2021-04-16 22:24:07 +02:00
|
|
|
|
config.process()
|
|
|
|
|
config.censor('PASSWORD', 'DATABASE')
|
|
|
|
|
logg.debug('config loaded from {}:\n{}'.format(args.c, config))
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-04-30 10:15:03 +02:00
|
|
|
|
# set up db
|
|
|
|
|
data_source_name = dsn_from_config(config)
|
|
|
|
|
SessionBase.connect(data_source_name,
|
|
|
|
|
pool_size=int(config.get('DATABASE_POOL_SIZE')),
|
|
|
|
|
debug=config.true('DATABASE_DEBUG'))
|
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
# set up translations
|
2021-08-06 18:29:01 +02:00
|
|
|
|
i18n.load_path.append(config.get('LOCALE_PATH'))
|
|
|
|
|
i18n.set('fallback', config.get('LOCALE_FALLBACK'))
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
|
|
|
|
# set Fernet key
|
|
|
|
|
PasswordEncoder.set_key(config.get('APP_PASSWORD_PEPPER'))
|
|
|
|
|
|
|
|
|
|
# create in-memory databases
|
|
|
|
|
ussd_menu_db = create_local_file_data_stores(file_location=config.get('USSD_MENU_FILE'),
|
|
|
|
|
table_name='ussd_menu')
|
|
|
|
|
UssdMenu.ussd_menu_db = ussd_menu_db
|
|
|
|
|
|
|
|
|
|
# define universal redis cache access
|
2021-08-06 18:29:01 +02:00
|
|
|
|
Cache.store = redis.StrictRedis(host=config.get('REDIS_HOST'),
|
|
|
|
|
port=config.get('REDIS_PORT'),
|
|
|
|
|
password=config.get('REDIS_PASSWORD'),
|
|
|
|
|
db=config.get('REDIS_DATABASE'),
|
|
|
|
|
decode_responses=True)
|
|
|
|
|
InMemoryUssdSession.store = Cache.store
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-03-04 17:47:13 +01:00
|
|
|
|
# define metadata URL
|
2021-04-09 15:00:15 +02:00
|
|
|
|
Metadata.base_url = config.get('CIC_META_URL')
|
2021-03-04 17:47:13 +01:00
|
|
|
|
|
|
|
|
|
# define signer values
|
2021-04-06 19:53:38 +02:00
|
|
|
|
export_dir = config.get('PGP_EXPORT_DIR')
|
|
|
|
|
if export_dir:
|
|
|
|
|
validate_presence(path=export_dir)
|
|
|
|
|
Signer.gpg_path = export_dir
|
2021-03-05 17:28:07 +01:00
|
|
|
|
Signer.gpg_passphrase = config.get('PGP_PASSPHRASE')
|
2021-04-06 19:53:38 +02:00
|
|
|
|
key_file_path = f"{config.get('PGP_KEYS_PATH')}{config.get('PGP_PRIVATE_KEYS')}"
|
|
|
|
|
if key_file_path:
|
|
|
|
|
validate_presence(path=key_file_path)
|
|
|
|
|
Signer.key_file_path = key_file_path
|
2021-03-04 17:47:13 +01:00
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
# initialize celery app
|
|
|
|
|
celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
|
|
|
|
|
|
|
|
|
|
# load states and transitions data
|
2021-08-06 18:29:01 +02:00
|
|
|
|
states = json_file_parser(filepath=config.get('MACHINE_STATES'))
|
|
|
|
|
transitions = json_file_parser(filepath=config.get('MACHINE_TRANSITIONS'))
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-03-04 17:47:13 +01:00
|
|
|
|
chain_spec = ChainSpec(
|
|
|
|
|
common_name=config.get('CIC_COMMON_NAME'),
|
|
|
|
|
engine=config.get('CIC_ENGINE'),
|
|
|
|
|
network_id=config.get('CIC_NETWORK_ID')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Chain.spec = chain_spec
|
2021-02-06 16:13:47 +01:00
|
|
|
|
UssdStateMachine.states = states
|
|
|
|
|
UssdStateMachine.transitions = transitions
|
|
|
|
|
|
2021-05-01 16:14:20 +02:00
|
|
|
|
# retrieve default token data
|
|
|
|
|
chain_str = Chain.spec.__str__()
|
2021-08-06 18:29:01 +02:00
|
|
|
|
default_token_data = query_default_token(chain_str)
|
|
|
|
|
|
2021-05-01 16:14:20 +02:00
|
|
|
|
|
|
|
|
|
# cache default token for re-usability
|
|
|
|
|
if default_token_data:
|
2021-08-06 18:29:01 +02:00
|
|
|
|
cache_key = cache_data_key(chain_str.encode('utf-8'), ':cic.default_token_data')
|
2021-05-01 16:14:20 +02:00
|
|
|
|
cache_data(key=cache_key, data=json.dumps(default_token_data))
|
|
|
|
|
else:
|
|
|
|
|
raise InitializationError(f'Default token data for: {chain_str} not found.')
|
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-08-06 18:29:01 +02:00
|
|
|
|
valid_service_codes = config.get('USSD_SERVICE_CODE').split(",")
|
2021-05-11 12:58:00 +02:00
|
|
|
|
|
2021-08-06 18:29:01 +02:00
|
|
|
|
E164Format.region = config.get('E164_REGION')
|
|
|
|
|
Support.phone_number = config.get('OFFICE_SUPPORT_PHONE')
|
2021-06-07 10:02:03 +02:00
|
|
|
|
|
2021-05-11 12:58:00 +02:00
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
def application(env, start_response):
|
|
|
|
|
"""Loads python code for application to be accessible over web server
|
|
|
|
|
:param env: Object containing server and request information
|
|
|
|
|
:type env: dict
|
|
|
|
|
:param start_response: Callable to define responses.
|
|
|
|
|
:type start_response: any
|
2021-04-30 10:15:03 +02:00
|
|
|
|
:return: a list containing a bytes representation of the response object
|
|
|
|
|
:rtype: list
|
2021-02-06 16:13:47 +01:00
|
|
|
|
"""
|
|
|
|
|
# define headers
|
|
|
|
|
errors_headers = [('Content-Type', 'text/plain'), ('Content-Length', '0')]
|
|
|
|
|
headers = [('Content-Type', 'text/plain')]
|
|
|
|
|
|
2021-06-29 12:49:25 +02:00
|
|
|
|
# create session for the life time of http request
|
|
|
|
|
session = SessionBase.create_session()
|
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
if get_request_method(env=env) == 'POST' and get_request_endpoint(env=env) == '/':
|
|
|
|
|
|
2021-05-11 12:58:00 +02:00
|
|
|
|
if env.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
|
2021-06-23 06:29:38 +02:00
|
|
|
|
start_response('405 Urlencoded, please', errors_headers)
|
2021-05-11 12:58:00 +02:00
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
post_data = env.get('wsgi.input').read()
|
|
|
|
|
post_data = post_data.decode('utf-8')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
post_data = parse_qs(post_data)
|
|
|
|
|
except TypeError:
|
|
|
|
|
start_response('400 Size matters', errors_headers)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
service_code = post_data.get('serviceCode')[0]
|
|
|
|
|
phone_number = post_data.get('phoneNumber')[0]
|
|
|
|
|
external_session_id = post_data.get('sessionId')[0]
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-05-11 12:58:00 +02:00
|
|
|
|
try:
|
|
|
|
|
user_input = post_data.get('text')[0]
|
|
|
|
|
except TypeError:
|
|
|
|
|
user_input = ""
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-04-09 15:00:15 +02:00
|
|
|
|
if phone_number:
|
2021-06-23 08:44:01 +02:00
|
|
|
|
phone_number = process_phone_number(phone_number=phone_number, region=E164Format.region)
|
2021-04-09 15:00:15 +02:00
|
|
|
|
|
2021-02-06 16:13:47 +01:00
|
|
|
|
if not check_ip(config=config, env=env):
|
|
|
|
|
start_response('403 Sneaky, sneaky', errors_headers)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
if not check_request_content_length(config=config, env=env):
|
|
|
|
|
start_response('400 Size matters', errors_headers)
|
|
|
|
|
return []
|
|
|
|
|
|
2021-05-11 12:58:00 +02:00
|
|
|
|
if service_code not in valid_service_codes:
|
2021-08-06 18:29:01 +02:00
|
|
|
|
response = translation_for(
|
|
|
|
|
'ussd.kenya.invalid_service_code',
|
|
|
|
|
i18n.config.get('fallback'),
|
|
|
|
|
valid_service_code=valid_service_codes[0]
|
|
|
|
|
)
|
|
|
|
|
response_bytes, headers = with_content_headers(headers, response)
|
2021-05-11 12:58:00 +02:00
|
|
|
|
start_response('200 OK', headers)
|
2021-02-06 16:13:47 +01:00
|
|
|
|
return [response_bytes]
|
|
|
|
|
|
|
|
|
|
if not validate_phone_number(phone_number):
|
2021-04-09 15:00:15 +02:00
|
|
|
|
logg.error('invalid phone number {}'.format(phone_number))
|
2021-02-06 16:13:47 +01:00
|
|
|
|
start_response('400 Invalid phone number format', errors_headers)
|
|
|
|
|
return []
|
2021-04-09 15:00:15 +02:00
|
|
|
|
logg.debug('session {} started for {}'.format(external_session_id, phone_number))
|
2021-02-06 16:13:47 +01:00
|
|
|
|
|
2021-08-06 18:29:01 +02:00
|
|
|
|
response = handle_menu_operations(
|
|
|
|
|
chain_str, external_session_id, phone_number, args.q, service_code, session, user_input
|
|
|
|
|
)
|
|
|
|
|
response_bytes, headers = with_content_headers(headers, response)
|
2021-02-06 16:13:47 +01:00
|
|
|
|
start_response('200 OK,', headers)
|
2021-06-29 12:49:25 +02:00
|
|
|
|
session.commit()
|
|
|
|
|
session.close()
|
2021-02-06 16:13:47 +01:00
|
|
|
|
return [response_bytes]
|
2021-05-11 12:58:00 +02:00
|
|
|
|
|
|
|
|
|
else:
|
2021-06-23 06:29:38 +02:00
|
|
|
|
logg.error('invalid query {}'.format(env))
|
|
|
|
|
for r in env:
|
|
|
|
|
logg.debug('{}: {}'.format(r, env))
|
2021-06-29 12:49:25 +02:00
|
|
|
|
session.close()
|
2021-05-11 12:58:00 +02:00
|
|
|
|
start_response('405 Play by the rules', errors_headers)
|
|
|
|
|
return []
|