From f4fe6e717ed47c4a5ffa8dead29cceb21ed4609b Mon Sep 17 00:00:00 2001 From: Alexandre Racine Date: Fri, 16 Jun 2023 22:51:47 +0200 Subject: [PATCH] Program documentation * Comments the code * Includes a README file --- .gitignore | 1 - README.md | 62 +++++++++ config.py | 257 +++++++++++++++++++++++------------- icons/inoreader.png | Bin 0 -> 5035 bytes ino.py | 212 +++++++++++++++++++---------- notif.py | 52 +++++--- oauth.py | 315 +++++++++++++++++++++++++++++--------------- refresh.py | 64 +++++---- 8 files changed, 659 insertions(+), 304 deletions(-) create mode 100644 README.md create mode 100644 icons/inoreader.png diff --git a/.gitignore b/.gitignore index a5d11de..ba0430d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -tokens.txt __pycache__/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..202acd3 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Inopy + +Inopy is a Python application that retrieves unread articles from the [Inoreader](https://www.inoreader.com) API and sends a notification if there are any. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Usage](#usage) +- [License](#license) + +## 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. + +The code is organized into the following modules: + +- `ino.py`: The main module that retrieves unread articles, handles token refreshing, and sends notifications. +- `config.py`: Contains configuration settings used by other 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). + +## Installation + +To use Inopy, you need to follow these steps: + +1. Install the required dependencies by running the following command: + +```bash +pip install requests pydbus flask waitress +``` + +2. Clone or download this repository. + +3. Run the `ino.py` module the first time and set up the configuration file by providing the necessary OAuth, API endpoint and notifications details. + +4. Run the `ino.py` module to retrieve unread articles and receive notifications. + +## 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. + +It should also contain the Inoreader API endpoints, notification labels, production status, browser path, host and port. + +Once the configuration is set up, you can adapt some of the default values. Typically, check and if necessary adapt the `prod` section of the file. It defines whether the program is run in production or development mode. + +To set a cron in Linux triggering the program for a notification, create a bash script containing the following code + +```bash +#!/bin/bash +export DISPLAY=:0.0 +export XAUTHORITY=/home/user/.Xauthority # adapt with your username +export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus + +python path_to_your_ino.py # adapt with the path to your program directory +``` +and define the cron job pointing to the bash script created. + +## License + +Inopy is released under the GNU General Public License version 3 or later. You can redistribute it and/or modify it under the terms of the license. For more details, please refer to the [GNU General Public License](https://www.gnu.org/licenses/). \ No newline at end of file diff --git a/config.py b/config.py index 8f61d78..2b603fc 100644 --- a/config.py +++ b/config.py @@ -1,115 +1,194 @@ +""" +========================================================================================= + + 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 code consists of two main functions: get_config and create_file. + + The get_config function check if the config directory or the config file exist. If not it creates config directory and/or config file and then gets the configuration data. + + The create_file function is called if the configuration file should be created. It prompts the user to enter configuration details and save them in the config file. + + Finally the config function serves as a wrapper function that sets the paths and calls get_config to retrieve the configuration data. + +========================================================================================= +""" + import json import os +''' +================================================================ + Create a function to check if the config directory + and the config file exist. + + If the config file or the config directory don't exist + create them. + + Read the config file and load its contents into a dictionary. + + Extract the necessary values from the config dictionary, + create a dictionary of local variables and return it. +================================================================ +''' + def get_config(config_path, config_file_path): + + if os.path.exists(config_path): + + if os.path.exists(config_file_path): + pass + + else: - if os.path.exists(config_path): + # If the config file doesn't exist, create it. + create_file(config_file_path) + + else: - if os.path.exists(config_file_path): - pass - - else: - create_file(config_file_path) - else: - os.mkdir(config_path) - print(f'{config_path} created!') - create_file(config_file_path) + # If the config directory doesn't exist, create it + os.mkdir(config_path) + print(f'{config_path} created!') + create_file(config_file_path) + + # load config content + with open(config_file_path) as config_file: + config = json.load(config_file) + + # Extract the necessary values from the config dictionary + bearer = config['oauth']['bearer'] + refresh_token = config['oauth']['refresh_token'] + endpoint = config['oauth']['endpoint'] + client_id = config['oauth']['client_id'] + client_secret = config['oauth']['client_secret'] + callback = config['oauth']['callback'] + scope = config['oauth']['scope'] + csrf = config['oauth']['csrf'] + home_url = config['oauth']['home_url'] - # Load the config file - with open(config_file_path) as config_file: - config = json.load(config_file) + unread_counts_url = config['inoapi']['unread_counts_url'] + feeds_list_url = config['inoapi']['feeds_list_url'] - bearer = config['oauth']['bearer'] - refresh_token = config['oauth']['refresh_token'] - endpoint = config['oauth']['endpoint'] - client_id = config['oauth']['client_id'] - client_secret = config['oauth']['client_secret'] - callback = config['oauth']['callback'] - scope = config['oauth']['scope'] - csrf = config['oauth']['csrf'] - home_url = config['oauth']['home_url'] + summary = config['notification']['summary'] + singular_article = config['notification']['singular_article'] + plural_articles = config['notification']['plural_articles'] - unread_counts_url = config['inoapi']['unread_counts_url'] - feeds_list_url = config['inoapi']['feeds_list_url'] + prod_status = config['prod']['status'] + browser_path = config['prod']['browser_path'] + host = config['prod']['host'] + port = config['prod']['port'] + + variables = locals() + return variables - summary = config['notification']['summary'] - singular_article = config['notification']['singular_article'] - plural_articles = config['notification']['plural_articles'] +''' +========================================================== + Create a function to prompt the user to enter details + for the configuration file. - prod_status = config['prod']['status'] - browser_path = config['prod']['browser_path'] - host = config['prod']['host'] - port = config['prod']['port'] - - variables = locals() - - return variables + Create the configuration dictionary. + + Write the configuration dictionary to the config file. +========================================================== +''' def create_file(config_file_path): - config = {} - - # Ask user for input - - print("\nEnter details about OAuth authentication: \n") + + config = {} - endpoint = input("Enter OAuth endpoint: ") - client_id = input("Enter your client id: ") - client_secret = input("Enter your client secret: ") - callback = input("Enter your callback URL: ") - scope = input("Enter the API scope (e.g. read OR read write): ") + # prompt user for configuration data + print("\nEnter details about OAuth authentication: \n") - print("\nEnter details about Inoreader API: \n") - - unread_counts_url = input("Enter URL for unread articles: ") - feeds_list_url = input("Enter URL for feeds lists: ") + endpoint = input("Enter OAuth endpoint: ") + client_id = input("Enter your client id: ") + client_secret = input("Enter your client secret: ") + callback = input("Enter your callback URL: ") + scope = input("Enter the API scope (e.g. read OR read write): ") - print("\nEnter details about notification message: \n") + print("\nEnter details about Inoreader API: \n") - summary = input("Enter summary (title) for notification: ") - singular_article = input("Enter singular label if there is only one unread article (e.g. new article in feed): ") - plural_articles = input("Enter plural label if there are many unread articles (e.g. new articles in feed): ") + unread_counts_url = input("Enter URL for unread articles: ") + feeds_list_url = input("Enter URL for feeds lists: ") + print("\nEnter details about notification message: \n") - # Create nested JSON structure - config["oauth"] = { - "bearer": "", - "refresh_token": "", - "endpoint": endpoint, - "client_id": client_id, - "client_secret": client_secret, - "callback": callback, - "scope": scope, - "csrf": "4902358490258", - "home_url": "http://localhost:5000" - } + summary = input("Enter summary (title) for notification: ") + singular_article = input("Enter singular label if there is only one unread article (e.g. new article in feed): ") + plural_articles = input("Enter plural label if there are many unread articles (e.g. new articles in feed): ") - config["inoapi"] = { - "unread_counts_url": unread_counts_url, - "feeds_list_url": feeds_list_url - } + # Create the configuration dictionary + config["oauth"] = { + "bearer": "", + "refresh_token": "", + "endpoint": endpoint, + "client_id": client_id, + "client_secret": client_secret, + "callback": callback, + "scope": scope, + "csrf": "4902358490258", + "home_url": "http://localhost:5000" + } - config["notification"] = { - "summary": summary, - "singular_article": singular_article, - "plural_articles": plural_articles - } + config["inoapi"] = { + "unread_counts_url": unread_counts_url, + "feeds_list_url": feeds_list_url + } - config["prod"] = { - "status": "true", - "browser_path": "/usr/bin/firefox", - "host": "0.0.0.0", - "port": "5000" - } + config["notification"] = { + "summary": summary, + "singular_article": singular_article, + "plural_articles": plural_articles + } - # Save config to a file - with open(config_file_path, "w") as file: - json.dump(config, file, indent=4) + config["prod"] = { + "status": "true", + "browser_path": "/usr/bin/firefox", + "host": "0.0.0.0", + "port": "5000" + } - print(f"{config_file_path} created successfully!") + # Write the config data to the config file + with open(config_file_path, "w") as file: + json.dump(config, file, indent=4) + + print(f"{config_file_path} created successfully!") + +''' +============================================== + Create a function to set the paths for the + config directory and file. + + Get the configuration data and return it. +============================================== +''' def config(): - config_path = os.path.join(os.environ['HOME'], '.config/inopy') - config_file = 'config.json' - config_file_path = os.path.join(config_path, config_file) - data = get_config(config_path, config_file_path) - return data \ No newline at end of file + + # Set the path of config file to + # /HOME/USER/.config/inopy/config.json + config_path = os.path.join(os.environ['HOME'], '.config/inopy') + config_file = 'config.json' + config_file_path = os.path.join(config_path, config_file) + + # Get and return the configuration data + data = get_config(config_path, config_file_path) + return data + +if __name__ == '__main__': + config = config() \ No newline at end of file diff --git a/icons/inoreader.png b/icons/inoreader.png new file mode 100644 index 0000000000000000000000000000000000000000..dd5328681b1407bee53c6aa869a5cbad8e4ae54a GIT binary patch literal 5035 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 literal 0 HcmV?d00001 diff --git a/ino.py b/ino.py index 5c57eca..55713dc 100644 --- a/ino.py +++ b/ino.py @@ -1,4 +1,6 @@ """ +========================================================================================= + Copyright © 2023 Alexandre Racine This file is part of Inopy. @@ -9,13 +11,13 @@ 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. - ------------------------------------------------------------------------------------- +========================================================================================= Inopy retrieves unread articles from the Inoreader API and sends a notification if there are any unread articles. @@ -26,48 +28,51 @@ Inopy is structured into functions and modules for making API requests, parsing response data, refreshing tokens and sending notifications. For more information about OAuth authentication, plase see + +========================================================================================= """ import requests import json import notif -import time -import sys -import os from config import config from oauth import app, run_app from refresh import refresh -""" - Read the configuration file. - - Get the bearer token from the config - and set it accordingly to Inoreader API - specifications. - - Get the API endpoint URL from the config - and send a GET request to the API. -""" +''' +=========================================== + Define functions to make an API request + with bearer token and parse response + data as JSON +=========================================== +''' def APIrequest(url, bearer): bearer_string = 'Bearer {}'.format(bearer) headers = {'Authorization': bearer_string} response = requests.get(url, headers=headers) - print(response.status_code) return response -# Parse the response as JSON def getData(response): data = json.loads(response.text) return data -""" - Refresh the bearer token if it expired. - Update the bearer and refresh token - in the config -""" +''' +=================================== + Initialize an empty message + string for the notification +=================================== +''' message = "" + +''' +=========================================== + Load configuration settings and + retrieve necessary configuration values +=========================================== +''' + config = config() bearer = config['bearer'] @@ -81,102 +86,177 @@ refresh_token = config['refresh_token'] summary = config['summary'] singular_article = config['singular_article'] plural_articles = config['plural_articles'] -singular_article = config['singular_article'] -plural_articles = config['plural_articles'] + +''' +========================================= + Create dictionaries and list to store + unread counts, subscriptions and feed + categories (folders) +========================================= +''' unreadcounts = {} subscriptions = {} categories = [] -# Make a request to get unread counts +# Make API request to get unread counts unread_response = APIrequest(unread_counts_url, bearer) -""" - If unauthorized (401) status code - is received, refresh the bearer token - and make a new request with the updated token. -""" +''' +=================================================== + If the response status code is 403 (Forbidden): + * Run the Flask app to get a bearer token + + * Load the updated configuration file + + * Update the bearer token with the new value + from the updated config and make a new API + request with the updated bearer token +=================================================== +''' + +''' +====================================================== + If the response status code is 401 (Unauthorized): + + * Refresh the bearer token + + * Load the updated configuration file + + * Update the bearer token with the new value + from the updated config and make a new API + request with the updated bearer token +====================================================== +''' + +''' +============================================ + If the response status code is 200 (OK): + + * proceed with the code execution +============================================ +''' + +# Check for 403 error case if unread_response.status_code == 403: - print(unread_response.status_code) - run_app() - #new_config = config() - #bearer = new_config['bearer'] + run_app() + with open(config_path) as config_file: new_config = json.load(config_file) - bearer = new_config['oauth']['bearer'] - print(bearer) - unread_response = APIrequest(unread_counts_url, bearer) - print(unread_response.text) + bearer = new_config['oauth']['bearer'] + + unread_response = APIrequest(unread_counts_url, bearer) + +# Check for 401 error case elif unread_response.status_code == 401: refresh(config_path, endpoint, client_id, client_secret, refresh_token) - #new_config = config() - #bearer = new_config['bearer'] + with open(config_path) as config_file: new_config = json.load(config_file) + bearer = new_config['oauth']['bearer'] - print(bearer) + unread_response = APIrequest(unread_counts_url, bearer) +# Proceed with the code execution elif unread_response.status_code == 200: pass - -""" - Get the list of feeds - Parse the response data - Parse the unread counts data -""" +# Make API request to get feeds list feeds_list_response = APIrequest(feeds_list_url, bearer) -print(feeds_list_response) + +''' +======================================= + Parse the responses data as JSON + + Iterate over the unread counts and + subscriptions and store them in the + respective dictionaries + + If the subscription has categories + (is part of a folder) append the + category in the categories list +======================================= +''' + feeds_list_data = getData(feeds_list_response) unread_data = getData(unread_response) -print(feeds_list_data) -print('\n\n') -print(unread_data) for unread in unread_data['unreadcounts']: - unread['count'] = int(unread['count']) if unread['count'] > 0: unreadcounts[unread['id']] = unread['count'] for subscribed in feeds_list_data['subscriptions']: - if subscribed['categories']: if subscribed['categories'][0]['id'] not in categories: categories.append(subscribed['categories'][0]['id']) subscriptions[subscribed['id']] = subscribed['title'] +''' +================================================== + Iterate over the unreadcounts dictionary + + Determine the appropriate singular or + plural notification label based on the + count (e.g. new article or new articles) + + Include the unread feed in the notification + only if it is not in the categories list. + This is to avoid duplicates notifications + for the unread feed and the folder in which + the feed is. + + Do not include the reading-list in the + notification + + If the unread_id exists in the subscriptions + dictionary, get the title associated with it. + Else extract the title from the unread_id. + + Finally append the count, new_articles label + and title to the message string +================================================== +''' + for unread_id, count in unreadcounts.items(): - - if count == 1: - new_articles = singular_article - else: - new_articles = plural_articles - + + # Determine singular or plural notification label + new_articles = singular_article if count == 1 else plural_articles count = str(count) - + + # Do not include the categories and the reading-list in the notification if not unread_id in categories: if unread_id.split("/")[-1] == "reading-list": - pass - + else: - + + # Get the clean feed title if unread_id in (k for k,v in subscriptions.items()): - title = next(v for k, v in subscriptions.items() if k == unread_id) + title = next(v for k, v in subscriptions.items() if k == unread_id) + else: title = unread_id.split("/")[-1] - + + # Build the final notification message message = message + count + " " + new_articles + " " + title + "\n" else: pass -# Send notification if message is not empty. +''' +==================================== + Send the notification for unread + feeds only if the message string + is set +==================================== +''' + if message != "": notif.send_notification(summary, message) + else: pass \ No newline at end of file diff --git a/notif.py b/notif.py index 6c1b40e..1a786af 100644 --- a/notif.py +++ b/notif.py @@ -1,4 +1,6 @@ """ +========================================================================================= + Copyright © 2023 Alexandre Racine This file is part of Inopy. @@ -9,26 +11,34 @@ 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 code uses the pydbus library to send notifications. It defines a function send_notification() that takes summary and body as parameters. + The aim of this module is to send a notification using the Inopy application. It takes a summary and body as input and utilizes D-Bus (Desktop Bus) to establish a session bus connection. - Then, it calls the Notify method to send a notification with the specified parameters. - - Note that this code assumes that the necessary dependencies are installed and that the D-Bus service for notifications is available on the system. +========================================================================================= """ +import os from pydbus import SessionBus ''' - Create a new session bus instance - Get the .Notifications interface object from the bus +========================================= + Define a function to send the + notification when a new unread + article is present in the feed + + * Create a new session + bus instance + + * Get the .Notifications interface + object from the bus +========================================= ''' def send_notification(summary, body): @@ -36,15 +46,21 @@ def send_notification(summary, body): notifications = bus.get('.Notifications') ''' - Call the Notify method on the notifications object to send a notification + ================================================================================== + Call the Notify method on the notifications object to send a notification with Parameters: - - 'MyApp': The name of the application sending the notification - - 0: The ID of the notification (0 means a new notification) - - '': An optional icon name or path for the notification - - summary: The summary text of the notification - - body: The body text of the notification - - []: A list of actions associated with the notification (empty in this case) - - {}: A dictionary of hints for the notification (empty in this case) - - 5000: The timeout duration in milliseconds for the notification (5000 ms = 5 seconds) + + * 'MyApp': The name of the application sending the notification + * 0: The ID of the notification (0 means a new notification) + * '': An optional icon name or path for the notification + * summary: The summary text of the notification + * body: The body text of the notification + * []: A list of actions associated with the notification + (empty in this case) + * {}: A dictionary of hints for the notification (empty in this case) + * 5000: The timeout duration in milliseconds for the notification + (5000 ms = 5 seconds) + ================================================================================== ''' - notifications.Notify('Inopy', 0, '/opt/chrome-apps-icons/inoreader.png', summary, body, [], {}, 5000) \ No newline at end of file + icon = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icons/inoreader.png') + notifications.Notify('Inopy', 0, icon, summary, body, [], {}, 5000) \ No newline at end of file diff --git a/oauth.py b/oauth.py index 8e659cd..27801cf 100644 --- a/oauth.py +++ b/oauth.py @@ -1,14 +1,53 @@ -from flask import Flask, request, redirect, render_template -from config import config -from waitress import serve +""" +========================================================================================= + + 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 provide an OAuth authentication flow using Flask. + + It sets up a web server that handles the authentication process, retrieves the access token and refresh token, and stores them in the configuration file. + + The module can be run in either production or development mode, and it opens a web browser to complete the authentication process. + +========================================================================================= +""" + import requests -import os -import signal import webbrowser import time import json import subprocess import threading +from flask import Flask, request, redirect, render_template +from config import config +from waitress import serve + +''' +=================================== + Load configuration values + + Extract values from the config + dictionary + + Build the URL for authorization +=================================== +''' config = config() @@ -19,149 +58,209 @@ 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) + 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(): - - # Get the authorization code from the request URL - - authorization_code = request.args.get('code') - csrf_check = request.args.get('state') - error_param = request.args.get('error') + 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 != None else False + csrf = True if csrf_check == CSRF else False + error = True if error_param is not None else False - if csrf == True and error != True: + 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 + } - # Exchange the authorization code for an access token - access_token_url = endpoint - 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'] - response = requests.post(access_token_url, data=payload) + 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() - # Parse the response to get the access token - if response.status_code == 200: + 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 - access_token = response.json()['access_token'] - refresh_token = response.json()['refresh_token'] +''' +====================================== + When the shutdown url is accessed: - with open(config_file_path, 'r+') as config_file: - # Load the JSON data from the file - config = json.load(config_file) - - # Update the token value in the config data - config['oauth']['bearer'] = access_token - config['oauth']['refresh_token'] = refresh_token - - # Move the file pointer back to the beginning of the file - config_file.seek(0) - - # Write the updated config data to the file - json.dump(config, config_file, indent=4) - config_file.truncate() - - return render_template('success.html', response=(access_token, refresh_token)) - - else: - - # Redirect the user to a desired URL - - if csrf != True: - return render_template('csrf-failed.html', response=(CSRF, csrf_check)) - - elif error == True: - error_content = request.args.get('error_description') - return render_template('oauth-error.html', response=(error_param, error_content)) - - else: - pass + * Shut down the Flask server + gracefully +====================================== +''' @app.route('/shutdown') def shutdown(): - # Shutting down the Flask app gracefully - #return ('proccess ended', time.sleep(5), os.kill(os.getpid(), signal.SIGINT)) - - request.environ.get('werkzeug.server.shutdown') - return 'Close this browser to terminate the process!' + 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 run_prod(): - - # Create a new Firefox profile - subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"]) - - # Launch Firefox with the new profile and open the URL - subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url]) - - serve(app, host=host, port=port)''' - -# Function to start the Flask server def start_server(): - serve(app, host=host, port=port) + 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) - # Create a new thread for the Flask server - server_thread = threading.Thread(target=start_server) + # 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]) - # Start the Flask server thread - server_thread.start() +''' +====================================== + Define a function to start the + development server inside a new + thread. - # Wait for the Flask server to start (adjust the delay as needed) - time.sleep(2) - - # Create a new Firefox profile - subprocess.run([browser_path, "-CreateProfile", "new_profile", "-no-remote"]) - - # Launch Firefox with the new profile and open the URL - subprocess.run([browser_path, "-P", "new_profile", "-no-remote", home_url]) + 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) - # Create a new thread for the Flask server - server_thread = threading.Thread(target=start_server) + # Open the home URL in the default web browser + webbrowser.open(home_url) + app.run() - # Start the Flask server thread - server_thread.start() - - # Wait for the Flask server to start (adjust the delay as needed) - time.sleep(2) - - webbrowser.open(home_url) - app.run() +''' +====================================== + Determine whether to run the + production or development server + based on the config +====================================== +''' def run_app(): + + if prod_status == "true": + run_prod() + + else: + run_dev() - if prod_status == "true": - print(prod_status) - run_prod() - else: - print(prod_status) - run_dev() +''' +====================================== + Run the application standalone + if the script is executed but not + imported +====================================== +''' if __name__ == '__main__': - #app.run() - run_app() \ No newline at end of file + run_app() \ No newline at end of file diff --git a/refresh.py b/refresh.py index 732f853..6582e6e 100644 --- a/refresh.py +++ b/refresh.py @@ -1,4 +1,6 @@ """ +========================================================================================= + Copyright © 2023 Alexandre Racine This file is part of Inopy. @@ -9,31 +11,57 @@ 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 code uses the values from the configuration file to construct a request payload. + The aim of this module is to refresh both OAuth 2.0 access and refresh tokens and update the configuration file with the refreshed tokens. - The refresh function sends a POST request to the Inoreader OAuth endpoint using the payload and headers to handle the refresh token process. - - Then it extracts the refreshed bearer token and the new refresh token in order to use it in the replace() function of the main ino.py module. +========================================================================================= """ +''' +===================================================== + Create a refresh function to refresh the bearer + and refresh token in the config data + + * Set the headers and prepare the payload + data for the HTTP request + + * Send a POST request to the specified + endpoint with the payload and headers + + * Check the response status code to determine + if the request was successful + + * Parse the response data as JSON and extract + the refreshed bearer token and new refresh + token from the response data + + * Open the config file. Load the existing + config data from the file and update the + bearer token and refresh token in the config + data + + * Move the file pointer to the beginning of + the file, write the updated config data back + to the file, overwriting the existing content + + * Truncate the file to remove any remaining + content after the updated data +===================================================== +''' + import requests import json -# Define a function named 'refresh' that handles the token refresh logic def refresh(config_path, endpoint, client_id, client_secret, refresh_token): - - # Set the headers for the request headers = {"Content-type": "application/x-www-form-urlencoded"} - - # Prepare the payload for the request + payload = { "client_id": client_id, "client_secret": client_secret, @@ -41,33 +69,25 @@ def refresh(config_path, endpoint, client_id, client_secret, refresh_token): "refresh_token": refresh_token } - # Send a POST request to the specified URL with the payload and headers response = requests.post(endpoint, data=payload, headers=headers) - - # Check the response status code + if response.status_code == 200: print("Request was successful.") + else: print("Request failed with status code:", response.status_code) - - # Parse the response data as JSON + data = json.loads(response.text) - # Extract the refreshed bearer token and new refresh token from the response data refreshed_bearer = data['access_token'] new_refresh_token = data['refresh_token'] with open(config_path, 'r+') as config_file: - # Load the JSON data from the file config = json.load(config_file) - # Update the token value in the config data config['oauth']['bearer'] = refreshed_bearer config['oauth']['refresh_token'] = new_refresh_token - # Move the file pointer back to the beginning of the file config_file.seek(0) - - # Write the updated config data to the file json.dump(config, config_file, indent=4) config_file.truncate() \ No newline at end of file