diff --git a/README.md b/README.md index 202acd3..9025f5b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Inopy is a Python application that retrieves unread articles from the [Inoreader ## Overview -It uses OAuth authentication for accessing the [API](https://www.inoreader.com/developers) and stores the authentication data in a JSON configuration file located at `/HOME/USER/.config/inopy/config.json`. The program is divided into multiple modules and functions for making API requests, parsing response data, refreshing tokens, and sending notifications. +It uses OAuth authentication for accessing the [API](https://www.inoreader.com/developers) and stores the authentication data in a JSON configuration file located at `/HOME/USER/.inopy/config/config.json`. The program is divided into multiple modules and functions for making API requests, parsing response data, refreshing tokens, sending notifications and logging the processes. The code is organized into the following modules: @@ -20,6 +20,7 @@ The code is organized into the following modules: - `oauth.py`: Implements the OAuth authentication flow using Flask. - `refresh.py`: Contains a `refresh` function for refreshing OAuth access and refresh tokens and updating the configuration file. - `notif.py`: Provides a function for sending notifications using D-Bus (only tested with Cinnamon desktop environment). +- `logs.py`: Defines logging options. ## Installation @@ -39,7 +40,7 @@ pip install requests pydbus flask waitress ## Usage -The first time `ino.py` module is run, it will check if `config.json` exists in `/HOME/USER/.config/inopy/`. If not it will prompt user for configuration details and create the `config.json` file. The file should contain the OAuth endpoint, client ID, client secret, callback URL, scope, CSRF value and home URL. +The first time `ino.py` module is run, it will check if `config.json` exists in `/HOME/USER/.inopy/config/`. If not it will prompt user for configuration details and create the `config.json` file. The file should contain the OAuth endpoint, client ID, client secret, callback URL, scope, CSRF value and home URL. It should also contain the Inoreader API endpoints, notification labels, production status, browser path, host and port. diff --git a/config.py b/config.py index 2b603fc..4e18561 100644 --- a/config.py +++ b/config.py @@ -32,6 +32,11 @@ import json import os +import logging +from logs import LogFile + +# Set logs file +log_file = LogFile() ''' ================================================================ @@ -51,8 +56,10 @@ import os def get_config(config_path, config_file_path): if os.path.exists(config_path): + logging.info(f'Found config directory at {config_path}') if os.path.exists(config_file_path): + logging.info(f'Found config file at {config_file_path}!') pass else: @@ -64,7 +71,8 @@ def get_config(config_path, config_file_path): # If the config directory doesn't exist, create it os.mkdir(config_path) - print(f'{config_path} created!') + logging.info(f'{config_path} created!') + create_file(config_file_path) # load config content @@ -167,7 +175,8 @@ def create_file(config_file_path): with open(config_file_path, "w") as file: json.dump(config, file, indent=4) - print(f"{config_file_path} created successfully!") + #print(f"{config_file_path} created successfully!") + logging.info(f'Created config file at {config_file_path}!') ''' ============================================== @@ -182,7 +191,7 @@ def config(): # Set the path of config file to # /HOME/USER/.config/inopy/config.json - config_path = os.path.join(os.environ['HOME'], '.config/inopy') + config_path = os.path.join(os.environ['HOME'], '.inopy/config') config_file = 'config.json' config_file_path = os.path.join(config_path, config_file) diff --git a/ino.py b/ino.py index 55713dc..21beb83 100644 --- a/ino.py +++ b/ino.py @@ -25,7 +25,7 @@ It performs token refreshing process if the API request returns an unauthorized status code. - Inopy is structured into functions and modules for making API requests, parsing response data, refreshing tokens and sending notifications. + Inopy is structured into functions and modules for making API requests, parsing response data, refreshing tokens, sending notifications and logging the processes. For more information about OAuth authentication, plase see @@ -35,9 +35,14 @@ import requests import json import notif +import logging from config import config -from oauth import app, run_app +from oauth import run_app from refresh import refresh +from logs import LogFile + +# Set logs file +log_file = LogFile() ''' =========================================== @@ -100,7 +105,12 @@ subscriptions = {} categories = [] # Make API request to get unread counts -unread_response = APIrequest(unread_counts_url, bearer) + +try: + unread_response = APIrequest(unread_counts_url, bearer) + +except Exception as e: + logging.debug(e) ''' =================================================== @@ -138,34 +148,47 @@ unread_response = APIrequest(unread_counts_url, bearer) ============================================ ''' -# Check for 403 error case -if unread_response.status_code == 403: - run_app() +try: - with open(config_path) as config_file: - new_config = json.load(config_file) + # Check for 403 error case + if unread_response.status_code == 403: + logging.info('Token not available: starting oauth process...') + run_app() - bearer = new_config['oauth']['bearer'] + with open(config_path) as config_file: + new_config = json.load(config_file) - unread_response = APIrequest(unread_counts_url, bearer) + bearer = new_config['oauth']['bearer'] -# Check for 401 error case -elif unread_response.status_code == 401: - refresh(config_path, endpoint, client_id, client_secret, refresh_token) + unread_response = APIrequest(unread_counts_url, bearer) - with open(config_path) as config_file: - new_config = json.load(config_file) + # Check for 401 error case + elif unread_response.status_code == 401: + logging.info('Token expired: starting refresh process...') + refresh(config_path, endpoint, client_id, client_secret, refresh_token) - bearer = new_config['oauth']['bearer'] - - unread_response = APIrequest(unread_counts_url, bearer) + with open(config_path) as config_file: + new_config = json.load(config_file) -# Proceed with the code execution -elif unread_response.status_code == 200: - pass + bearer = new_config['oauth']['bearer'] + + unread_response = APIrequest(unread_counts_url, bearer) + + # Proceed with the code execution + elif unread_response.status_code == 200: + logging.info('API request ok: retrieving data...') + pass + +except Exception as e: + logging.debug(e) # Make API request to get feeds list -feeds_list_response = APIrequest(feeds_list_url, bearer) + +try: + feeds_list_response = APIrequest(feeds_list_url, bearer) + +except Exception as e: + logging.debug(e) ''' ======================================= @@ -255,8 +278,14 @@ for unread_id, count in unreadcounts.items(): ==================================== ''' -if message != "": - notif.send_notification(summary, message) +try: + if message != "": + notif.send_notification(summary, message) + logging.info('Notification successfully sent!') -else: - pass \ No newline at end of file + else: + logging.info('No unread articles. Notification not sent.') + pass + +except Exception as e: + logging.debug(e) \ No newline at end of file diff --git a/logs.py b/logs.py new file mode 100644 index 0000000..b9a73f2 --- /dev/null +++ b/logs.py @@ -0,0 +1,41 @@ +""" +========================================================================================= + + Copyright © 2023 Alexandre Racine + + This file is part of Inopy. + + Inopy is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + Inopy is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with Inopy. If not, see . + +========================================================================================= + + DISCLAIMER: parts of this code and comments blocks were created + with the help of ChatGPT developped by OpenAI + Followed by human reviewing, refactoring and fine-tuning. + +========================================================================================= + + This module aims to log the program processes. + +========================================================================================= +""" + +import os +import logging + +# Setting the logs file +def LogFile(): + + # Determine the user's home directory + logs_dir = os.path.join(os.environ['HOME'], '.inopy/logs') + os.makedirs(logs_dir, exist_ok=True) + + # Configure logging + log_file = os.path.join(logs_dir, "inopy.log") + + logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', +datefmt='%m/%d/%Y %I:%M:%S %p', filename=log_file, level=logging.DEBUG) \ No newline at end of file diff --git a/oauth.py b/oauth.py index 27801cf..0cd3097 100644 --- a/oauth.py +++ b/oauth.py @@ -34,225 +34,255 @@ import time import json import subprocess import threading +import logging from flask import Flask, request, redirect, render_template from config import config from waitress import serve +from logs import LogFile -''' -=================================== - Load configuration values - - Extract values from the config - dictionary - - Build the URL for authorization -=================================== -''' - -config = config() - -endpoint = config['endpoint'] -client_id = config['client_id'] -client_secret = config['client_secret'] -callback = config['callback'] -scope = config['scope'] -CSRF = config['csrf'] -home_url = config['home_url'] -prod_status = config['prod_status'] -browser_path = config['browser_path'] -host = config['host'] -port = config['port'] -config_file_path = config['config_file_path'] - -url = 'https://www.inoreader.com/oauth2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}'.format(client_id, callback, scope, CSRF) - -''' -========================== - Initiate the Flask app -========================== -''' - -app = Flask(__name__) - -''' -================================== - When the home url is accessed: - - * Redirect the user to the - authorization URL -================================== -''' - -@app.route('/') -def index(): - return redirect(url) - -''' -===================================================== - When the oauth-callback url is accessed: - - * Get the authorization code and other - parameters from the callback URL - - * Check CSRF validation token and if there - is an error parameter in the URL - - * Request bearer token and refresh token - - * Save the bearer token and refresh token - to the config file - - * If process is successfull: - - * render the success template with the - response - - * If CSRF failed: - - * render the CSRF failure template - - * If an error parameter is present in the URL: - - * Render the OAuth error template -===================================================== -''' - -@app.route('/oauth-callback') - -def oauth_callback(): - authorization_code = request.args.get('code') - csrf_check = request.args.get('state') - error_param = request.args.get('error') - - csrf = True if csrf_check == CSRF else False - error = True if error_param is not None else False - - if csrf and not error: - access_token_url = endpoint - - # Prepare data to request bearer token - payload = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'client_id': client_id, - 'client_secret': client_secret, - 'redirect_uri': callback - } - - # Request bearer token and refresh token - response = requests.post(access_token_url, data=payload) - - if response.status_code == 200: - - access_token = response.json()['access_token'] - refresh_token = response.json()['refresh_token'] - - with open(config_file_path, 'r+') as config_file: - config = json.load(config_file) - - # Save the bearer token and refresh token to the config file - config['oauth']['bearer'] = access_token - config['oauth']['refresh_token'] = refresh_token - - config_file.seek(0) - - json.dump(config, config_file, indent=4) - config_file.truncate() - - return render_template('success.html', response=(access_token, refresh_token)) - - else: - if not csrf: - return render_template('csrf-failed.html', response=(CSRF, csrf_check)) - - elif error: - error_content = request.args.get('error_description') - return render_template('oauth-error.html', response=(error_param, error_content)) - else: - pass - -''' -====================================== - When the shutdown url is accessed: - - * Shut down the Flask server - gracefully -====================================== -''' - -@app.route('/shutdown') -def shutdown(): - request.environ.get('werkzeug.server.shutdown') - return 'Close this browser to terminate the process!' - -''' -====================================== - Define a function to start the - Flask server using Waitress in - production mode -====================================== -''' - -def start_server(): - serve(app, host=host, port=port) - -''' -====================================== - Define a function to start the - production server inside a new - thread. - - This is to ensure the server is - actually already running before - opening the web browser -====================================== -''' - -def run_prod(): - server_thread = threading.Thread(target=start_server) - server_thread.start() - time.sleep(2) - - # Launch a separate browser process with a new profile - subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"]) - subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url]) - -''' -====================================== - Define a function to start the - development server inside a new - thread. - - This is to ensure the server is - actually already running before - opening the web browser -====================================== -''' - -def run_dev(): - server_thread = threading.Thread(target=start_server) - server_thread.start() - time.sleep(2) - - # Open the home URL in the default web browser - webbrowser.open(home_url) - app.run() - -''' -====================================== - Determine whether to run the - production or development server - based on the config -====================================== -''' +# Set logs file +log_file = LogFile() def run_app(): - - if prod_status == "true": - run_prod() - - else: - run_dev() + + ''' + =================================== + Load configuration values + + Extract values from the config + dictionary + + Build the URL for authorization + =================================== + ''' + + conf = config() + + endpoint = conf['endpoint'] + client_id = conf['client_id'] + client_secret = conf['client_secret'] + callback = conf['callback'] + scope = conf['scope'] + CSRF = conf['csrf'] + home_url = conf['home_url'] + prod_status = conf['prod_status'] + browser_path = conf['browser_path'] + host = conf['host'] + port = conf['port'] + config_file_path = conf['config_file_path'] + + url = 'https://www.inoreader.com/oauth2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}'.format(client_id, callback, scope, CSRF) + + ''' + ========================== + Initiate the Flask app + ========================== + ''' + + app = Flask(__name__) + + ''' + ================================== + When the home url is accessed: + + * Redirect the user to the + authorization URL + ================================== + ''' + + try: + @app.route('/') + def index(): + return redirect(url) + + logging.info('Redirecting to authorization URL...') + + except Exception as e: + logging.debug(e) + + ''' + ===================================================== + When the oauth-callback url is accessed: + + * Get the authorization code and other + parameters from the callback URL + + * Check CSRF validation token and if there + is an error parameter in the URL + + * Request bearer token and refresh token + + * Save the bearer token and refresh token + to the config file + + * If process is successfull: + + * render the success template with the + response + + * If CSRF failed: + + * render the CSRF failure template + + * If an error parameter is present in the URL: + + * Render the OAuth error template + ===================================================== + ''' + + try: + @app.route('/oauth-callback') + + def oauth_callback(): + authorization_code = request.args.get('code') + csrf_check = request.args.get('state') + error_param = request.args.get('error') + + csrf = True if csrf_check == CSRF else False + error = True if error_param is not None else False + + if csrf and not error: + access_token_url = endpoint + + # Prepare data to request bearer token + payload = { + 'grant_type': 'authorization_code', + 'code': authorization_code, + 'client_id': client_id, + 'client_secret': client_secret, + 'redirect_uri': callback + } + + # Request bearer token and refresh token + response = requests.post(access_token_url, data=payload) + + if response.status_code == 200: + + access_token = response.json()['access_token'] + refresh_token = response.json()['refresh_token'] + + with open(config_file_path, 'r+') as config_file: + config = json.load(config_file) + + # Save the bearer token and refresh token to the config file + config['oauth']['bearer'] = access_token + config['oauth']['refresh_token'] = refresh_token + + config_file.seek(0) + + json.dump(config, config_file, indent=4) + config_file.truncate() + + logging.info('New token obtained successfully...') + return render_template('success.html', response=(access_token, refresh_token)) + + else: + if not csrf: + logging.warning('CRSF validation failed...') + return render_template('csrf-failed.html', response=(CSRF, csrf_check)) + + elif error: + error_content = request.args.get('error_description') + logging.debug(error_content) + return render_template('oauth-error.html', response=(error_param, error_content)) + + else: + pass + + except Exception as e: + logging.debug(e) + + ''' + ====================================== + When the shutdown url is accessed: + + * Shut down the Flask server + gracefully + ====================================== + ''' + + try: + @app.route('/shutdown') + def shutdown(): + request.environ.get('werkzeug.server.shutdown') + logging.info('Shutting down Flask server...') + return 'Close this browser to terminate the process!' + + except Exception as e: + logging.debug(e) + + ''' + ====================================== + Define a function to start the + Flask server using Waitress in + production mode + ====================================== + ''' + + def start_server(): + serve(app, host=host, port=port) + + ''' + ====================================== + Define a function to start the + production server inside a new + thread. + + This is to ensure the server is + actually already running before + opening the web browser + ====================================== + ''' + + def run_prod(): + logging.info('Running program in production mode...') + server_thread = threading.Thread(target=start_server) + server_thread.start() + time.sleep(2) + + # Launch a separate browser process with a new profile + subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"]) + subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url]) + + ''' + ====================================== + Define a function to start the + development server inside a new + thread. + + This is to ensure the server is + actually already running before + opening the web browser + ====================================== + ''' + + def run_dev(): + logging.info('Running program in development mode...') + server_thread = threading.Thread(target=start_server) + server_thread.start() + time.sleep(2) + + # Open the home URL in the default web browser + webbrowser.open(home_url) + app.run() + + ''' + ====================================== + Determine whether to run the + production or development server + based on the config + ====================================== + ''' + + try: + if prod_status == "true": + run_prod() + + else: + run_dev() + + except Exception as e: + logging.debug(e) ''' ====================================== diff --git a/refresh.py b/refresh.py index 6582e6e..20025a6 100644 --- a/refresh.py +++ b/refresh.py @@ -58,6 +58,11 @@ import requests import json +import logging +from logs import LogFile + +# Set logs file +log_file = LogFile() def refresh(config_path, endpoint, client_id, client_secret, refresh_token): headers = {"Content-type": "application/x-www-form-urlencoded"} @@ -72,10 +77,10 @@ def refresh(config_path, endpoint, client_id, client_secret, refresh_token): response = requests.post(endpoint, data=payload, headers=headers) if response.status_code == 200: - print("Request was successful.") + logging.info("Request was successful. Token was refreshed...") else: - print("Request failed with status code:", response.status_code) + logging.debug(f'Request failed with status code: {response.status_code}') data = json.loads(response.text)