diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example index 26b2f37..3cb178a 100644 --- a/docker-compose.yaml.example +++ b/docker-compose.yaml.example @@ -10,10 +10,24 @@ services: ports: - 127.0.0.1:8080:80 depends_on: + - gifted-subs-api - quotes-api - twitch-cache-api - cms + # Gifted subs API service with /data/gifted-subs mounted as database storage + # SECRET_KEY is needed for API key validation + gifted-subs-api: + build: + context: ./gifted-subs-api + volumes: + - /data/gifted-subs:/gifted-subs + environment: + - SQLALCHEMY_DATABASE_URI=sqlite:////gifted-subs/gifted-subs.db + - SECRET_KEY=__SECRET_KEY__ + expose: + - 5000 + # Quotes API service with /data/quotes mounted as database storage # SECRET_KEY is needed for API key validation quotes-api: diff --git a/gifted-subs-api/.gitignore b/gifted-subs-api/.gitignore new file mode 100644 index 0000000..6a18ad4 --- /dev/null +++ b/gifted-subs-api/.gitignore @@ -0,0 +1,96 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + diff --git a/gifted-subs-api/Dockerfile b/gifted-subs-api/Dockerfile new file mode 100644 index 0000000..3e9833f --- /dev/null +++ b/gifted-subs-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/gifted-subs-api/app.py b/gifted-subs-api/app.py new file mode 100644 index 0000000..a995ce2 --- /dev/null +++ b/gifted-subs-api/app.py @@ -0,0 +1,174 @@ +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, GiftedSub + + +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) + + +gifted_sub_fields = { + 'id': flask_restful.fields.Integer(), + 'giver': flask_restful.fields.String(), + 'receiver': flask_restful.fields.String(), + 'time': flask_restful.fields.DateTime(dt_format='iso8601'), + 'created_at': flask_restful.fields.DateTime(dt_format='iso8601'), + 'updated_at': flask_restful.fields.DateTime(dt_format='iso8601'), +} + + +gifted_sub_parser = flask_restful.reqparse.RequestParser() +gifted_sub_parser.add_argument('id', type=int) +gifted_sub_parser.add_argument('giver', type=str, required=True) +gifted_sub_parser.add_argument('receiver', type=str, required=True) +gifted_sub_parser.add_argument('time', type=flask_restful.inputs.datetime_from_iso8601, 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) + + +@login_manager.request_loader +def load_user(request): + key = request.headers.get('X-Gifted-Subs-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 + + +class GiftedSubResource(flask_restful.Resource): + @flask_restful.marshal_with(gifted_sub_fields) + def get(self, id): + q = db.session.query(GiftedSub).filter(GiftedSub.id == id) + gifted_sub = q.first() + if not gifted_sub: + flask_restful.abort(404, message='Gifted sub {0} does not exist'.format(id)) + return gifted_sub, 200 + + @flask_login.login_required + @flask_restful.marshal_with(gifted_sub_fields) + def put(self, id): + args = gifted_sub_parser.parse_args() + now = sqlalchemy.func.now() + q = db.session.query(GiftedSub).filter(GiftedSub.id == id) + gifted_sub = q.first() + if not gifted_sub: + gifted_sub = GiftedSub(id=id, created_at=now) + gifted_sub.giver = args['giver'] + gifted_sub.receiver = args['receiver'] + gifted_sub.time = args['time'] + gifted_sub.updated_at = now + db.session.add(gifted_sub) + db.session.commit() + return gifted_sub, 200 + + @flask_login.login_required + def delete(self, id): + q = db.session.query(GiftedSub).filter(GiftedSub.id == id) + gifted_sub = q.first() + if not gifted_sub: + flask_restful.abort(404, message='Gifted sub {0} does not exist'.format(id)) + db.session.delete(gifted_sub) + db.session.commit() + return None, 204 + + +class GiftedSubsResource(flask_restful.Resource): + @flask_restful.marshal_with(gifted_sub_fields) + def get(self): + args = filter_parser.parse_args() + q = db.session.query(GiftedSub) + if args['filter']: + q = q.filter(GiftedSub.giver.ilike('%{}%'.format(args['filter'])) |\ + GiftedSub.receiver.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(GiftedSub, 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']) + gifted_subs = q.all() + return gifted_subs, 200, {'X-Total-Count': count} + + @flask_login.login_required + @flask_restful.marshal_with(gifted_sub_fields) + def post(self): + args = gifted_sub_parser.parse_args() + if not args['giver']: + flask_restful.abort(400, message='Missing required parameter giver') + if not args['receiver']: + flask_restful.abort(400, message='Missing required parameter receiver') + if not args['time']: + flask_restful.abort(400, message='Missing required parameter time') + now = sqlalchemy.func.now() + q = db.session.query(GiftedSub).filter((GiftedSub.giver == args['giver']) &\ + (GiftedSub.receiver == args['receiver']) &\ + (sqlalchemy.func.DATE(GiftedSub.time) == args['time'].date())) + gifted_sub = q.first() + if not gifted_sub: + gifted_sub = GiftedSub(created_at=now) + gifted_sub.giver = args['giver'] + gifted_sub.receiver = args['receiver'] + gifted_sub.time = args['time'] + gifted_sub.updated_at = now + db.session.add(gifted_sub) + db.session.commit() + url = api.url_for(GiftedSubResource, id=gifted_sub.id, _external=True, _scheme='https') + return gifted_sub, 201, {'Location': url} + + +api.add_resource(GiftedSubResource, '/gifted-subs/') +api.add_resource(GiftedSubsResource, '/gifted-subs') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', threaded=True, debug=False) diff --git a/gifted-subs-api/db.py b/gifted-subs-api/db.py new file mode 100644 index 0000000..bc9c321 --- /dev/null +++ b/gifted-subs-api/db.py @@ -0,0 +1,15 @@ +import flask_sqlalchemy + + +db = flask_sqlalchemy.SQLAlchemy(session_options=dict(autoflush=False)) + + +class GiftedSub(db.Model): + __tablename__ = 'giftedsubs' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + giver = db.Column(db.String) + receiver = db.Column(db.String) + time = db.Column(db.DateTime) + created_at = db.Column(db.DateTime) + updated_at = db.Column(db.DateTime) diff --git a/gifted-subs-api/generate_api_key.py b/gifted-subs-api/generate_api_key.py new file mode 100644 index 0000000..fbd415b --- /dev/null +++ b/gifted-subs-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/gifted-subs-api/requirements.txt b/gifted-subs-api/requirements.txt new file mode 100644 index 0000000..61e7259 --- /dev/null +++ b/gifted-subs-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 e37d4ec..57f7212 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -60,6 +60,15 @@ http { root /twitch-logs; } + location ^~ /gifted-subs/api/ { + rewrite ^/gifted-subs/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://gifted-subs-api:5000/; + } + location ^~ /quotes/api/ { rewrite ^/quotes/api(/.*)$ $1 break; proxy_set_header Host $host;