From 089583960b3cf730b3b20235ea8e62b10cb01cf2 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 16:59:35 +0530 Subject: [PATCH 01/36] Separate micro services --- app/Dockerfile | 11 +++ app/main.py | 94 ++++++++++++++++++++++++ app/requirements.txt | 2 + app/templates/index.html | 25 +++++++ app/templates/success.html | 11 +++ database/Dockerfile | 11 +++ database/main.py | 54 ++++++++++++++ database/requirements.txt | 2 + Dockerfile => job/Dockerfile | 2 - main.py => job/main.py | 63 +++++++++++++++- requirements.txt => job/requirements.txt | 0 11 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 app/Dockerfile create mode 100644 app/main.py create mode 100644 app/requirements.txt create mode 100644 app/templates/index.html create mode 100644 app/templates/success.html create mode 100644 database/Dockerfile create mode 100644 database/main.py create mode 100644 database/requirements.txt rename Dockerfile => job/Dockerfile (92%) rename main.py => job/main.py (63%) rename requirements.txt => job/requirements.txt (100%) 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 -- 2.45.1 From 34282e6f30aa65f85554eefe5191327daa922059 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 17:42:45 +0530 Subject: [PATCH 02/36] fix: with app.app_context() --- database/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/database/main.py b/database/main.py index 8e9d19f..9537bff 100644 --- a/database/main.py +++ b/database/main.py @@ -17,8 +17,9 @@ class Token(db.Model): def __repr__(self): return f'' -# Initialize the database -db.create_all() +# Create an application context +with app.app_context(): + db.create_all() # API to create a new token entry @app.route('/token', methods=['POST']) -- 2.45.1 From c377909954a9e6df6d596edb66b69869b12a260d Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 18:59:37 +0530 Subject: [PATCH 03/36] fix: database check --- database/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/database/main.py b/database/main.py index 9537bff..54c2ada 100644 --- a/database/main.py +++ b/database/main.py @@ -29,6 +29,9 @@ def create_token(): refresh_token = data.get('refresh_token') expiration_seconds = data.get('expiration_seconds') + if not access_token or not refresh_token or not expiration_seconds: + return 'Missing required fields', 400 + new_token = Token(access_token=access_token, refresh_token=refresh_token, expiration_seconds=expiration_seconds) db.session.add(new_token) db.session.commit() -- 2.45.1 From 1d1c2aac9fde64c2296dcedbb6a5cee63741e017 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 19:11:56 +0530 Subject: [PATCH 04/36] fix: app - expose on 0.0.0.0 --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 4dc3538..1d65456 100644 --- a/app/main.py +++ b/app/main.py @@ -91,4 +91,4 @@ def save_tokens(access_token, refresh_token, expiration_seconds): response.raise_for_status() if __name__ == '__main__': - app.run(debug=True, port=5000) \ No newline at end of file + app.run(host='0.0.0.0', debug=True, port=5000) \ No newline at end of file -- 2.45.1 From 29a39cb63375a3e833a2833de32321c97f3d6ee0 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 19:22:08 +0530 Subject: [PATCH 05/36] fix: database - timestamp --- database/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/main.py b/database/main.py index 54c2ada..c4cf177 100644 --- a/database/main.py +++ b/database/main.py @@ -48,7 +48,7 @@ def get_latest_token(): 'access_token': latest_token.access_token, 'refresh_token': latest_token.refresh_token, 'expiration_seconds': latest_token.expiration_seconds, - 'timestamp': latest_token.timestamp.isoformat() + 'timestamp': int(latest_token.timestamp.timestamp()) } return jsonify({'token': token_info}), 200 else: -- 2.45.1 From 516d5db9a0b82ec6ea028943c1ca81d4e9ea89b7 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Wed, 24 Jan 2024 19:25:17 +0530 Subject: [PATCH 06/36] fix: app - import datetime --- app/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/main.py b/app/main.py index 1d65456..7545bd0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import os from flask import Flask, render_template, request, redirect, abort import requests +from datetime import datetime def get_env_variable(var_name): value = os.environ.get(var_name) -- 2.45.1 From 16c94fcaa686bd67a9c301a798b711e7c9f530ef Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 10:55:52 +0530 Subject: [PATCH 07/36] test with github auth --- app/main.py | 36 +++++++++++++++++++++++++++++------- app/templates/index.html | 3 ++- app/templates/success.html | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/main.py b/app/main.py index 7545bd0..b552e2b 100644 --- a/app/main.py +++ b/app/main.py @@ -22,7 +22,12 @@ csrf_protection_string = None @app.route('/') def home(): if is_logged_in(): - return render_template('success.html') + resp_json = requests.get(f'{database_url}/token/latest').json() + access_token = resp_json['token']['access_token'] + user_info = requests.get('https://api.github.com/user', headers={ + 'Authorization': f'Bearer {access_token}' + }).json() + return render_template('success.html', user_info=user_info) # Generate a CSRF protection string global csrf_protection_string @@ -42,19 +47,32 @@ def oauth_redirect(): 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', + # }, + # 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' + # } + # ) + + # TEST: Github OAuth - REMOVE response = requests.post( - 'https://www.inoreader.com/oauth2/token', + 'https://github.com/login/oauth/access_token', headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-agent': 'your-user-agent' + 'Accept': 'application/json' }, 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' + 'client_secret': get_env_variable('CLIENT_SECRET') } ) @@ -62,6 +80,10 @@ def oauth_redirect(): tokens = response.json() + # TEST: Github OAuth - REMOVE + tokens['refresh_token'] = 'N/A' + tokens['expires_in'] = 36000 + # Save tokens for later use save_tokens(tokens['access_token'], tokens['refresh_token'], tokens['expires_in']) diff --git a/app/templates/index.html b/app/templates/index.html index c0b183c..637b6ec 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -15,7 +15,8 @@ var encodedOptionalScopes = encodeURIComponent('{{ optional_scopes }}'); // Construct the URL using Jinja variables - var oauthUrl = `https://www.inoreader.com/oauth2/auth?client_id={{ client_id }}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=${encodedOptionalScopes}&state={{ csrf_protection_string }}`; + // var oauthUrl = `https://www.inoreader.com/oauth2/auth?client_id={{ client_id }}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=${encodedOptionalScopes}&state={{ csrf_protection_string }}`; + var oauthUrl = `https://github.com/login/oauth/authorize?client_id={{ client_id }}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=${encodedOptionalScopes}&state={{ csrf_protection_string }}`; // Redirect to the constructed URL window.location.href = oauthUrl; diff --git a/app/templates/success.html b/app/templates/success.html index 8e2a59c..16207a4 100644 --- a/app/templates/success.html +++ b/app/templates/success.html @@ -6,6 +6,6 @@ Simple Frontend -

Logged In!

+

Logged In as {{ user_info.login }}({{user_info.name}})

-- 2.45.1 From d0c888d6c451a790521a8274181830316fbd827c Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 11:32:03 +0530 Subject: [PATCH 08/36] implement logout --- app/main.py | 30 +++++++++++++++++++++++++++--- app/templates/success.html | 4 ++++ database/main.py | 14 ++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index b552e2b..ee974f3 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,5 @@ import os -from flask import Flask, render_template, request, redirect, abort +from flask import Flask, render_template, request, redirect, abort, url_for, session import requests from datetime import datetime @@ -24,6 +24,10 @@ def home(): if is_logged_in(): resp_json = requests.get(f'{database_url}/token/latest').json() access_token = resp_json['token']['access_token'] + + # set session token id + session['token_id'] = resp_json['token']['id'] + user_info = requests.get('https://api.github.com/user', headers={ 'Authorization': f'Bearer {access_token}' }).json() @@ -87,7 +91,27 @@ def oauth_redirect(): # Save tokens for later use save_tokens(tokens['access_token'], tokens['refresh_token'], tokens['expires_in']) - return redirect('/') + return redirect(url_for('home')) + +# logout +@app.route('/logout') +def logout(): + token_id = session.get('token_id') + + if not token_id: + return redirect(url_for('home')) + + # remove token_id from session + session.pop('token_id', None) + + response = requests.put(f'{database_url}/token/{token_id}', headers={ + 'Content-Type': 'application/json' + }, json={ + 'is_logged_in': False + }) + response.raise_for_status() + + return redirect(url_for('home')) def is_logged_in(): response = requests.get(f'{database_url}/token/latest') @@ -96,7 +120,7 @@ def is_logged_in(): 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 resp_json['token']['is_logged_in'] or False return False def save_tokens(access_token, refresh_token, expiration_seconds): diff --git a/app/templates/success.html b/app/templates/success.html index 16207a4..63b06d5 100644 --- a/app/templates/success.html +++ b/app/templates/success.html @@ -7,5 +7,9 @@

Logged In as {{ user_info.login }}({{user_info.name}})

+ +
+ +
diff --git a/database/main.py b/database/main.py index c4cf177..c46d5b9 100644 --- a/database/main.py +++ b/database/main.py @@ -12,6 +12,7 @@ class Token(db.Model): 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) + is_logged_in = db.Column(db.Boolean, default=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow) def __repr__(self): @@ -45,6 +46,7 @@ def get_latest_token(): if latest_token: token_info = { + 'id': latest_token.id, 'access_token': latest_token.access_token, 'refresh_token': latest_token.refresh_token, 'expiration_seconds': latest_token.expiration_seconds, @@ -54,5 +56,17 @@ def get_latest_token(): else: return '', 204 +# API to update the token based on the id +@app.route('/token/', methods=['PUT']) +def update_token(id): + token = Token.query.get_or_404(id) + data = request.get_json() + token.access_token = data.get('access_token') or token.access_token + token.refresh_token = data.get('refresh_token') or token.refresh_token + token.expiration_seconds = data.get('expiration_seconds') or token.expiration_seconds + token.is_logged_in = data.get('is_logged_in') + db.session.commit() + return '', 204 + if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) -- 2.45.1 From 6614f74c9ffdda1bd50f5e96578919daabeb3574 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 11:34:44 +0530 Subject: [PATCH 09/36] fix database api --- database/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/database/main.py b/database/main.py index c46d5b9..deef244 100644 --- a/database/main.py +++ b/database/main.py @@ -50,6 +50,7 @@ def get_latest_token(): 'access_token': latest_token.access_token, 'refresh_token': latest_token.refresh_token, 'expiration_seconds': latest_token.expiration_seconds, + 'is_logged_in': latest_token.is_logged_in, 'timestamp': int(latest_token.timestamp.timestamp()) } return jsonify({'token': token_info}), 200 -- 2.45.1 From 021f3bcb7cdabf83424b78a9163e751d1b9edfbe Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 11:38:58 +0530 Subject: [PATCH 10/36] use app.secret_key to enable session --- app/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/main.py b/app/main.py index ee974f3..d185a27 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,10 @@ 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') +secret_key = get_env_variable('APP_SECRET_KEY') + +# Set secret key to enable sessions +app.secret_key = secret_key csrf_protection_string = None -- 2.45.1 From 67766a7708fbd54f0497f2e94cc4f4415539f770 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 11:41:58 +0530 Subject: [PATCH 11/36] fix logout method --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index d185a27..fbed310 100644 --- a/app/main.py +++ b/app/main.py @@ -98,7 +98,7 @@ def oauth_redirect(): return redirect(url_for('home')) # logout -@app.route('/logout') +@app.route('/logout', methods=['POST']) def logout(): token_id = session.get('token_id') -- 2.45.1 From 1b7a37c4e1fc3a80e149581222a7a3673a9128e4 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 20:35:02 +0530 Subject: [PATCH 12/36] db-service: Updated table schema & exposed new APIs --- database/main.py | 62 +++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/database/main.py b/database/main.py index deef244..d55ee95 100644 --- a/database/main.py +++ b/database/main.py @@ -1,6 +1,7 @@ from flask import Flask, jsonify, request from flask_sqlalchemy import SQLAlchemy from datetime import datetime +import uuid app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tokens.db' # Use SQLite for simplicity @@ -8,11 +9,13 @@ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) class Token(db.Model): - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.String(36), primary_key=True, default=str(uuid.uuid4())) + email = db.Column(db.String(255), nullable=False) 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) - is_logged_in = db.Column(db.Boolean, default=True) + readwise_api_key = db.Column(db.String(255)) + active = db.Column(db.Boolean, default=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow) def __repr__(self): @@ -26,46 +29,51 @@ with app.app_context(): @app.route('/token', methods=['POST']) def create_token(): data = request.get_json() + email = data.get('email') access_token = data.get('access_token') refresh_token = data.get('refresh_token') expiration_seconds = data.get('expiration_seconds') - if not access_token or not refresh_token or not expiration_seconds: + if not email or access_token or not refresh_token or not expiration_seconds: return 'Missing required fields', 400 - new_token = Token(access_token=access_token, refresh_token=refresh_token, expiration_seconds=expiration_seconds) + # unique email when active is true + existing_token = Token.query.filter_by(email=email, active=True).first() + if existing_token: + return jsonify({'error': 'An active token with this email already exists'}), 400 + + new_token = Token(email=email, access_token=access_token, refresh_token=refresh_token, expiration_seconds=expiration_seconds) db.session.add(new_token) db.session.commit() - return ('', 204) + return jsonify({'id': new_token.id}), 201 -# 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 = { - 'id': latest_token.id, - 'access_token': latest_token.access_token, - 'refresh_token': latest_token.refresh_token, - 'expiration_seconds': latest_token.expiration_seconds, - 'is_logged_in': latest_token.is_logged_in, - 'timestamp': int(latest_token.timestamp.timestamp()) - } - return jsonify({'token': token_info}), 200 - else: - return '', 204 +# API to get the token based on the id +@app.route('/token/', methods=['GET']) +def get_token_by_id(id): + token = Token.query.get_or_404(id) + token_info = { + 'id': token.id, + 'email': token.email, + 'access_token': token.access_token, + 'refresh_token': token.refresh_token, + 'expiration_seconds': token.expiration_seconds, + 'readwise_api_key': token.readwise_api_key, + 'active': token.active, + 'timestamp': int(token.timestamp.timestamp()) + } + return jsonify({'token': token_info}), 200 # API to update the token based on the id @app.route('/token/', methods=['PUT']) -def update_token(id): +def update_token_by_id(id): token = Token.query.get_or_404(id) data = request.get_json() - token.access_token = data.get('access_token') or token.access_token - token.refresh_token = data.get('refresh_token') or token.refresh_token - token.expiration_seconds = data.get('expiration_seconds') or token.expiration_seconds - token.is_logged_in = data.get('is_logged_in') + token.access_token = data.get('access_token', token.access_token) + token.refresh_token = data.get('refresh_token', token.refresh_token) + token.expiration_seconds = data.get('expiration_seconds', token.expiration_seconds) + token.active = data.get('active', token.active) + token.readwise_api_key = data.get('readwise_api_key', token.readwise_api_key) db.session.commit() return '', 204 -- 2.45.1 From 3ec293e1faeea72641d568ce68416528c3525d99 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Tue, 30 Jan 2024 20:35:39 +0530 Subject: [PATCH 13/36] app-service: Updated templates and login logic --- app/main.py | 77 +++++++++++++++++------- app/templates/home.html | 28 +++++++++ app/templates/{index.html => login.html} | 4 +- app/templates/success.html | 15 ----- 4 files changed, 86 insertions(+), 38 deletions(-) create mode 100644 app/templates/home.html rename app/templates/{index.html => login.html} (90%) delete mode 100644 app/templates/success.html diff --git a/app/main.py b/app/main.py index fbed310..d2e153c 100644 --- a/app/main.py +++ b/app/main.py @@ -26,23 +26,28 @@ csrf_protection_string = None @app.route('/') def home(): if is_logged_in(): - resp_json = requests.get(f'{database_url}/token/latest').json() - access_token = resp_json['token']['access_token'] - - # set session token id - session['token_id'] = resp_json['token']['id'] + token_id = session.get('token_id') + resp = requests.get(f'{database_url}/token/{token_id}') + resp.raise_for_status() + resp_json = resp.json() + token = resp_json['token'] user_info = requests.get('https://api.github.com/user', headers={ - 'Authorization': f'Bearer {access_token}' + 'Authorization': f'Bearer {token.get("access_token")}' }).json() - return render_template('success.html', user_info=user_info) + + last_synced = datetime.fromtimestamp(token.get('timestamp')).strftime('%Y-%m-%d %H:%M:%S') + next_sync = datetime.fromtimestamp(token.get('timestamp') + token.get('expiration_seconds')).strftime('%Y-%m-%d %H:%M:%S') + return render_template('home.html', user_info=user_info, + readwise_api_key=token.get('readwise_api_key', None), + last_synced=last_synced, next_sync=next_sync) # 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, + return render_template('login.html', client_id=client_id, redirect_uri=redirect_uri, optional_scopes=optional_scopes, csrf_protection_string=csrf_protection_string) @app.route('/oauth-redirect') @@ -86,15 +91,21 @@ def oauth_redirect(): response.raise_for_status() - tokens = response.json() + token = response.json() # TEST: Github OAuth - REMOVE - tokens['refresh_token'] = 'N/A' - tokens['expires_in'] = 36000 + token['refresh_token'] = 'N/A' + token['expires_in'] = 3600 # Save tokens for later use - save_tokens(tokens['access_token'], tokens['refresh_token'], tokens['expires_in']) + token_id = save_token( + token.get('email'), # for inoreader it's userEmail + token.get('access_token'), + token.get('refresh_token'), + token.get('expires_in') + ) + set_session_token_id(token_id) return redirect(url_for('home')) # logout @@ -108,32 +119,51 @@ def logout(): # remove token_id from session session.pop('token_id', None) + # response = requests.put(f'{database_url}/token/{token_id}', headers={ + # 'Content-Type': 'application/json' + # }, json={ + # 'is_logged_in': False + # }) + # response.raise_for_status() + + return redirect(url_for('home')) + +@app.route('/readwise', methods=['POST']) +def submit_readwise_api(): + token_id = session.get('token_id') + + if not token_id: + return redirect(url_for('home')) + response = requests.put(f'{database_url}/token/{token_id}', headers={ 'Content-Type': 'application/json' }, json={ - 'is_logged_in': False + 'readwise_api_key': request.form.get('readwise_api_key') }) response.raise_for_status() return redirect(url_for('home')) def is_logged_in(): - response = requests.get(f'{database_url}/token/latest') - response.raise_for_status() - if response.status_code == 204: + token_id = session.get('token_id') + if not token_id: return False - elif response.status_code == 200: - resp_json = response.json() - return resp_json['token']['is_logged_in'] or False - return False -def save_tokens(access_token, refresh_token, expiration_seconds): + response = requests.get(f'{database_url}/token/{token_id}') + response.raise_for_status() + resp_json = response.json() + token = resp_json['token'] + + return token.get('active', False) + +def save_token(email, access_token, refresh_token, expiration_seconds): response = requests.post( f'{database_url}/token', headers={ 'Content-Type': 'application/json' }, json={ + 'email': email, 'access_token': access_token, 'refresh_token': refresh_token, 'expiration_seconds': expiration_seconds @@ -141,5 +171,10 @@ def save_tokens(access_token, refresh_token, expiration_seconds): ) response.raise_for_status() + return response.json().get('id') + +def set_session_token_id(token_id): + session['token_id'] = token_id + if __name__ == '__main__': app.run(host='0.0.0.0', debug=True, port=5000) \ No newline at end of file diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..dc1dcbe --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,28 @@ + + + + + + Inoreader To Readwise + + +

Logged In as {{ user_info.login }}({{user_info.name}})

+ + +

Last Synced: {{ last_synced }}

+

Next Synced: {{ next_synced }}

+
+ + +
+ + + +
+ + +
+ +
+ + diff --git a/app/templates/index.html b/app/templates/login.html similarity index 90% rename from app/templates/index.html rename to app/templates/login.html index 637b6ec..5ed3901 100644 --- a/app/templates/index.html +++ b/app/templates/login.html @@ -3,10 +3,10 @@ - Simple Frontend + Inoreader To Readwise - +