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..9537bff
--- /dev/null
+++ b/database/main.py
@@ -0,0 +1,55 @@
+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''
+
+# Create an application context
+with app.app_context():
+ 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