diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..5d1a81a --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.8-slim-buster + +WORKDIR /app + +ADD . /app + +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 5000 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4dc3538 --- /dev/null +++ b/app/main.py @@ -0,0 +1,94 @@ +import os +from flask import Flask, render_template, request, redirect, abort +import requests + +def get_env_variable(var_name): + value = os.environ.get(var_name) + if not value: + raise ValueError(f"Missing required environment variable: {var_name}") + return value + +app = Flask(__name__) + +# Read environment variables outside the route function +client_id = get_env_variable('CLIENT_ID') +redirect_uri = get_env_variable('REDIRECT_URI') +optional_scopes = get_env_variable('OPTIONAL_SCOPES') +database_url = get_env_variable('DATABASE_URL') + +csrf_protection_string = None + +@app.route('/') +def home(): + if is_logged_in(): + return render_template('success.html') + + # Generate a CSRF protection string + global csrf_protection_string + csrf_protection_string = os.urandom(16).hex() + + # Pass dynamic variables to the template + return render_template('index.html', client_id=client_id, redirect_uri=redirect_uri, + optional_scopes=optional_scopes, csrf_protection_string=csrf_protection_string) + +@app.route('/oauth-redirect') +def oauth_redirect(): + auth_code = request.args.get('code') + csrf_token = request.args.get('state') + + # Verify the CSRF protection string + if csrf_token != csrf_protection_string: + abort(400, 'Invalid CSRF token. Please try again.') + + # Exchange authorization code for access and refresh tokens + response = requests.post( + 'https://www.inoreader.com/oauth2/token', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-agent': 'your-user-agent' + }, + data={ + 'code': auth_code, + 'redirect_uri': get_env_variable('REDIRECT_URI'), + 'client_id': get_env_variable('CLIENT_ID'), + 'client_secret': get_env_variable('CLIENT_SECRET'), + 'scope': '', + 'grant_type': 'authorization_code' + } + ) + + response.raise_for_status() + + tokens = response.json() + + # Save tokens for later use + save_tokens(tokens['access_token'], tokens['refresh_token'], tokens['expires_in']) + + return redirect('/') + +def is_logged_in(): + response = requests.get(f'{database_url}/token/latest') + response.raise_for_status() + if response.status_code == 204: + return False + elif response.status_code == 200: + resp_json = response.json() + return resp_json['token']['expiration_seconds'] + resp_json['token']['timestamp'] > datetime.now().timestamp() + return False + +def save_tokens(access_token, refresh_token, expiration_seconds): + response = requests.post( + f'{database_url}/token', + headers={ + 'Content-Type': 'application/json' + }, + json={ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expiration_seconds': expiration_seconds + } + ) + response.raise_for_status() + +if __name__ == '__main__': + app.run(debug=True, port=5000) \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..4b73652 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.1 +requests==2.31.0 \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..c0b183c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,25 @@ + + + + + + Simple Frontend + + + + + + + diff --git a/app/templates/success.html b/app/templates/success.html new file mode 100644 index 0000000..8e2a59c --- /dev/null +++ b/app/templates/success.html @@ -0,0 +1,11 @@ + + + + + + Simple Frontend + + +

Logged In!

+ + diff --git a/database/Dockerfile b/database/Dockerfile new file mode 100644 index 0000000..5d1a81a --- /dev/null +++ b/database/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.8-slim-buster + +WORKDIR /app + +ADD . /app + +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 5000 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/database/main.py b/database/main.py new file mode 100644 index 0000000..8e9d19f --- /dev/null +++ b/database/main.py @@ -0,0 +1,54 @@ +from flask import Flask, jsonify, request +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tokens.db' # Use SQLite for simplicity +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +class Token(db.Model): + id = db.Column(db.Integer, primary_key=True) + access_token = db.Column(db.String(255), nullable=False) + refresh_token = db.Column(db.String(255), nullable=False) + expiration_seconds = db.Column(db.Integer, nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + +# Initialize the database +db.create_all() + +# API to create a new token entry +@app.route('/token', methods=['POST']) +def create_token(): + data = request.get_json() + access_token = data.get('access_token') + refresh_token = data.get('refresh_token') + expiration_seconds = data.get('expiration_seconds') + + new_token = Token(access_token=access_token, refresh_token=refresh_token, expiration_seconds=expiration_seconds) + db.session.add(new_token) + db.session.commit() + + return ('', 204) + +# API to get the latest token entry +@app.route('/token/latest', methods=['GET']) +def get_latest_token(): + latest_token = Token.query.order_by(Token.timestamp.desc()).first() + + if latest_token: + token_info = { + 'access_token': latest_token.access_token, + 'refresh_token': latest_token.refresh_token, + 'expiration_seconds': latest_token.expiration_seconds, + 'timestamp': latest_token.timestamp.isoformat() + } + return jsonify({'token': token_info}), 200 + else: + return '', 204 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/database/requirements.txt b/database/requirements.txt new file mode 100644 index 0000000..86d56fd --- /dev/null +++ b/database/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.1 +Flask-SQLAlchemy==3.1.1 \ No newline at end of file diff --git a/Dockerfile b/job/Dockerfile similarity index 92% rename from Dockerfile rename to job/Dockerfile index 2d677f5..efa4ad0 100644 --- a/Dockerfile +++ b/job/Dockerfile @@ -6,6 +6,4 @@ ADD . /app RUN pip install --no-cache-dir -r requirements.txt -EXPOSE 80 - CMD ["python", "main.py"] diff --git a/main.py b/job/main.py similarity index 63% rename from main.py rename to job/main.py index 656f0cd..c1ea171 100644 --- a/main.py +++ b/job/main.py @@ -6,6 +6,8 @@ import logging DATA_STORE_PATH = "/data/last_update_time.txt" +DATABASE_URL = os.getenv("DATABASE_URL") + logging.basicConfig(level=logging.INFO) class APIHandler: @@ -35,7 +37,7 @@ def get_new_annotations(last_annotation_time): inoreader = APIHandler( "https://www.inoreader.com/reader/api/0/stream/contents", headers = { - 'Authorization': 'Bearer ' + os.getenv("INOREADER_ACCESS_TOKEN") + 'Authorization': 'Bearer ' + get_inoreader_access_token() } ) @@ -99,6 +101,65 @@ def push_annotations_to_readwise(annotations): } ) +def get_inoreader_access_token(): + response = requests.get(f'{DATABASE_URL}/token/latest') + response.raise_for_status() + + if response.status_code == 204: + # throw error - not logged in. Please log in first through the web app + raise Exception("Not logged in. Please log in first through the web app") + elif response.status_code == 200: + resp_json = response.json() + if resp_json['token']['expiration_seconds'] + resp_json['token']['timestamp'] > datetime.now().timestamp(): + return resp_json['token']['access_token'] + else: + return refresh_inoreader_access_token(resp_json['token']['refresh_token']) + + access_token = get_token_from_database() + if not access_token: + access_token = refresh_inoreader_access_token() + + if not access_token: + raise Exception("Unable to get access token. Try logging in again through the web app") + return access_token + +def refresh_inoreader_access_token(refresh_token): + response = requests.post( + 'https://www.inoreader.com/oauth2/token', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data={ + 'refresh_token': refresh_token, + 'client_id': os.getenv("INOREADER_CLIENT_ID"), + 'client_secret': os.getenv("INOREADER_CLIENT_SECRET"), + 'grant_type': 'refresh_token' + } + ) + + response.raise_for_status() + + tokens = response.json() + + # Save tokens for later use + save_tokens(tokens['access_token'], tokens['refresh_token'], tokens['expires_in']) + + return tokens['access_token'] + +def save_tokens(access_token, refresh_token, expiration_seconds): + response = requests.post( + f'{DATABASE_URL}/token', + headers={ + 'Content-Type': 'application/json' + }, + json={ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expiration_seconds': expiration_seconds + } + ) + response.raise_for_status() + def main(): while True: diff --git a/requirements.txt b/job/requirements.txt similarity index 100% rename from requirements.txt rename to job/requirements.txt