├── .gitignore ├── LICENSE ├── README.md ├── application.py ├── circle.yml ├── config.example.py ├── controllers ├── __init__.py ├── gcm.py ├── message.py ├── service.py └── subscription.py ├── database.sql ├── models ├── __init__.py ├── gcm.py ├── message.py ├── service.py └── subscription.py ├── requirements.txt ├── shared.py ├── static ├── favicon.ico └── robots.txt ├── tests.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | venv/ 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | 57 | #editor 58 | .idea 59 | *.cfg 60 | 61 | #gedit 62 | *~ 63 | 64 | config.py 65 | wsgi.py 66 | 67 | start_server 68 | uwsgi.ini 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, IOException 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY 17 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pushjet Server Api [![Circle CI](https://circleci.com/gh/Pushjet/Pushjet-Server-Api/tree/master.svg?style=svg)](https://circleci.com/gh/Pushjet/Pushjet-Server-Api/tree/master) [![License](http://img.shields.io/badge/license-BSD-blue.svg?style=flat)](/LICENSE) 2 | ================== 3 | This is the core for Pushjet. It manages the whole shebang. 4 | -------------------------------------------------------------------------------- /application.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | # coding=utf-8 3 | from __future__ import unicode_literals 4 | from flask import Flask, jsonify, redirect, send_from_directory, request 5 | from sys import exit, stderr 6 | from os import getenv 7 | 8 | try: 9 | import config 10 | except ImportError: 11 | stderr.write("FATAL: Please copy config.example.py to config.py and edit the file.") 12 | exit(1) 13 | 14 | from shared import db 15 | from controllers import * 16 | from utils import Error 17 | 18 | gcm_enabled = True 19 | if config.google_api_key == '': 20 | stderr.write("WARNING: GCM disabled, please enter the google api key for gcm") 21 | gcm_enabled = False 22 | if not isinstance(config.google_gcm_sender_id, int): 23 | stderr.write("WARNING: GCM disabled, sender id is not an integer") 24 | gcm_enabled = False 25 | elif config.google_gcm_sender_id == 0: 26 | stderr.write('WARNING: GCM disabled, invalid sender id found') 27 | gcm_enabled = False 28 | 29 | 30 | app = Flask(__name__) 31 | app.debug = config.debug or int(getenv('FLASK_DEBUG', 0)) > 0 32 | app.config['SQLALCHEMY_DATABASE_URI'] = config.database_uri 33 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 34 | db.init_app(app) 35 | with app.app_context(): 36 | db.engine.execute("SET NAMES 'utf8mb4' COLLATE 'utf8mb4_unicode_ci'") 37 | 38 | 39 | @app.route('/') 40 | def index(): 41 | return redirect('http://docs.pushjet.io') 42 | 43 | 44 | @app.route('/robots.txt') 45 | @app.route('/favicon.ico') 46 | def robots_txt(): 47 | return send_from_directory(app.static_folder, request.path[1:]) 48 | 49 | 50 | @app.route('/version') 51 | def version(): 52 | with open('.git/refs/heads/master', 'r') as f: 53 | return f.read(7) 54 | 55 | 56 | @app.errorhandler(429) 57 | def limit_rate(e): 58 | return Error.RATE_TOOFAST 59 | 60 | 61 | app.register_blueprint(subscription) 62 | app.register_blueprint(message) 63 | app.register_blueprint(service) 64 | if gcm_enabled: 65 | app.register_blueprint(gcm) 66 | 67 | if __name__ == '__main__': 68 | app.run() 69 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | timezone: 3 | Europe/Amsterdam 4 | python: 5 | version: 2.7 6 | 7 | dependencies: 8 | pre: 9 | - mysql -e 'CREATE DATABASE IF NOT EXISTS pushjet_api' # Create the database 10 | - mysql -e 'SOURCE database.sql' -Dpushjet_api 11 | - cp config.example.py config.py # The default config should be fine 12 | 13 | -------------------------------------------------------------------------------- /config.example.py: -------------------------------------------------------------------------------- 1 | # Must be a mysql database! 2 | database_uri = 'mysql://root@localhost/pushjet_api' 3 | 4 | # Are we debugging the server? 5 | # Do not turn this on when in production! 6 | debug = False 7 | 8 | # Google Cloud Messaging configuration (required for android!) 9 | google_api_key = '' 10 | google_gcm_sender_id = 509878466986 # Change this to your gcm sender id 11 | 12 | # Message Queueing, this should be the relay. A "sane" value 13 | # for this would be something like ipc:///tmp/pushjet-relay.ipc 14 | zeromq_relay_uri = '' 15 | -------------------------------------------------------------------------------- /controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .subscription import subscription 2 | from .message import message 3 | from .service import service 4 | from .gcm import gcm -------------------------------------------------------------------------------- /controllers/gcm.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from utils import has_uuid, Error 3 | from models import Gcm 4 | from shared import db 5 | from config import google_gcm_sender_id 6 | 7 | gcm = Blueprint('gcm', __name__) 8 | 9 | 10 | @gcm.route("/gcm", methods=["POST"]) 11 | @has_uuid 12 | def gcm_register(client): 13 | registration = request.form.get('regid', False) or request.form.get('regId', False) 14 | 15 | if not registration: 16 | return Error.ARGUMENT_MISSING('regid') 17 | regs = Gcm.query.filter_by(uuid=client).all() 18 | for u in regs: 19 | db.session.delete(u) 20 | reg = Gcm(client, registration) 21 | db.session.add(reg) 22 | db.session.commit() 23 | return Error.NONE 24 | 25 | 26 | @gcm.route("/gcm", methods=["DELETE"]) 27 | @has_uuid 28 | def gcm_unregister(client): 29 | regs = Gcm.query.filter_by(uuid=client).all() 30 | for u in regs: 31 | db.session.delete(u) 32 | db.session.commit() 33 | return Error.NONE 34 | 35 | 36 | @gcm.route("/gcm", methods=["GET"]) 37 | def gcm_sender_id(): 38 | data = dict(sender_id=google_gcm_sender_id) 39 | return jsonify(data) 40 | 41 | -------------------------------------------------------------------------------- /controllers/message.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | from flask import current_app 3 | 4 | from utils import Error, has_uuid, has_secret, queue_zmq_message 5 | from shared import db 6 | from models import Subscription, Message, Gcm 7 | from datetime import datetime 8 | from config import zeromq_relay_uri, google_api_key 9 | from json import dumps as json_encode 10 | 11 | message = Blueprint('message', __name__) 12 | 13 | 14 | @message.route('/message', methods=['POST']) 15 | @has_secret 16 | def message_send(service): 17 | text = request.form.get('message') 18 | if not text: 19 | return Error.ARGUMENT_MISSING('message') 20 | 21 | subscribers = Subscription.query.filter_by(service=service).count() 22 | if subscribers == 0: 23 | # Pretend we did something even though we didn't 24 | # Nobody is listening so it doesn't really matter 25 | return Error.NONE 26 | 27 | level = (request.form.get('level') or '3')[0] 28 | level = int(level) if level in "12345" else 3 29 | title = request.form.get('title', '').strip()[:255] 30 | link = request.form.get('link', '').strip() 31 | msg = Message(service, text, title, level, link) 32 | db.session.add(msg) 33 | db.session.commit() 34 | 35 | if google_api_key or current_app.config['TESTING']: 36 | Gcm.send_message(msg) 37 | 38 | if zeromq_relay_uri: 39 | queue_zmq_message(json_encode({"message": msg.as_dict()})) 40 | 41 | service.cleanup() 42 | db.session.commit() 43 | return Error.NONE 44 | 45 | 46 | @message.route('/message', methods=['GET']) 47 | @has_uuid 48 | def message_recv(client): 49 | subscriptions = Subscription.query.filter_by(device=client).all() 50 | if len(subscriptions) == 0: 51 | return jsonify({'messages': []}) 52 | 53 | msg = [] 54 | for l in subscriptions: 55 | msg += l.messages().all() 56 | 57 | last_read = max([0] + [m.id for m in msg]) 58 | for l in subscriptions: 59 | l.timestamp_checked = datetime.utcnow() 60 | l.last_read = max(l.last_read, last_read) 61 | l.service.cleanup() 62 | 63 | ret = jsonify({'messages': [m.as_dict() for m in msg]}) 64 | db.session.commit() 65 | return ret 66 | 67 | 68 | @message.route('/message', methods=['DELETE']) 69 | @has_uuid 70 | def message_read(client): 71 | subscriptions = Subscription.query.filter_by(device=client).all() 72 | if len(subscriptions) > 0: 73 | last_message = Message.query.order_by(Message.id.desc()).first() 74 | for l in subscriptions: 75 | l.timestamp_checked = datetime.utcnow() 76 | l.last_read = last_message.id if last_message else 0 77 | 78 | for l in subscriptions: 79 | l.service.cleanup() 80 | db.session.commit() 81 | 82 | return Error.NONE 83 | -------------------------------------------------------------------------------- /controllers/service.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | from utils import Error, is_service, is_secret, has_secret, queue_zmq_message 3 | from models import Service, Message 4 | from shared import db 5 | from json import dumps as json_encode 6 | from config import zeromq_relay_uri 7 | 8 | service = Blueprint('service', __name__) 9 | 10 | 11 | @service.route('/service', methods=['POST']) 12 | def service_create(): 13 | name = request.form.get('name', '').strip() 14 | icon = request.form.get('icon', '').strip() 15 | if not name: 16 | return Error.ARGUMENT_MISSING('name') 17 | srv = Service(name, icon) 18 | db.session.add(srv) 19 | db.session.commit() 20 | return jsonify({"service": srv.as_dict(True)}) 21 | 22 | 23 | @service.route('/service', methods=['GET']) 24 | def service_info(): 25 | secret = request.form.get('secret', '') or request.args.get('secret', '') 26 | service_ = request.form.get('service', '') or request.args.get('service', '') 27 | 28 | if service_: 29 | if not is_service(service_): 30 | return Error.INVALID_SERVICE 31 | 32 | srv = Service.query.filter_by(public=service_).first() 33 | if not srv: 34 | return Error.SERVICE_NOTFOUND 35 | return jsonify({"service": srv.as_dict()}) 36 | 37 | if secret: 38 | if not is_secret(secret): 39 | return Error.INVALID_SECRET 40 | 41 | srv = Service.query.filter_by(secret=secret).first() 42 | if not srv: 43 | return Error.SERVICE_NOTFOUND 44 | return jsonify({"service": srv.as_dict()}) 45 | 46 | return Error.ARGUMENT_MISSING('service') 47 | 48 | 49 | @service.route('/service', methods=['DELETE']) 50 | @has_secret 51 | def service_delete(service): 52 | subscriptions = service.subscribed().all() 53 | messages = Message.query.filter_by(service=service).all() 54 | 55 | # In case we need to send this at a later point 56 | # when the subscriptions have been deleted. 57 | send_later = [] 58 | if zeromq_relay_uri: 59 | for l in subscriptions: 60 | send_later.append(json_encode({'subscription': l.as_dict()})) 61 | 62 | map(db.session.delete, subscriptions) # Delete all subscriptions 63 | map(db.session.delete, messages) # Delete all messages 64 | db.session.delete(service) 65 | 66 | db.session.commit() 67 | 68 | # Notify that the subscriptions have been deleted 69 | if zeromq_relay_uri: 70 | map(queue_zmq_message, send_later) 71 | 72 | return Error.NONE 73 | 74 | 75 | @service.route('/service', methods=['PATCH']) 76 | @has_secret 77 | def service_patch(service): 78 | fields = ['name', 'icon'] 79 | updated = False 80 | 81 | for field in fields: 82 | data = request.form.get(field, '').strip() 83 | if data is not '': 84 | setattr(service, field, data) 85 | updated = True 86 | 87 | if updated: 88 | db.session.commit() 89 | return Error.NONE 90 | else: 91 | return Error.NO_CHANGES 92 | -------------------------------------------------------------------------------- /controllers/subscription.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from utils import Error, has_service, has_uuid, queue_zmq_message 3 | from shared import db 4 | from models import Subscription 5 | from json import dumps as json_encode 6 | from config import zeromq_relay_uri 7 | 8 | subscription = Blueprint('subscription', __name__) 9 | 10 | 11 | @subscription.route('/subscription', methods=['POST']) 12 | @has_uuid 13 | @has_service 14 | def subscription_post(client, service): 15 | exists = Subscription.query.filter_by(device=client).filter_by(service=service).first() is not None 16 | if exists: 17 | return Error.DUPLICATE_LISTEN 18 | 19 | subscription_new = Subscription(client, service) 20 | db.session.add(subscription_new) 21 | db.session.commit() 22 | 23 | if zeromq_relay_uri: 24 | queue_zmq_message(json_encode({'subscription': subscription_new.as_dict()})) 25 | 26 | return jsonify({'service': service.as_dict()}) 27 | 28 | 29 | @subscription.route('/subscription', methods=['GET']) 30 | @has_uuid 31 | def subscription_get(client): 32 | subscriptions = Subscription.query.filter_by(device=client).all() 33 | return jsonify({'subscriptions': [_.as_dict() for _ in subscriptions]}) 34 | 35 | 36 | @subscription.route('/subscription', methods=['DELETE']) 37 | @has_uuid 38 | @has_service 39 | def subscription_delete(client, service): 40 | l = Subscription.query.filter_by(device=client).filter_by(service=service).first() 41 | if l is not None: 42 | db.session.delete(l) 43 | db.session.commit() 44 | return Error.NONE 45 | return Error.NOT_SUBSCRIBED 46 | -------------------------------------------------------------------------------- /database.sql: -------------------------------------------------------------------------------- 1 | SET @OLD_UNIQUE_CHECKS = @@UNIQUE_CHECKS, UNIQUE_CHECKS = 0; 2 | SET @OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS = 0; 3 | SET @OLD_SQL_MODE = @@SQL_MODE, SQL_MODE = 'TRADITIONAL,ALLOW_INVALID_DATES'; 4 | 5 | 6 | -- ----------------------------------------------------- 7 | -- Table `subscription` 8 | -- ----------------------------------------------------- 9 | DROP TABLE IF EXISTS `subscription`; 10 | 11 | CREATE TABLE IF NOT EXISTS `subscription` ( 12 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 13 | `device` VARCHAR(40) NOT NULL, 14 | `service_id` INT(10) UNSIGNED NOT NULL, 15 | `last_read` INT(10) UNSIGNED NOT NULL DEFAULT '0', 16 | `timestamp_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | `timestamp_checked` TIMESTAMP NULL DEFAULT NULL, 18 | PRIMARY KEY (`id`) 19 | ) 20 | ENGINE = InnoDB 21 | DEFAULT CHARSET = utf8mb4 22 | COLLATE = utf8mb4_unicode_ci; 23 | 24 | 25 | -- ----------------------------------------------------- 26 | -- Table `message` 27 | -- ----------------------------------------------------- 28 | DROP TABLE IF EXISTS `message`; 29 | 30 | CREATE TABLE IF NOT EXISTS `message` ( 31 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 32 | `service_id` INT(10) UNSIGNED NOT NULL, 33 | `text` TEXT NOT NULL, 34 | `title` VARCHAR(255) NULL DEFAULT NULL, 35 | `level` TINYINT(4) NOT NULL DEFAULT '0', 36 | `link` TEXT NULL, 37 | `timestamp_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 38 | PRIMARY KEY (`id`) 39 | ) 40 | ENGINE = InnoDB 41 | DEFAULT CHARSET = utf8mb4 42 | COLLATE = utf8mb4_unicode_ci; 43 | 44 | -- ----------------------------------------------------- 45 | -- Table `service` 46 | -- ----------------------------------------------------- 47 | DROP TABLE IF EXISTS `service`; 48 | 49 | CREATE TABLE IF NOT EXISTS `service` ( 50 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 51 | `secret` VARCHAR(32) NOT NULL, 52 | `public` VARCHAR(40) NOT NULL, 53 | `name` VARCHAR(255) NOT NULL, 54 | `icon` TEXT NULL, 55 | `timestamp_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 56 | PRIMARY KEY (`id`) 57 | ) 58 | ENGINE = InnoDB 59 | DEFAULT CHARSET = utf8mb4 60 | COLLATE = utf8mb4_unicode_ci; 61 | 62 | -- ----------------------------------------------------- 63 | -- Table `gcm` 64 | -- ----------------------------------------------------- 65 | DROP TABLE IF EXISTS `gcm`; 66 | 67 | CREATE TABLE IF NOT EXISTS `gcm` ( 68 | `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, 69 | `uuid` VARCHAR(40) NOT NULL, 70 | `gcmid` TEXT NOT NULL, 71 | `pubkey` TEXT DEFAULT NULL, 72 | `timestamp_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 73 | `rsa_pub` BLOB(1024) DEFAULT NULL, 74 | PRIMARY KEY (`id`) 75 | ) 76 | ENGINE = InnoDB 77 | DEFAULT CHARSET = utf8mb4 78 | COLLATE = utf8mb4_unicode_ci; 79 | 80 | SET SQL_MODE = @OLD_SQL_MODE; 81 | SET FOREIGN_KEY_CHECKS = @OLD_FOREIGN_KEY_CHECKS; 82 | SET UNIQUE_CHECKS = @OLD_UNIQUE_CHECKS; 83 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import Service 2 | from .message import Message 3 | from .subscription import Subscription 4 | from .gcm import Gcm 5 | -------------------------------------------------------------------------------- /models/gcm.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from shared import db 3 | from sqlalchemy.dialects.mysql import INTEGER 4 | from datetime import datetime 5 | from config import google_api_key 6 | from models import Subscription, Message 7 | import requests 8 | 9 | gcm_url = 'https://android.googleapis.com/gcm/send' 10 | 11 | 12 | class Gcm(db.Model): 13 | id = db.Column(INTEGER(unsigned=True), primary_key=True) 14 | uuid = db.Column(db.VARCHAR(40), nullable=False) 15 | gcmid = db.Column(db.TEXT, nullable=False) 16 | timestamp_created = db.Column(db.TIMESTAMP, default=datetime.utcnow) 17 | 18 | def __init__(self, device, gcmid): 19 | self.uuid = device 20 | self.gcmid = gcmid 21 | 22 | def __repr__(self): 23 | return ''.format(self.uuid) 24 | 25 | def as_dict(self): 26 | data = { 27 | "uuid": self.service.as_dict(), 28 | "gcm_registration_id": self.gcmId, 29 | "timestamp": int((self.timestamp_created - datetime.utcfromtimestamp(0)).total_seconds()), 30 | } 31 | return data 32 | 33 | @staticmethod 34 | def send_message(message): 35 | """ 36 | 37 | :type message: Message 38 | """ 39 | subscriptions = Subscription.query.filter_by(service=message.service).all() 40 | if len(subscriptions) == 0: 41 | return 0 42 | gcm_devices = Gcm.query.filter(Gcm.uuid.in_([l.device for l in subscriptions])).all() 43 | 44 | if len(gcm_devices) > 0: 45 | data = dict(message=message.as_dict(), encrypted=False) 46 | Gcm.gcm_send([r.gcmid for r in gcm_devices], data) 47 | 48 | if len(gcm_devices) > 0: 49 | uuids = [g.uuid for g in gcm_devices] 50 | gcm_subscriptions = Subscription.query.filter_by(service=message.service).filter(Subscription.device.in_(uuids)).all() 51 | last_message = Message.query.order_by(Message.id.desc()).first() 52 | for l in gcm_subscriptions: 53 | l.timestamp_checked = datetime.utcnow() 54 | l.last_read = last_message.id if last_message else 0 55 | db.session.commit() 56 | return len(gcm_devices) 57 | 58 | @staticmethod 59 | def gcm_send(ids, data): 60 | url = 'https://android.googleapis.com/gcm/send' 61 | headers = dict(Authorization='key={}'.format(google_api_key)) 62 | data = dict(registration_ids=ids, data=data) 63 | 64 | if current_app.config['TESTING'] is True: 65 | current_app.config['TESTING_GCM'].append(data) 66 | else: 67 | requests.post(url, json=data, headers=headers) 68 | -------------------------------------------------------------------------------- /models/message.py: -------------------------------------------------------------------------------- 1 | from shared import db 2 | from datetime import datetime 3 | from sqlalchemy.dialects.mysql import INTEGER, TINYINT 4 | 5 | 6 | class Message(db.Model): 7 | id = db.Column(INTEGER(unsigned=True), primary_key=True) 8 | service_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('service.id'), nullable=False) 9 | service = db.relationship('Service', backref=db.backref('message', lazy='dynamic')) 10 | text = db.Column(db.TEXT, nullable=False) 11 | title = db.Column(db.VARCHAR) 12 | level = db.Column(TINYINT, nullable=False, default=0) 13 | link = db.Column(db.TEXT, nullable=False, default='') 14 | timestamp_created = db.Column(db.TIMESTAMP, default=datetime.utcnow) 15 | 16 | def __init__(self, service, text, title=None, level=0, link=''): 17 | self.service = service 18 | self.text = text 19 | self.title = title 20 | self.level = level 21 | self.link = link 22 | 23 | def __repr__(self): 24 | return ''.format(self.id) 25 | 26 | def as_dict(self): 27 | return { 28 | "service": self.service.as_dict(), 29 | "message": self.text, 30 | "title": self.title, 31 | "link": self.link, 32 | "level": self.level, 33 | "timestamp": int((self.timestamp_created - datetime.utcfromtimestamp(0)).total_seconds()) 34 | } 35 | -------------------------------------------------------------------------------- /models/service.py: -------------------------------------------------------------------------------- 1 | from shared import db 2 | from datetime import datetime 3 | from sqlalchemy.dialects.mysql import INTEGER 4 | import hashlib 5 | from os import urandom 6 | from .subscription import Subscription 7 | from .message import Message 8 | 9 | 10 | class Service(db.Model): 11 | id = db.Column(INTEGER(unsigned=True), primary_key=True) 12 | secret = db.Column(db.VARCHAR(32), nullable=False) 13 | public = db.Column(db.VARCHAR(40), nullable=False) 14 | name = db.Column(db.VARCHAR(255), nullable=False) 15 | icon = db.Column(db.TEXT, nullable=False, default='') 16 | timestamp_created = db.Column(db.TIMESTAMP, default=datetime.utcnow) 17 | 18 | def __init__(self, name, icon=''): 19 | self.secret = hashlib.sha1(urandom(100)).hexdigest()[:32] 20 | self.name = name 21 | self.icon = icon 22 | pub = list(hashlib.new('ripemd160', self.secret).hexdigest())[:40] 23 | sep = [4, 11, 24, 30] 24 | for s in sep: 25 | pub[s] = '-' 26 | self.public = ''.join(pub) 27 | 28 | def __repr__(self): 29 | return ''.format(self.id, self.name) 30 | 31 | def cleanup(self): 32 | threshold = self.subscribed().order_by(Subscription.last_read.asc()).first().last_read 33 | 34 | # Nothing to do 35 | if not threshold: 36 | return 37 | 38 | messages = Message.query \ 39 | .filter_by(service=self) \ 40 | .filter(threshold > Message.id) \ 41 | .all() 42 | 43 | map(db.session.delete, messages) 44 | 45 | def subscribed(self): 46 | return Subscription.query.filter_by(service=self) 47 | 48 | def as_dict(self, secret=False): 49 | data = { 50 | "public": self.public, 51 | "name": self.name, 52 | "created": int((self.timestamp_created - datetime.utcfromtimestamp(0)).total_seconds()), 53 | "icon": self.icon, 54 | } 55 | if secret: 56 | data["secret"] = self.secret 57 | return data 58 | -------------------------------------------------------------------------------- /models/subscription.py: -------------------------------------------------------------------------------- 1 | from shared import db 2 | from sqlalchemy.dialects.mysql import INTEGER 3 | from datetime import datetime 4 | from .message import Message 5 | 6 | 7 | class Subscription(db.Model): 8 | id = db.Column(INTEGER(unsigned=True), primary_key=True) 9 | device = db.Column(db.VARCHAR(40), nullable=False) 10 | service_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('service.id'), nullable=False) 11 | service = db.relationship('Service', backref=db.backref('subscription', lazy='dynamic')) 12 | last_read = db.Column(INTEGER(unsigned=True), db.ForeignKey('message.id'), default=0) 13 | timestamp_created = db.Column(db.TIMESTAMP, default=datetime.utcnow) 14 | timestamp_checked = db.Column(db.TIMESTAMP) 15 | 16 | def __init__(self, device, service): 17 | last_message = Message.query.order_by(Message.id.desc()).first() 18 | 19 | self.device = device 20 | self.service = service 21 | self.timestamp_checked = datetime.utcnow() 22 | self.last_read = last_message.id if last_message else 0 23 | 24 | def __repr__(self): 25 | return ''.format(self.id) 26 | 27 | def messages(self): 28 | return Message.query \ 29 | .filter_by(service_id=self.service_id) \ 30 | .filter(Message.id > self.last_read) 31 | 32 | def as_dict(self): 33 | data = { 34 | "uuid": self.device, 35 | "service": self.service.as_dict(), 36 | "timestamp": int((self.timestamp_created - datetime.utcfromtimestamp(0)).total_seconds()), 37 | "timestamp_checked": int((self.timestamp_checked - datetime.utcfromtimestamp(0)).total_seconds()) 38 | } 39 | return data 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mysql-python 2 | sqlalchemy 3 | flask 4 | flask-sqlalchemy 5 | pyzmq 6 | requests -------------------------------------------------------------------------------- /shared.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from config import zeromq_relay_uri 3 | import zmq 4 | 5 | db = SQLAlchemy() 6 | 7 | zmq_relay_socket = None 8 | zeromq_context = None 9 | 10 | if zeromq_relay_uri: 11 | zeromq_context = zmq.Context() 12 | zmq_relay_socket = zeromq_context.socket(zmq.PUSH) 13 | zmq_relay_socket.connect(zeromq_relay_uri) 14 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pushjet/Pushjet-Server-Api/7ddb7df1d905e4359ecb7021aaf5b8b7d360e65f/static/favicon.ico -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /message 3 | Disallow: /gcm 4 | Disallow: /listen 5 | Disallow: /service 6 | Disallow: /version 7 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | from uuid import uuid4 6 | import unittest 7 | import string 8 | import random 9 | import json 10 | import sys 11 | 12 | try: 13 | import config 14 | except ImportError: 15 | sys.exit('Please copy config.example.py to config.py and configure it') 16 | 17 | 18 | class PushjetTestCase(unittest.TestCase): 19 | def setUp(self): 20 | config.google_api_key = config.google_api_key or 'PLACEHOLDER' 21 | 22 | self.uuid = str(uuid4()) 23 | from application import app 24 | 25 | app.config['TESTING'] = True 26 | app.config['TESTING_GCM'] = [] 27 | 28 | self.gcm = app.config['TESTING_GCM'] 29 | self.app = app.test_client() 30 | self.app_real = app 31 | 32 | def _random_str(self, length=10, unicode=True): 33 | # A random string with the "cupcake" in Japanese appended to it 34 | # Always make sure that there is some unicode in there 35 | random_str = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(length)) 36 | 37 | if unicode: 38 | random_str = random_str[:-7] + 'カップケーキ' 39 | # It's important that the following is a 4+-byte Unicode character. 40 | random_str = '😉' + random_str 41 | 42 | return random_str 43 | 44 | def _failing_loader(self, s): 45 | data = json.loads(s) 46 | if 'error' in data: 47 | err = data['error'] 48 | raise AssertionError("Got an unexpected error, [{}] {}".format(err['id'], err['message'])) 49 | 50 | return data 51 | 52 | def test_service_create(self): 53 | name = "Hello test! {}".format(self._random_str(5)) 54 | data = { 55 | "name": name, 56 | "icon": "http://i.imgur.com/{}.png".format(self._random_str(7, False)) 57 | } 58 | rv = json.loads(self.app.post('/service', data=data).data) 59 | assert 'service' in rv 60 | return rv['service']['public'], rv['service']['secret'], name 61 | 62 | def test_subscription_new(self): 63 | public, secret, name = self.test_service_create() 64 | data = dict(uuid=self.uuid, service=public) 65 | rv = self.app.post('/subscription', data=data) 66 | self._failing_loader(rv.data) 67 | return public, secret 68 | 69 | def test_subscription_double(self): 70 | public, secret = self.test_subscription_new() 71 | data = dict(uuid=self.uuid, service=public) 72 | rv = self.app.post('/subscription', data=data) 73 | assert rv.status_code == 409 74 | data = json.loads(rv.data) 75 | assert 'error' in data 76 | assert data['error']['id'] == 4 77 | 78 | def test_subscription_delete(self): 79 | public, secret = self.test_subscription_new() 80 | rv = self.app.delete('/subscription?uuid={}&service={}'.format(self.uuid, public)) 81 | self._failing_loader(rv.data) 82 | return public, secret 83 | 84 | def test_subscription_invalid_delete(self): 85 | # Without a just-deleted service there's a chance to get an existing 86 | # one, as a test database isn't created when running tests. 87 | public, secret = self.test_subscription_delete() 88 | rv = self.app.delete('/subscription?uuid={}&service={}'.format(self.uuid, public)) 89 | assert rv.status_code == 409 90 | data = json.loads(rv.data) 91 | assert 'error' in data 92 | assert data['error']['id'] == 11 93 | 94 | def test_subscription_list(self): 95 | public, secret = self.test_subscription_new() 96 | rv = self.app.get('/subscription?uuid={}'.format(self.uuid)) 97 | resp = self._failing_loader(rv.data) 98 | assert 'subscriptions' in resp 99 | assert len(resp['subscriptions']) == 1 100 | assert resp['subscriptions'][0]['service']['public'] == public 101 | 102 | def test_message_send(self, public='', secret=''): 103 | if not public or not secret: 104 | public, secret = self.test_subscription_new() 105 | data = { 106 | "level": random.randint(0, 5), 107 | "message": "Test message - {}".format(self._random_str(20)), 108 | "title": "Test Title - {}".format(self._random_str(5)), 109 | "secret": secret, 110 | } 111 | rv = self.app.post('/message', data=data) 112 | self._failing_loader(rv.data) 113 | return public, secret, data 114 | 115 | def test_message_send_no_subscribers(self): 116 | # We just want to know if the server "accepts" it 117 | public, secret, name = self.test_service_create() 118 | self.test_message_send(public, secret) 119 | 120 | def test_message_receive(self, amount=-1): 121 | if amount <= 0: 122 | self.test_message_send() 123 | amount = 1 124 | 125 | rv = self.app.get('/message?uuid={}'.format(self.uuid)) 126 | resp = self._failing_loader(rv.data) 127 | z = len(resp['messages']) 128 | assert len(resp['messages']) is amount 129 | 130 | # Ensure it is marked as read 131 | rv = self.app.get('/message?uuid={}'.format(self.uuid)) 132 | resp = self._failing_loader(rv.data) 133 | assert len(resp['messages']) is 0 134 | 135 | def test_message_receive_no_subs(self): 136 | self.test_message_send() 137 | rv = self.app.get('/message?uuid={}'.format(uuid4())) 138 | resp = self._failing_loader(rv.data) 139 | assert len(resp['messages']) is 0 140 | 141 | def test_message_receive_multi(self): 142 | self.test_message_mark_read() 143 | 144 | for _ in range(3): 145 | public, secret = self.test_subscription_new() 146 | for _ in range(5): 147 | self.test_message_send(public, secret) 148 | 149 | self.test_message_receive(15) 150 | 151 | def test_message_mark_read(self): 152 | self.test_message_send() 153 | self.app.delete('/message?uuid={}'.format(self.uuid)) 154 | rv = self.app.get('/message?uuid={}'.format(self.uuid)) 155 | resp = self._failing_loader(rv.data) 156 | assert len(resp['messages']) == 0 157 | 158 | def test_message_mark_read_double(self): 159 | self.test_message_mark_read() 160 | 161 | # Read again without sending 162 | self.app.delete('/message?uuid={}'.format(self.uuid)) 163 | rv = self.app.get('/message?uuid={}'.format(self.uuid)) 164 | resp = self._failing_loader(rv.data) 165 | assert len(resp['messages']) == 0 166 | 167 | def test_message_mark_read_multi(self): 168 | # Stress test it a bit 169 | for _ in range(3): 170 | public, secret = self.test_subscription_new() 171 | for _ in range(5): 172 | self.test_message_send(public, secret) 173 | 174 | self.test_message_mark_read() 175 | 176 | def test_service_delete(self): 177 | public, secret = self.test_subscription_new() 178 | # Send a couple of messages, these should be deleted 179 | for _ in range(10): 180 | self.test_message_send(public, secret) 181 | 182 | rv = self.app.delete('/service?secret={}'.format(secret)) 183 | self._failing_loader(rv.data) 184 | 185 | # Does the service not exist anymore? 186 | rv = self.app.get('/service?service={}'.format(public)) 187 | assert 'error' in json.loads(rv.data) 188 | 189 | # Has the subscriptioner been deleted? 190 | rv = self.app.get('/subscription?uuid={}'.format(self.uuid)) 191 | resp = self._failing_loader(rv.data) 192 | assert public not in [l['service']['public'] for l in resp['subscriptions']] 193 | 194 | def test_service_info(self): 195 | public, secret, name = self.test_service_create() 196 | rv = self.app.get('/service?service={}'.format(public)) 197 | data = self._failing_loader(rv.data) 198 | assert 'service' in data 199 | srv = data['service'] 200 | assert srv['name'] == name 201 | assert srv['public'] == public 202 | 203 | def test_service_info_secret(self): 204 | public, secret, name = self.test_service_create() 205 | rv = self.app.get('/service?secret={}'.format(secret)) 206 | data = self._failing_loader(rv.data) 207 | assert 'service' in data 208 | srv = data['service'] 209 | assert srv['name'] == name 210 | assert srv['public'] == public 211 | 212 | def test_service_update(self): 213 | public, secret, name = self.test_service_create() 214 | data = { 215 | "name": self._random_str(10), 216 | "icon": "http://i.imgur.com/{}.png".format(self._random_str(7, False)) 217 | } 218 | rv = self.app.patch('/service?secret={}'.format(secret), data=data).data 219 | self._failing_loader(rv) 220 | 221 | # Test if patched 222 | rv = self.app.get('/service?service={}'.format(public)) 223 | rv = self._failing_loader(rv.data)['service'] 224 | for key in data.keys(): 225 | assert data[key] == rv[key] 226 | 227 | def test_uuid_regex(self): 228 | rv = self.app.get('/service?service={}'.format(self._random_str(20))).data 229 | assert 'error' in json.loads(rv) 230 | 231 | def test_service_regex(self): 232 | rv = self.app.get('/message?uuid={}'.format(self._random_str(20))).data 233 | assert 'error' in json.loads(rv) 234 | 235 | def test_missing_arg(self): 236 | rv = json.loads(self.app.get('/message').data) 237 | assert 'error' in rv and rv['error']['id'] is 7 238 | rv = json.loads(self.app.get('/service').data) 239 | assert 'error' in rv and rv['error']['id'] is 7 240 | 241 | def test_gcm_register(self): 242 | reg_id = self._random_str(40, unicode=False) 243 | data = {'uuid': self.uuid, 'regId': reg_id} 244 | rv = self.app.post('/gcm', data=data).data 245 | self._failing_loader(rv) 246 | return reg_id 247 | 248 | def test_gcm_unregister(self): 249 | self.test_gcm_register() 250 | rv = self.app.delete('/gcm', data={'uuid': self.uuid}).data 251 | self._failing_loader(rv) 252 | 253 | def test_gcm_register_double(self): 254 | self.test_gcm_register() 255 | self.test_gcm_register() 256 | 257 | def test_gcm_send(self): 258 | reg_id = self.test_gcm_register() 259 | public, secret, data = self.test_message_send() 260 | 261 | messages = [m['data'] for m in self.gcm 262 | if reg_id in m['registration_ids']] 263 | 264 | assert len(messages) is 1 265 | assert messages[0]['encrypted'] is False 266 | 267 | message = messages[0]['message'] 268 | assert message['service']['public'] == public 269 | assert message['message'] == data['message'] 270 | 271 | # def test_get_version(self): 272 | # version = self.app.get('/version').data 273 | # 274 | # assert len(version) is 7 275 | # with open('.git/refs/heads/master', 'r') as f: 276 | # assert f.read()[:7] == version 277 | 278 | def test_get_static(self): 279 | files = ['robots.txt', 'favicon.ico'] 280 | 281 | for f in files: 282 | path = os.path.join(self.app_real.root_path, 'static', f) 283 | with open(path, 'rb') as i: 284 | data = self.app.get('/{}'.format(f)).data 285 | assert data == i.read() 286 | 287 | 288 | if __name__ == '__main__': 289 | unittest.main() 290 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from json import dumps 3 | from flask import request, jsonify 4 | from functools import wraps 5 | from models import Service 6 | from shared import zmq_relay_socket 7 | 8 | uuid = compile(r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$') 9 | service = compile(r'^[a-zA-Z0-9]{4}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{12}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{9}$') 10 | is_uuid = lambda s: uuid.match(s) is not None 11 | is_service = lambda s: service.match(s) is not None 12 | is_secret = lambda s: compile(r'^[a-zA-Z0-9]{32}$').match(s) is not None 13 | 14 | QUERY_ACTION_NEW_MESSAGE = 0 15 | QUERY_UPDATE_LISTEN = 1 16 | 17 | 18 | class Error(object): 19 | @staticmethod 20 | def _e(message, error_code, http_status): 21 | return (dumps({'error': {'message': message, 'id': error_code}}), http_status) 22 | 23 | NONE = (dumps({'status': 'ok'}), 200) # OK 24 | INVALID_CLIENT = _e.__func__('Invalid client uuid', 1, 400) # Bad request 25 | INVALID_SERVICE = _e.__func__('Invalid service', 2, 400) # - || - 26 | INVALID_SECRET = _e.__func__('Invalid secret', 3, 400) # - || - 27 | DUPLICATE_LISTEN = _e.__func__('Already subscribed to that service', 4, 409) # Conflict 28 | RATE_TOOFAST = _e.__func__('Whoaw there cowboy, slow down!', 5, 429) # Too many requests 29 | SERVICE_NOTFOUND = _e.__func__('Service not found', 6, 404) 30 | INVALID_PUBKEY = _e.__func__('Invalid public key supplied. Please send a DER formatted base64 encoded key.', 8, 400) # Bad request 31 | CONNECTION_CLOSING = _e.__func__('Connection closing', 9, 499) # Client closed request 32 | NO_CHANGES = _e.__func__('No changes were made', 10, 400) # Bad request 33 | NOT_SUBSCRIBED = _e.__func__('Not subscribed to that service', 11, 409) # Conflict 34 | 35 | @staticmethod 36 | def ARGUMENT_MISSING(arg): 37 | return Error._e('Missing argument {}'.format(arg), 7, 400) # Bad request 38 | 39 | 40 | def has_uuid(f): 41 | @wraps(f) 42 | def df(*args, **kwargs): 43 | client = request.form.get('uuid', '') or request.args.get('uuid', '') 44 | if not client: 45 | return Error.ARGUMENT_MISSING('uuid') 46 | if not is_uuid(client): 47 | return Error.INVALID_CLIENT 48 | return f(*args, client=client, **kwargs) 49 | 50 | return df 51 | 52 | 53 | def has_service(f): 54 | @wraps(f) 55 | def df(*args, **kwargs): 56 | service = request.form.get('service', '') or request.args.get('service', '') 57 | if not service: 58 | return Error.ARGUMENT_MISSING('service') 59 | if not is_service(service): 60 | return Error.INVALID_SERVICE 61 | 62 | srv = Service.query.filter_by(public=service).first() 63 | if not srv: 64 | return Error.SERVICE_NOTFOUND 65 | return f(*args, service=srv, **kwargs) 66 | 67 | return df 68 | 69 | 70 | def has_secret(f): 71 | @wraps(f) 72 | def df(*args, **kwargs): 73 | secret = request.form.get('secret', '') or request.args.get('secret', '') 74 | if not secret: 75 | return Error.ARGUMENT_MISSING('secret') 76 | if not is_secret(secret): 77 | return Error.INVALID_SECRET 78 | 79 | srv = Service.query.filter_by(secret=secret).first() 80 | if not srv: 81 | return Error.SERVICE_NOTFOUND 82 | return f(*args, service=srv, **kwargs) 83 | 84 | return df 85 | 86 | 87 | def queue_zmq_message(message): 88 | zmq_relay_socket.send_string(message) 89 | --------------------------------------------------------------------------------