diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example index 3c9506a..3815b8a 100644 --- a/docker-compose.yaml.example +++ b/docker-compose.yaml.example @@ -10,6 +10,7 @@ services: ports: - 127.0.0.1:8080:80 depends_on: + - horde-members-api - instagram-api - quotes-api - teespring-api @@ -17,6 +18,19 @@ services: - twitch-subs-api - cms + # Cheese Horde members API service with /data/horde-members mounted as database storage + # SECRET_KEY is needed for API key validation + horde-members-api: + build: + context: ./horde-members-api + volumes: + - /data/horde-members:/horde-members + environment: + - SQLALCHEMY_DATABASE_URI=sqlite:////horde-members/horde-members.db + - SECRET_KEY=__SECRET_KEY__ + expose: + - 5000 + # Instagram API service with /data/instagram mounted as database storage # INSTAGRAM_USERNAME is needed for synchronization instagram-api: diff --git a/horde-members-api/Dockerfile b/horde-members-api/Dockerfile new file mode 100644 index 0000000..3e9833f --- /dev/null +++ b/horde-members-api/Dockerfile @@ -0,0 +1,14 @@ +FROM python:alpine + +WORKDIR /app +COPY . . + +RUN pip install --no-cache-dir --requirement requirements.txt + +RUN addgroup -g 9999 lilia + +EXPOSE 5000 + +USER nobody:lilia + +ENTRYPOINT ["python", "app.py"] diff --git a/horde-members-api/app.py b/horde-members-api/app.py new file mode 100644 index 0000000..f046074 --- /dev/null +++ b/horde-members-api/app.py @@ -0,0 +1,256 @@ +import logging +import os + +import flask +import flask_login +import flask_restful +import flask_restful.fields +import flask_restful.reqparse +import itsdangerous +import sqlalchemy +import sqlalchemy.engine + +from db import db, Member + + +app = flask.Flask(__name__) +app.logger.setLevel(logging.INFO) +app.config.update( + ERROR_404_HELP=False, + SQLALCHEMY_TRACK_MODIFICATIONS=False, + SQLALCHEMY_DATABASE_URI=os.getenv('SQLALCHEMY_DATABASE_URI'), + SECRET_KEY=os.getenv('SECRET_KEY')) + +if app.config.get('SQLALCHEMY_DATABASE_URI', '').startswith('sqlite://'): + @sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, 'connect') + def set_sqlite_pragma(dbapi_connection, connection_record): + dbapi_connection.execute('PRAGMA journal_mode=WAL') + dbapi_connection.execute('PRAGMA synchronous=NORMAL') + +db.init_app(app) +db.create_all(app=app) + +login_manager = flask_login.LoginManager() +login_manager.init_app(app) + +api = flask_restful.Api(app) + + +member_fields = { + 'id': flask_restful.fields.Integer(), + 'nick': flask_restful.fields.String(), + 'x': flask_restful.fields.Float(), + 'y': flask_restful.fields.Float(), + 'created_at': flask_restful.fields.DateTime(dt_format='iso8601'), + 'updated_at': flask_restful.fields.DateTime(dt_format='iso8601'), +} +geometry_fields = { + 'type': flask_restful.fields.String(default='Point'), + 'coordinates': flask_restful.fields.List(flask_restful.fields.Float()), +} +properties_fields = { + 'nick': flask_restful.fields.String(), +} +feature_fields = { + 'type': flask_restful.fields.String(default='Feature'), + 'geometry': flask_restful.fields.Nested(geometry_fields), + 'properties': flask_restful.fields.Nested(properties_fields), +} +features_fields = { + 'type': flask_restful.fields.String(default='FeatureCollection'), + 'features': flask_restful.fields.List(flask_restful.fields.Nested(feature_fields)), +} + + +member_parser = flask_restful.reqparse.RequestParser() +member_parser.add_argument('id', type=int) +member_parser.add_argument('nick', type=str, required=True) +member_parser.add_argument('x', type=float, required=True) +member_parser.add_argument('y', type=float, required=True) + +filter_parser = flask_restful.reqparse.RequestParser() +filter_parser.add_argument('filter', type=str) +filter_parser.add_argument('sort_by', type=str) +filter_parser.add_argument('sort_order', type=str) +filter_parser.add_argument('page_number', type=int) +filter_parser.add_argument('page_size', type=int) + +feature_parser = flask_restful.reqparse.RequestParser() +feature_parser.add_argument('type', type=str, required=True) +feature_parser.add_argument('geometry', type=dict, required=True) +feature_parser.add_argument('properties', type=dict, required=True) + +geometry_parser = flask_restful.reqparse.RequestParser() +geometry_parser.add_argument('type', type=str, location=('geometry',), required=True) +geometry_parser.add_argument('coordinates', type=float, action='append', + location=('geometry',), required=True) + +properties_parser = flask_restful.reqparse.RequestParser() +properties_parser.add_argument('nick', type=str, location=('properties',), required=True) + + +@login_manager.request_loader +def load_user(request): + key = request.headers.get('X-Members-API-Key') + if not key: + return None + s = itsdangerous.TimedJSONWebSignatureSerializer(app.config['SECRET_KEY']) + try: + user = flask_login.UserMixin() + user.id = s.loads(key) + return user + except (itsdangerous.SignatureExpired, itsdangerous.BadSignature): + return None + + +def member2feature(member): + return { + 'geometry': { + 'coordinates': [ + member.x, + member.y, + ], + }, + 'properties': { + 'nick': member.nick, + }, + } + + +class MemberResource(flask_restful.Resource): + @flask_restful.marshal_with(member_fields) + def get(self, id): + q = db.session.query(Member).filter(Member.id == id) + member = q.first() + if not member: + flask_restful.abort(404, message='Member {0} does not exist'.format(id)) + return member, 200 + + @flask_login.login_required + @flask_restful.marshal_with(member_fields) + def put(self, id): + args = member_parser.parse_args() + now = sqlalchemy.func.now() + q = db.session.query(Member).filter(Member.id == id) + member = q.first() + if not member: + member = Member(id=id, created_at=now) + member.nick = args['nick'] + member.x = args['x'] + member.y = args['y'] + member.updated_at = now + db.session.add(member) + db.session.commit() + return member, 200 + + @flask_login.login_required + def delete(self, id): + q = db.session.query(Member).filter(Member.id == id) + member = q.first() + if not member: + flask_restful.abort(404, message='Member {0} does not exist'.format(id)) + db.session.delete(member) + db.session.commit() + return None, 204 + + +class MembersResource(flask_restful.Resource): + @flask_restful.marshal_with(member_fields) + def get(self): + args = filter_parser.parse_args() + q = db.session.query(Member) + if args['filter']: + q = q.filter(Member.nick.ilike('%{}%'.format(args['filter']))) + count = q.count() + if args['sort_order'] == 'random': + q = q.order_by(sqlalchemy.func.random()) + elif args['sort_by']: + col = getattr(Member, args['sort_by'], None) + if col: + if args['sort_order']: + order_by = getattr(col, args['sort_order'], None) + if order_by: + q = q.order_by(order_by()) + else: + q = q.order_by(col) + if args['page_size']: + q = q.limit(args['page_size']) + if args['page_number'] and args['page_size']: + q = q.offset(args['page_number'] * args['page_size']) + members = q.all() + return members, 200, {'X-Total-Count': count} + + @flask_login.login_required + @flask_restful.marshal_with(member_fields) + def post(self): + args = member_parser.parse_args() + if not args['nick']: + flask_restful.abort(400, message='Missing required parameter nick') + if not args['x']: + flask_restful.abort(400, message='Missing required parameter x') + if not args['y']: + flask_restful.abort(400, message='Missing required parameter y') + now = sqlalchemy.func.now() + q = db.session.query(Member).filter(Member.user == args['nick']) + member = q.first() + if not member: + member = Member(created_at=now) + member.user = args['nick'] + member.x = args['x'] + member.y = args['y'] + member.updated_at = now + db.session.add(member) + db.session.commit() + url = api.url_for(MemberResource, id=member.id, _external=True, _scheme='https') + return member, 201, {'Location': url} + + +class FeatureResource(flask_restful.Resource): + @flask_restful.marshal_with(feature_fields) + def get(self, id): + q = db.session.query(Member).filter(Member.id == id) + member = q.first() + if not member: + flask_restful.abort(404, message='Member {0} does not exist'.format(id)) + return member2feature(member), 200 + + +class FeaturesResource(flask_restful.Resource): + @flask_restful.marshal_with(features_fields) + def get(self): + q = db.session.query(Member) + members = q.all() + return {'features': [member2feature(m) for m in members]}, 200 + + @flask_login.login_required + @flask_restful.marshal_with(feature_fields) + def post(self): + feature_args = feature_parser.parse_args() + geometry_args = geometry_parser.parse_args(req=feature_args) + properties_args = properties_parser.parse_args(req=feature_args) + if feature_args['type'] != 'Feature': + flask_restful.abort(400, message='Unexpected feature type') + if geometry_args['type'] != 'Point': + flask_restful.abort(400, message='Unexpected geometry type') + now = sqlalchemy.func.now() + q = db.session.query(Member).filter(Member.nick == properties_args['nick']) + member = q.first() + if not member: + member = Member(created_at=now) + member.nick = properties_args['nick'] + member.x, member.y = geometry_args['coordinates'] + member.updated_at = now + db.session.add(member) + db.session.commit() + url = api.url_for(FeatureResource, id=member.id, _external=True, _scheme='https') + return member2feature(member), 201, {'Location': url} + + +api.add_resource(MemberResource, '/members/') +api.add_resource(MembersResource, '/members') +api.add_resource(FeatureResource, '/features/') +api.add_resource(FeaturesResource, '/features') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', threaded=True, debug=False) diff --git a/horde-members-api/db.py b/horde-members-api/db.py new file mode 100644 index 0000000..8a832ae --- /dev/null +++ b/horde-members-api/db.py @@ -0,0 +1,15 @@ +import flask_sqlalchemy + + +db = flask_sqlalchemy.SQLAlchemy(session_options=dict(autoflush=False)) + + +class Member(db.Model): + __tablename__ = 'members' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + nick = db.Column(db.String) + x = db.Column(db.Float) + y = db.Column(db.Float) + created_at = db.Column(db.DateTime) + updated_at = db.Column(db.DateTime) diff --git a/horde-members-api/generate_api_key.py b/horde-members-api/generate_api_key.py new file mode 100644 index 0000000..fbd415b --- /dev/null +++ b/horde-members-api/generate_api_key.py @@ -0,0 +1,24 @@ +import argparse +import os + +import itsdangerous + + +def generate(secret_key, username, expiration): + s = itsdangerous.TimedJSONWebSignatureSerializer(secret_key, expires_in=expiration) + return s.dumps(username) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('username', metavar='USERNAME', help='user name') + parser.add_argument('expiration', metavar='EXPIRATION', type=int, + help='expiration time in seconds') + args = parser.parse_args() + secret_key = os.getenv('SECRET_KEY') + api_key = generate(secret_key, args.username, args.expiration) + print(api_key.decode('utf-8')) + + +if __name__ == '__main__': + main() diff --git a/horde-members-api/requirements.txt b/horde-members-api/requirements.txt new file mode 100644 index 0000000..61e7259 --- /dev/null +++ b/horde-members-api/requirements.txt @@ -0,0 +1,5 @@ +Flask +Flask-Login +Flask-RESTful +Flask-SQLAlchemy +itsdangerous diff --git a/nginx/nginx.conf b/nginx/nginx.conf index d4d7b24..7f59b7c 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -82,6 +82,15 @@ http { root /twitch-logs; } + location ^~ /horde-members/api/ { + rewrite ^/horde-members/api(/.*)$ $1 break; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + tcp_nodelay on; + proxy_pass http://horde-members-api:5000/; + } + location ^~ /instagram/api/ { rewrite ^/instagram/api(/.*)$ $1 break; proxy_set_header Host $host;