├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── app ├── __init__.py └── api_1_0 │ ├── __init__.py │ ├── authentication.py │ ├── errors.py │ ├── smstools.py │ └── views.py ├── config.py ├── log └── .gitignore ├── manage.py ├── requirements.txt ├── test.py └── uwsgi.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | /venv/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.travis-ci.com/user/languages/python 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | 9 | before_install: 10 | - mkdir outgoing 11 | - mkdir sent 12 | - echo 'test:$apr1$oOXGqDEk$G68MGAQ1eqsBEmL.ZrSbq0' > htpasswd.users 13 | 14 | # command to install dependencies 15 | install: "pip install -r requirements.txt" 16 | 17 | # command to run tests 18 | script: 19 | - ./test.py 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Leonid Vasiliev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | smstools-http-api 2 | ================= 3 | 4 | REST HTTP API with Flask for SMS Server Tools 3 (http://smstools3.kekekasvi.com/) 5 | 6 | 7 | Installation 8 | ------------ 9 | 10 | After cloning, create a virtual environment and install the requirements. For Linux and Mac users: 11 | 12 | $ virtualenv venv 13 | $ source venv/bin/activate 14 | (venv) $ pip install -r requirements.txt 15 | 16 | If you are on Windows, then use the following commands instead: 17 | 18 | $ virtualenv venv 19 | $ venv\Scripts\activate 20 | (venv) $ pip install -r requirements.txt 21 | 22 | Running 23 | ------- 24 | 25 | To run the server use the following command: 26 | 27 | (venv) $ python manage.py runserver --host 127.0.0.1 -r 28 | * Running on http://127.0.0.1:5000/ 29 | * Restarting with reloader 30 | 31 | Then from a different terminal window you can send requests. 32 | 33 | If you decide to run this application under a different user than `smsd`, you need to adjust the `outgoing` spooler directory to retain proper permissions for newly created files or `smsd` won't be able to process the messages. 34 | 35 | # Debian, Ubuntu 36 | chmod g+s /var/spool/sms/outgoing 37 | setfacl -m d:g:smsd:rwX /var/spool/sms/outgoing 38 | 39 | # Fedora, RHEL 40 | chmod g+s /var/spool/sms/outgoing 41 | setfacl -m d:g:smstools:rwX /var/spool/sms/outgoing 42 | 43 | You need to have file system ACLs enabled for this to work. 44 | 45 | 46 | API Documentation 47 | ----------------- 48 | 49 | - `POST /api/v1.0/sms/outgoing` 50 | 51 | Send a new SMS. 52 | 53 | The body must contain a JSON object that defines `mobiles` array and `text` fields. Status code 201 is returned on success. Body of the response contains following JSON object: 54 | 55 | - `message_id` - Mapping of supplied numbers to their corresponding spooler file names (ids). 56 | - `parts_count` - Tells you how many parts will the SMS be split into. 57 | - `sent_text` - Original message text. 58 | - `queue` - Queue to send message. If queue is not set, you can define DEFAULTQUEUE in config.py or keep smsd choose it. 59 | 60 | Status code 400 (Bad Request) is returned in case of a failure to parse input data. 61 | 62 | If `USER_WHITELIST` option is enabled in the `config.py`, number access control lists are applied. 63 | 64 | - `GET /api/v1.0/sms/outgoing` 65 | 66 | Send a new SMS. 67 | 68 | The parameters must contain a `mobiles` (separate each entry with comma) and `text` fields. Status code 201 is returned on success. Body of the response contains following JSON object as describe above. 69 | 70 | 71 | - `GET /api/v1.0/sms//` 72 | 73 | Get information about a message. 74 | 75 | Where `kind` can be one of `incoming`, `outgoing`, `checked`, `failed`, or `sent`. Body of the response contains a JSON object with all headers and following two fields: 76 | 77 | - `message_id` - Unique identifier of the message. 78 | - `text` - Text of the message. 79 | 80 | Status code 404 (Not Found) signifies that no message with such an `message_id` has been found. 81 | 82 | 83 | - `DELETE /api/v1.0/sms//` 84 | 85 | Delete a message. 86 | 87 | Where `kind` can be one of `incoming`, `outgoing`, `checked`, `failed`, or `sent`. Body of the response contains a JSON object with all headers and following two fields: 88 | 89 | - `deleted` - Unique identifier of the message.(/) 90 | 91 | Status code 404 (Not Found) signifies that no message with such an `message_id` has been found. 92 | Status code 403 (Forbidden) signifies that auth user not found in ADMIN_ACCOUNTS. 93 | 94 | 95 | - `GET /api/v1.0/sms//` 96 | 97 | List messages of given `kind`. 98 | 99 | Where `kind` can be one of `incoming`, `outgoing`, `checked`, `failed`, or `sent`. Body of the response contains a JSON object with a single field: 100 | 101 | - `message_id` - List of identifiers of the messages of given `kind`. 102 | - `limit` - Output limit for message_id (false for unlimited). 103 | - `total_count` - Count of list message_id. 104 | 105 | Example 106 | ------- 107 | 108 | To send an SMS with `curl` installed (POST): 109 | 110 | $ curl -u lvv:SecretPAss -i -H "Content-Type: application/json; charset=UTF-8" -d '{"text":"Hi, Jack!", "mobiles":["79680000000", "79160000000"]}' http://127.0.0.1:5000/api/v1.0/sms/outgoing 111 | 112 | Should result in: 113 | 114 | HTTP/1.0 201 CREATED 115 | Content-Type: application/json 116 | 117 | { 118 | "mobiles": { 119 | "79160000000": { 120 | "message_id": "f22f4f49-eb75-44d8-9690-7ac6d5b607a0", 121 | "response": "Ok" 122 | }, 123 | "79680000000": { 124 | "message_id": "0be8248b-1711-4257-b766-18fcac2babcb", 125 | "response": "Ok" 126 | }, 127 | }, 128 | "parts_count": 1, 129 | "sent_text": "Hi, Jack!" 130 | } 131 | 132 | To send an SMS with `curl` installed (GET): 133 | 134 | $ curl -u lvv:SecretPAss -i -H "Content-Type: application/json; charset=UTF-8" 'http://127.0.0.1:5000/api/v1.0/sms/outgoing?text=Hi+Jack!&mobiles=79680000000,79160000000' 135 | 136 | To inquire about a sent SMS: 137 | 138 | $ curl -u lvv:SecretPAss -i -H "Content-Type: application/json; charset=UTF-8" http://127.0.0.1:5000/api/v1.0/sms/sent/83c201e0-c093-47ee-9ab1-e968cbc58446 139 | 140 | Should result in: 141 | 142 | HTTP/1.0 200 OK 143 | Content-Type: application/json 144 | 145 | { 146 | "From": "lvv", 147 | "Sent": "14-10-13 11:37:09", 148 | "To": "79160000000", 149 | "Modem": "GSM1", 150 | "IMSI": "230000000000000", 151 | "message_id": "83c201e0-c093-47ee-9ab1-e968cbc58446" 152 | "text": "Hi, Jack!", 153 | } 154 | 155 | Using a wrong `message_id`: 156 | 157 | $ curl -u lvv:SecretPAss -i -H "Content-Type: application/json; charset=UTF-8" http://127.0.0.1:5000/api/v1.0/sms/sent/7b533050-d5a0-4b52-b8ba-f5d2ed42e630f 158 | 159 | Should result in: 160 | 161 | HTTP/1.0 404 NOT FOUND 162 | Content-Type: application/json 163 | 164 | { 165 | "message: ": "Not found: http://127.0.0.1:5000/api/v1.0/sms/outgoing/7b533050-d5a0-4b52-b8ba-f5d2ed42e630f", 166 | "status: ": 404 167 | } 168 | 169 | Listing all received messages: 170 | 171 | $ curl -u lvv:SecretPAss -i -H "Content-Type: application/json; charset=UTF-8" http://127.0.0.1:5000/api/v1.0/sms/sent/ 172 | 173 | Should result in: 174 | 175 | HTTP/1.0 200 OK 176 | Content-Type: application/json 177 | 178 | { 179 | "limit": 10000, 180 | "message_id": ["feabbf50-0a55-488e-af0c-026cd3a5d10d", "4935165a-606d-450e-ad91-0086483e4ecc"], 181 | "total_count": 2 182 | } 183 | 184 | Link to the APIARY doc: 185 | [https://smstoolshttpapi.docs.apiary.io](https://smstoolshttpapi.docs.apiary.io) 186 | 187 | And that is all. 188 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from flask import Flask 5 | from config import config 6 | 7 | def create_app(config_name): 8 | app = Flask(__name__) 9 | app.config.from_object(config[config_name]) 10 | config[config_name].init_app(app) 11 | 12 | # attach routes and custom error pages here 13 | from .api_1_0 import api_1_0 as api_1_0_blueprint 14 | app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0') 15 | 16 | return app 17 | 18 | if __name__ == '__main__': 19 | app.run() 20 | -------------------------------------------------------------------------------- /app/api_1_0/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from flask import Blueprint 4 | 5 | api_1_0 = Blueprint('api_1_0', __name__) 6 | 7 | from . import authentication, views, errors 8 | -------------------------------------------------------------------------------- /app/api_1_0/authentication.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import unicode_literals 4 | from flask import current_app, g 5 | from flask_httpauth import HTTPBasicAuth 6 | from .errors import unauthorized 7 | from passlib.apache import HtpasswdFile 8 | 9 | auth = HTTPBasicAuth() 10 | 11 | @auth.verify_password 12 | def verify_password(login, password): 13 | try: 14 | htpasswd_file = HtpasswdFile(current_app.config['HTPASSWD_PATH']) 15 | except EnvironmentError: 16 | return False 17 | 18 | if htpasswd_file.check_password(login, password): 19 | return True 20 | else: 21 | g.reason = 'invalid password' 22 | 23 | return False 24 | 25 | @auth.error_handler 26 | def auth_error(): 27 | try: 28 | return unauthorized('Unauthorized access: %s' % g.reason) 29 | except AttributeError: 30 | return unauthorized('Unauthorized access') 31 | -------------------------------------------------------------------------------- /app/api_1_0/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from flask import jsonify, request 4 | from . import api_1_0 5 | 6 | @api_1_0.app_errorhandler(400) 7 | def bad_request(exception): 8 | response = {'status: ': 400, 'message: ': 'Bad request: ' + request.url} 9 | response['reason'] = (exception) 10 | response = jsonify(response) 11 | response.status_code = 400 12 | return response 13 | 14 | def unauthorized(exception): 15 | response = {'status: ': 401, 'message: ': 'Unauthorized: ' + request.url} 16 | response['reason'] = (exception) 17 | response = jsonify(response) 18 | response.status_code = 401 19 | return response 20 | 21 | @api_1_0.app_errorhandler(403) 22 | def forbidden(exception): 23 | response = jsonify({'status: ': 403, 'message: ': 'Forbidden: ' + request.url}) 24 | response.status_code = 403 25 | return response 26 | 27 | @api_1_0.app_errorhandler(404) 28 | def not_found(exception): 29 | response = jsonify({'status: ': 404, 'message: ': 'Not found: ' + request.url}) 30 | response.status_code = 404 31 | return response 32 | 33 | @api_1_0.app_errorhandler(405) 34 | def not_allowed(exception): 35 | response = jsonify({'status: ': 405, 'message: ': 'Not allowed: ' + request.url}) 36 | response.status_code = 405 37 | return response 38 | 39 | @api_1_0.app_errorhandler(500) 40 | def internal_error(exception): 41 | response = jsonify({'status: ': 500, 'message: ': 'Internal error: ' + request.url}) 42 | response.status_code = 500 43 | return response 44 | -------------------------------------------------------------------------------- /app/api_1_0/smstools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import os 6 | import sys 7 | import uuid 8 | from email.message import Message 9 | from flask import current_app, request, jsonify 10 | from .authentication import auth 11 | from .errors import not_found, forbidden 12 | 13 | python_ver = sys.version_info 14 | python3_ver = (3, 0) 15 | 16 | if python_ver >= python3_ver: 17 | use_python3 = True 18 | read_mode = "rb" 19 | write_mode = "wb" 20 | else: 21 | use_python3 = False 22 | read_mode = "r" 23 | write_mode = "w" 24 | 25 | def access_mobile(mobile): 26 | if not 'USER_WHITELIST' in current_app.config.keys(): 27 | # Number access control disabled. 28 | return True 29 | if current_app.config['USER_WHITELIST'].get(auth.username()): 30 | return mobile in current_app.config['USER_WHITELIST'].get(auth.username(), []) 31 | else: 32 | return True 33 | 34 | def validate_mobile(mobile): 35 | return re.match(r'^\+?\d+$', mobile) and True 36 | 37 | def is_admin(user): 38 | if 'ADMIN_ACCOUNTS' in current_app.config and auth.username() in current_app.config['ADMIN_ACCOUNTS']: 39 | return True 40 | return False 41 | 42 | def list_some_sms(kind): 43 | if kind not in current_app.config['KINDS']: 44 | return not_found(None) 45 | 46 | limit = current_app.config.get('LIMIT') or False 47 | message_ids = os.listdir(current_app.config[kind.upper()]) 48 | message_ids = [mid for mid in message_ids if not mid.endswith('.LOCK')] 49 | total_count = len(message_ids) 50 | result = {} 51 | result['total_count'] = total_count 52 | result['limit'] = limit 53 | result['message_id'] = message_ids[:limit] if limit else message_ids 54 | 55 | return jsonify(result) 56 | 57 | def delete_some_sms(kind, message_id): 58 | if kind not in current_app.config['KINDS']: 59 | return not_found(None) 60 | 61 | if is_admin(auth.username()): 62 | result = {} 63 | try: 64 | os.remove(current_app.config[kind.upper()] + "/" + message_id) 65 | result['deleted'] = kind + '/' + message_id 66 | return jsonify(result) 67 | except OSError: 68 | return not_found(None) 69 | 70 | return forbidden(None) 71 | 72 | def get_some_sms(kind, message_id): 73 | if kind not in current_app.config['KINDS']: 74 | return not_found(None) 75 | try: 76 | with open(os.path.join(current_app.config[kind.upper()], message_id), read_mode) as fp: 77 | header_flag = True 78 | result = {} 79 | 80 | for line in fp: 81 | line = line.decode('utf-8') 82 | if line == os.linesep: 83 | header_flag = False 84 | if header_flag == True: 85 | try: 86 | key, val = line.split(':') 87 | result[key] = val.strip() 88 | except ValueError: 89 | pass 90 | # text message 91 | if header_flag == False: 92 | if result.get('Alphabet', '').startswith('UCS'): 93 | charset = 'utf-16-be' 94 | else: 95 | # Since UTF-8 is backwards compatible with US-ASCII and 96 | # outgoing messages sent from the command line will be 97 | # in UTF-8 without an Alphabet option, this will work. 98 | charset = 'utf-8' 99 | for line in fp: 100 | result['text'] = result.get('text', '') + line.decode(charset) 101 | 102 | result['message_id'] = message_id 103 | 104 | if result['From'] == auth.username(): 105 | return jsonify(result) 106 | elif is_admin(auth.username()): 107 | return jsonify(result) 108 | else: 109 | return forbidden(None) 110 | except EnvironmentError: 111 | return not_found(None) 112 | 113 | def detect_coding(text): 114 | text_len=len(text) 115 | try: 116 | parts_count = text_len // 153 + (text_len % 153 > 0) 117 | text = text.encode('ascii') 118 | coding = 'ISO' 119 | except UnicodeEncodeError: 120 | parts_count = text_len // 67 + (text_len % 67 > 0) 121 | text = text.encode('utf-16-be') 122 | coding = 'UCS2' 123 | 124 | return text, coding, parts_count 125 | 126 | def send_sms(data): 127 | text, coding, parts_count = detect_coding(data['text']) 128 | 129 | result = { 130 | 'sent_text': data['text'], 131 | 'parts_count': parts_count, 132 | 'mobiles': {} 133 | } 134 | 135 | for mobile in data['mobiles']: 136 | # generate message_id 137 | message_id = str(uuid.uuid4()) 138 | 139 | result['mobiles'][mobile] = {} 140 | result['mobiles'][mobile]['message_id'] = message_id 141 | result['mobiles'][mobile]['dlr_status'] = os.path.join(os.path.dirname(request.url), 142 | os.path.basename(current_app.config['SENT']), message_id) 143 | 144 | if not validate_mobile(mobile): 145 | current_app.logger.info('Message from [%s] to [%s] have invalid mobile number' % (auth.username(), mobile)) 146 | result['mobiles'][mobile]['response'] = 'Failed: invalid mobile number' 147 | continue 148 | 149 | if access_mobile(mobile): 150 | lock_file = os.path.join(current_app.config['OUTGOING'], message_id + '.LOCK') 151 | m = Message() 152 | m.add_header('From', auth.username()) 153 | m.add_header('To', mobile) 154 | m.add_header('Alphabet', coding) 155 | if data.get('queue'): 156 | result.update({'queue' : data['queue']}) 157 | m.add_header('Queue', result.get('queue')) 158 | m.set_payload(text) 159 | 160 | with open(lock_file, write_mode) as fp: 161 | if use_python3: 162 | fp.write(m.as_bytes()) 163 | else: 164 | fp.write(m.as_string()) 165 | 166 | msg_file = lock_file.split('.LOCK')[0] 167 | os.rename(lock_file, msg_file) 168 | os.chmod(msg_file, 0o660) 169 | current_app.logger.info('Message from [%s] to [%s] placed to the spooler as [%s]' % (auth.username(), mobile, msg_file)) 170 | result['mobiles'][mobile]['response'] = 'Ok' 171 | else: 172 | current_app.logger.info('Message from [%s] to [%s] have forbidden mobile number' % (auth.username(), mobile)) 173 | result['mobiles'][mobile]['response'] = 'Failed: forbidden mobile number' 174 | 175 | return result 176 | -------------------------------------------------------------------------------- /app/api_1_0/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import unicode_literals 4 | from flask import current_app, request, jsonify 5 | from . import api_1_0 6 | from .errors import bad_request 7 | from .authentication import auth 8 | 9 | from .smstools import * 10 | 11 | @api_1_0.route('/monitoring', methods=['GET']) 12 | def monitoring_view(): 13 | return jsonify({'monitoring': 'ok'}) 14 | 15 | @api_1_0.route('/sms//', methods=['GET']) 16 | @auth.login_required 17 | def list_some_sms(kind): 18 | return list_some_sms(kind) 19 | 20 | @api_1_0.route('/sms//', methods=['GET']) 21 | @auth.login_required 22 | def get_some_sms_view(kind, message_id): 23 | return get_some_sms(kind, message_id) 24 | 25 | @api_1_0.route('/sms//', methods=['DELETE']) 26 | @auth.login_required 27 | def delete_sms_view(kind, message_id): 28 | return delete_some_sms(kind, message_id) 29 | 30 | @api_1_0.route('/sms/outgoing', methods=['GET', 'POST']) 31 | @auth.login_required 32 | def outgoing_view(): 33 | required_fields = ( 'mobiles', 'text' ) 34 | 35 | if request.method == 'POST': 36 | request_object = request.json 37 | elif request.method == 'GET': 38 | request_object = {} 39 | mobiles = request.args.get('mobiles') 40 | text = request.args.get('text') 41 | if mobiles: 42 | request_object['mobiles'] = mobiles.replace(' ', '+').split(',') 43 | if text: 44 | request_object['text'] = text 45 | 46 | # Check input data 47 | if type(request_object) is not dict: 48 | return bad_request('Wrong JSON object') 49 | for required_field in required_fields: 50 | if required_field not in request_object: 51 | return bad_request('Missing required: {0}'.format(required_field)) 52 | if type(request_object['mobiles']) is not list: 53 | return bad_request('mobiles is not array') 54 | if len(request_object['mobiles']) == 0: 55 | return bad_request('mobiles array is empty') 56 | 57 | try: 58 | unicode_str = unicode() 59 | except NameError: 60 | unicode_str = str() 61 | 62 | for mobile in request_object['mobiles']: 63 | if type(mobile) is not type(unicode_str): 64 | return bad_request('mobiles is not unicode') 65 | 66 | if type(request_object['text']) is not type(unicode_str): 67 | return bad_request('text is not unicode') 68 | 69 | queue = request_object.get('queue', current_app.config.get('DEFAULTQUEUE')) 70 | data = { 71 | 'mobiles': request_object['mobiles'], 72 | 'text': request_object['text'], 73 | 'queue' : queue 74 | } 75 | 76 | result = send_sms(data) 77 | return jsonify(result) 78 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | class Config: 5 | 6 | # Valid message kinds (also spooler directories). 7 | KINDS = ['incoming', 'outgoing', 'checked', 'failed', 'sent'] 8 | 9 | # Accounts who may access to all messages 10 | #ADMIN_ACCOUNTS = ['superadm'] 11 | 12 | # Default queue for send message, if not set, keep smsd choose the default queue 13 | #DEFAULTQUEUE = "GSM1" 14 | 15 | # Limit for list messages of given kind. 16 | #LIMIT = 10000 17 | 18 | # Whitelist for users 19 | #USER_WHITELIST = { 20 | # 'lvv': [ '911' ], 21 | #} 22 | 23 | @staticmethod 24 | def init_app(app): 25 | pass 26 | 27 | class ProductionConfig(Config): 28 | 29 | # Smsd spool path 30 | INCOMING = "/var/spool/sms/incoming" 31 | OUTGOING = "/var/spool/sms/outgoing" 32 | CHECKED = "/var/spool/sms/checked" 33 | FAILED = "/var/spool/sms/failed" 34 | SENT = "/var/spool/sms/sent" 35 | 36 | # Users database (htpasswd format) 37 | # Generate hash: openssl passwd -apr1 PASSWORD 38 | # username:$apr1$qSS22H6v$sem/.bUQXjGUIIHb.MXLw1 39 | HTPASSWD_PATH="/usr/local/etc/smstools-http-api/htpasswd.users" 40 | 41 | @classmethod 42 | def init_app(cls, app): 43 | Config.init_app(app) 44 | 45 | import logging 46 | 47 | level = logging.INFO 48 | logFormatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') 49 | consoleHandler = logging.StreamHandler() 50 | consoleHandler.setFormatter(logFormatter) 51 | app.logger.setLevel(level) 52 | app.logger.addHandler(consoleHandler) 53 | 54 | class DevelopmentConfig(Config): 55 | DEBUG = True 56 | 57 | # Smsd spool path 58 | INCOMING = "tmp/incoming" 59 | OUTGOING = "tmp/outgoing" 60 | CHECKED = "tmp/checked" 61 | FAILED = "tmp/failed" 62 | SENT = "tmp/sent" 63 | 64 | # Users database (htpasswd format) 65 | # Generate hash: openssl passwd -apr1 PASSWORD 66 | # username:$apr1$qSS22H6v$sem/.bUQXjGUIIHb.MXLw1 67 | HTPASSWD_PATH="htpasswd.users" 68 | 69 | class TestConfig(Config): 70 | TESTING = True 71 | DEBUG = True 72 | OUTGOING = "outgoing" 73 | SENT = "sent" 74 | HTPASSWD_PATH = "htpasswd.users" 75 | 76 | config = { 77 | 'development': DevelopmentConfig, 78 | 'production': ProductionConfig, 79 | 'test': TestConfig, 80 | 'default': DevelopmentConfig 81 | } 82 | -------------------------------------------------------------------------------- /log/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from app import create_app 6 | from flask_script import Manager 7 | 8 | app = create_app(os.getenv('FLASK_CONFIG') or 'default') 9 | manager = Manager(app) 10 | 11 | if __name__ == '__main__': 12 | manager.run() 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | Flask==1.1.1 3 | Flask-HTTPAuth==3.3.0 4 | Flask-Script==2.0.6 5 | itsdangerous==1.1.0 6 | Jinja2==2.10.3 7 | MarkupSafe==1.1.1 8 | passlib==1.7.2 9 | Werkzeug==0.16.0 10 | wheel==0.24.0 11 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from app import create_app 5 | import unittest 6 | import base64 7 | 8 | user_credentials = base64.b64encode(b'test:test').decode('utf-8') 9 | 10 | class AppTestCase(unittest.TestCase): 11 | def setUp(self): 12 | self.app = create_app('test') 13 | self.client = self.app.test_client() 14 | 15 | def tearDown(self): 16 | pass 17 | 18 | def test_app_configuration(self): 19 | self.assertTrue(self.app.config['TESTING']) 20 | 21 | def test_unauthorized_access(self): 22 | response = self.client.get('/api/v1.0/sms/sent/test') 23 | self.assertTrue('Unauthorized access' in response.get_data(as_text=True)) 24 | 25 | def test_authorized_access(self): 26 | headers = {"Authorization": "Basic {}".format(user_credentials)} 27 | response = self.client.get('/api/v1.0/sms/sent/test', headers=headers) 28 | self.assertTrue('Not found' in response.get_data(as_text=True)) 29 | 30 | if __name__ == '__main__': 31 | unittest.main() 32 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | base = /opt/apps/smstools-http-api 3 | chdir = %(base) 4 | module = manage 5 | callable = app 6 | home = %(base)/venv 7 | master = true 8 | processes = 2 9 | socket = 127.0.0.1:5000 10 | vacuum = true 11 | disable-logging = true 12 | logger = file:%(base)/log/smstools-http-api.log 13 | env = FLASK_CONFIG=production 14 | --------------------------------------------------------------------------------