diff --git a/eos/saveddata/ssocharacter.py b/eos/saveddata/ssocharacter.py index 352e3724a..d1234c2c4 100644 --- a/eos/saveddata/ssocharacter.py +++ b/eos/saveddata/ssocharacter.py @@ -32,23 +32,11 @@ class SsoCharacter(object): self.accessToken = accessToken self.refreshToken = refreshToken self.accessTokenExpires = None - self.esi_client = None @reconstructor def init(self): - self.esi_client = None - - def get_sso_data(self): - """ Little "helper" function to get formated data for esipy security - """ - return { - 'access_token': self.accessToken, - 'refresh_token': self.refreshToken, - 'expires_in': ( - self.accessTokenExpires - datetime.datetime.utcnow() - ).total_seconds() - } + pass def is_token_expired(self): if self.accessTokenExpires is None: diff --git a/gui/esiFittings.py b/gui/esiFittings.py index 2f53a5819..0ac8e90db 100644 --- a/gui/esiFittings.py +++ b/gui/esiFittings.py @@ -1,5 +1,3 @@ -import time -import webbrowser import json # noinspection PyPackageRequirements import wx @@ -15,8 +13,8 @@ from gui.display import Display import gui.globalEvents as GE from logbook import Logger -import calendar -from service.esi import Esi, APIException +from service.esi import Esi +from service.esiAccess import APIException from service.port import ESIExportException pyfalog = Logger(__name__) @@ -110,11 +108,11 @@ class EveFittings(wx.Frame): waitDialog = wx.BusyInfo("Fetching fits, please wait...", parent=self) try: - fittings = sEsi.getFittings(self.getActiveCharacter()) + self.fittings = sEsi.getFittings(self.getActiveCharacter()) # self.cacheTime = fittings.get('cached_until') # self.updateCacheStatus(None) # self.cacheTimer.Start(1000) - self.fitTree.populateSkillTree(fittings) + self.fitTree.populateSkillTree(self.fittings) del waitDialog except requests.exceptions.ConnectionError: msg = "Connection error, please check your internet connection" @@ -149,6 +147,9 @@ class EveFittings(wx.Frame): if dlg.ShowModal() == wx.ID_YES: try: sEsi.delFitting(self.getActiveCharacter(), data['fitting_id']) + # repopulate the fitting list + self.fitTree.populateSkillTree(self.fittings) + self.fitView.update([]) except requests.exceptions.ConnectionError: msg = "Connection error, please check your internet connection" pyfalog.error(msg) @@ -156,8 +157,9 @@ class EveFittings(wx.Frame): class ESIExceptionHandler(object): + # todo: make this a generate excetpion handler for all calls def __init__(self, parentWindow, ex): - if ex.response['error'] == "invalid_token": + if ex.response['error'].startswith('Token is not valid'): dlg = wx.MessageDialog(parentWindow, "There was an error validating characters' SSO token. Please try " "logging into the character again to reset the token.", "Invalid Token", @@ -361,9 +363,13 @@ class FittingsTreeView(wx.Panel): tree = self.fittingsTreeCtrl tree.DeleteChildren(root) + sEsi = Esi.getInstance() + dict = {} fits = data for fit in fits: + if (fit['fitting_id'] in sEsi.fittings_deleted): + continue ship = getItem(fit['ship_type_id']) if ship.name not in dict: dict[ship.name] = [] diff --git a/service/esi.py b/service/esi.py index adbf7e774..78c80e04a 100644 --- a/service/esi.py +++ b/service/esi.py @@ -2,82 +2,33 @@ import wx from logbook import Logger import threading -import uuid import time -import config import base64 import json -import os import config import webbrowser import eos.db -import datetime from eos.enum import Enum from eos.saveddata.ssocharacter import SsoCharacter +from service.esiAccess import APIException import gui.globalEvents as GE from service.server import StoppableHTTPServer, AuthHandler from service.settings import EsiSettings +from service.esiAccess import EsiAccess import wx from requests import Session pyfalog = Logger(__name__) -from urllib.parse import urlencode - -# todo: reimplement Caching for calls -# from esipy.cache import FileCache -# file_cache = FileCache(cache_path) -# cache_path = os.path.join(config.savePath, config.ESI_CACHE) -# -# if not os.path.exists(cache_path): -# os.mkdir(cache_path) -# - -sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login -esi_url = "https://esi.tech.ccp.is" - -oauth_authorize = '%s/oauth/authorize' % sso_url -oauth_token = '%s/oauth/token' % sso_url - - -class APIException(Exception): - """ Exception for SSO related errors """ - - def __init__(self, url, code, json_response): - self.url = url - self.status_code = code - self.response = json_response - super(APIException, self).__init__(str(self)) - - def __str__(self): - if 'error' in self.response: - return 'HTTP Error %s: %s' % (self.status_code, - self.response['error']) - elif 'message' in self.response: - return 'HTTP Error %s: %s' % (self.status_code, - self.response['message']) - return 'HTTP Error %s' % (self.status_code) - - -class ESIEndpoints(Enum): - CHAR = "/v4/characters/{character_id}/" - CHAR_SKILLS = "/v4/characters/{character_id}/skills/" # prepend https://esi.evetech.net/ - CHAR_FITTINGS = "/v1/characters/{character_id}/fittings/" - CHAR_DEL_FIT = "/v1/characters/{character_id}/fittings/{fitting_id}/" - -class Servers(Enum): - TQ = 0 - SISI = 1 - class LoginMethod(Enum): SERVER = 0 MANUAL = 1 -class Esi(object): +class Esi(EsiAccess): _instance = None @classmethod @@ -88,6 +39,7 @@ class Esi(object): return cls._instance def __init__(self): + super().__init__() self.settings = EsiSettings.getInstance() # these will be set when needed @@ -97,32 +49,14 @@ class Esi(object): self.implicitCharacter = None + # until I can get around to making proper caching and modifications to said cache, storee deleted fittings here + # so that we can easily hide them in the fitting browser + self.fittings_deleted = set() + # need these here to post events import gui.mainFrame # put this here to avoid loop self.mainFrame = gui.mainFrame.MainFrame.getInstance() - if sso_url is None or sso_url == "": - raise AttributeError("sso_url cannot be None or empty " - "without app parameter") - - self.oauth_authorize = '%s/oauth/authorize' % sso_url - self.oauth_token = '%s/oauth/token' % sso_url - - # use ESI url for verify, since it's better for caching - if esi_url is None or esi_url == "": - raise AttributeError("esi_url cannot be None or empty") - self.oauth_verify = '%s/verify/' % esi_url - - - # session request stuff - self._session = Session() - self._session.headers.update({ - 'Accept': 'application/json', - 'User-Agent': ( - 'pyfa v{}'.format(config.version) - ) - }) - def delSsoCharacter(self, id): char = eos.db.getSsoCharacter(id, config.getClientSecret()) @@ -139,66 +73,35 @@ class Esi(object): return chars def getSsoCharacter(self, id): - """ - Get character, and modify to include the eve connection - """ char = eos.db.getSsoCharacter(id, config.getClientSecret()) - if char is not None and char.esi_client is None: - char.esi_client = Esi.genEsiClient() - Esi.update_token(char, Esi.get_sso_data(char)) # don't use update_token on security directly, se still need to apply the values here - eos.db.commit() return char - def getSkills(self, id): char = self.getSsoCharacter(id) - resp = self.get(char, ESIEndpoints.CHAR_SKILLS, character_id=char.characterID) - # resp = self.check_response(char.esi_client.request(op)) + resp = super().getSkills(char) return resp.json() def getSecStatus(self, id): char = self.getSsoCharacter(id) - resp = self.get(char, ESIEndpoints.CHAR, character_id=char.characterID) + resp = super().getSecStatus(char) return resp.json() def getFittings(self, id): char = self.getSsoCharacter(id) - resp = self.get(char, ESIEndpoints.CHAR_FITTINGS, character_id=char.characterID) + resp = super().getFittings(char) return resp.json() def postFitting(self, id, json_str): # @todo: new fitting ID can be recovered from resp.data, char = self.getSsoCharacter(id) - resp = self.post(char, ESIEndpoints.CHAR_FITTINGS, json_str, character_id=char.characterID) + resp = super().postFitting(char, json_str) return resp.json() def delFitting(self, id, fittingID): char = self.getSsoCharacter(id) - self.delete(char, ESIEndpoints.CHAR_DEL_FIT, character_id=char.characterID, fitting_id=fittingID) - - def check_response(self, resp): - # if resp.status >= 400: - # raise EsiException(resp.status) - return resp - - @staticmethod - def get_sso_data(char): - """ Little "helper" function to get formated data for esipy security - """ - return { - 'access_token': char.accessToken, - 'refresh_token': config.cipher.decrypt(char.refreshToken).decode(), - 'expires_in': (char.accessTokenExpires - datetime.datetime.utcnow()).total_seconds() - } - - @staticmethod - def update_token(char, tokenResponse): - """ helper function to update token data from SSO response """ - char.accessToken = tokenResponse['access_token'] - char.accessTokenExpires = datetime.datetime.fromtimestamp(time.time() + tokenResponse['expires_in']) - if 'refresh_token' in tokenResponse: - char.refreshToken = config.cipher.encrypt(tokenResponse['refresh_token'].encode()) + super().delFitting(char, fittingID) + self.fittings_deleted.add(fittingID) def login(self): serverAddr = None @@ -213,24 +116,6 @@ class Esi(object): self.httpd.stop() self.httpd = None - def getLoginURI(self, redirect=None): - self.state = str(uuid.uuid4()) - - args = { - 'state': self.state, - 'pyfa_version': config.version, - 'login_method': self.settings.get('loginMode'), - 'client_hash': config.getClientSecret() - } - - if redirect is not None: - args['redirect'] = redirect - - return '%s?%s' % ( - oauth_authorize, - urlencode(args) - ) - def startServer(self): # todo: break this out into two functions: starting the server, and getting the URI pyfalog.debug("Starting server") @@ -248,44 +133,6 @@ class Esi(object): return 'http://localhost:{}'.format(port) - def get_oauth_header(self, token): - """ Return the Bearer Authorization header required in oauth calls - - :return: a dict with the authorization header - """ - return {'Authorization': 'Bearer %s' % token} - - def get_refresh_token_params(self, refreshToken): - """ Return the param object for the post() call to get the access_token - from the refresh_token - - :param code: the refresh token - :return: a dict with the url, params and header - """ - if refreshToken is None: - raise AttributeError('No refresh token is defined.') - - return { - 'data': { - 'grant_type': 'refresh_token', - 'refresh_token': refreshToken, - }, - 'url': self.oauth_token, - } - - def refresh(self, ssoChar): - request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode()) - res = self._session.post(**request_data) - if res.status_code != 200: - raise APIException( - request_data['url'], - res.status_code, - res.json() - ) - json_res = res.json() - self.update_token(ssoChar, json_res) - return json_res - def handleLogin(self, ssoInfo): auth_response = json.loads(base64.b64decode(ssoInfo)) @@ -326,33 +173,3 @@ class Esi(object): self.handleLogin(message['SSOInfo'][0]) - def __before_request(self, ssoChar): - if ssoChar.is_token_expired(): - json_response = self.refresh(ssoChar) - # AFTER_TOKEN_REFRESH.send(**json_response) - - if ssoChar.accessToken is not None: - self._session.headers.update(self.get_oauth_header(ssoChar.accessToken)) - - def get(self, ssoChar, endpoint, *args, **kwargs): - self.__before_request(ssoChar) - endpoint = endpoint.format(**kwargs) - return self._session.get("{}{}".format(esi_url, endpoint)) - - # check for warnings, also status > 400 - - - def post(self, ssoChar, endpoint, json, *args, **kwargs): - self.__before_request(ssoChar) - endpoint = endpoint.format(**kwargs) - return self._session.post("{}{}".format(esi_url, endpoint), data=json) - - # check for warnings, also status > 400 - - def delete(self, ssoChar, endpoint, *args, **kwargs): - self.__before_request(ssoChar) - endpoint = endpoint.format(**kwargs) - return self._session.delete("{}{}".format(esi_url, endpoint)) - - # check for warnings, also status > 400 - diff --git a/service/esiAccess.py b/service/esiAccess.py new file mode 100644 index 000000000..864ca397d --- /dev/null +++ b/service/esiAccess.py @@ -0,0 +1,211 @@ +# noinspection PyPackageRequirements +from logbook import Logger +import uuid +import time +import config + +import datetime +from eos.enum import Enum +from eos.saveddata.ssocharacter import SsoCharacter +from service.settings import EsiSettings + +from requests import Session +from urllib.parse import urlencode + +pyfalog = Logger(__name__) + +# todo: reimplement Caching for calls +# from esipy.cache import FileCache +# file_cache = FileCache(cache_path) +# cache_path = os.path.join(config.savePath, config.ESI_CACHE) +# +# if not os.path.exists(cache_path): +# os.mkdir(cache_path) +# + +# todo: move these over to getters that automatically determine which endpoint we use. +sso_url = "https://www.pyfa.io" # "https://login.eveonline.com" for actual login +esi_url = "https://esi.tech.ccp.is" + +oauth_authorize = '%s/oauth/authorize' % sso_url +oauth_token = '%s/oauth/token' % sso_url + + +class APIException(Exception): + """ Exception for SSO related errors """ + + def __init__(self, url, code, json_response): + self.url = url + self.status_code = code + self.response = json_response + super(APIException, self).__init__(str(self)) + + def __str__(self): + if 'error' in self.response: + return 'HTTP Error %s: %s' % (self.status_code, + self.response['error']) + elif 'message' in self.response: + return 'HTTP Error %s: %s' % (self.status_code, + self.response['message']) + return 'HTTP Error %s' % (self.status_code) + + +class ESIEndpoints(Enum): + CHAR = "/v4/characters/{character_id}/" + CHAR_SKILLS = "/v4/characters/{character_id}/skills/" + CHAR_FITTINGS = "/v1/characters/{character_id}/fittings/" + CHAR_DEL_FIT = "/v1/characters/{character_id}/fittings/{fitting_id}/" + + +# class Servers(Enum): +# TQ = 0 +# SISI = 1 + +class EsiAccess(object): + def __init__(self): + if sso_url is None or sso_url == "": + raise AttributeError("sso_url cannot be None or empty " + "without app parameter") + + self.settings = EsiSettings.getInstance() + + self.oauth_authorize = '%s/oauth/authorize' % sso_url + self.oauth_token = '%s/oauth/token' % sso_url + + # use ESI url for verify, since it's better for caching + if esi_url is None or esi_url == "": + raise AttributeError("esi_url cannot be None or empty") + self.oauth_verify = '%s/verify/' % esi_url + + + # session request stuff + self._session = Session() + self._session.headers.update({ + 'Accept': 'application/json', + 'User-Agent': ( + 'pyfa v{}'.format(config.version) + ) + }) + + def getSkills(self, char): + return self.get(char, ESIEndpoints.CHAR_SKILLS, character_id=char.characterID) + + def getSecStatus(self, char): + return self.get(char, ESIEndpoints.CHAR, character_id=char.characterID) + + def getFittings(self, char): + return self.get(char, ESIEndpoints.CHAR_FITTINGS, character_id=char.characterID) + + def postFitting(self, char, json_str): + # @todo: new fitting ID can be recovered from resp.data, + return self.post(char, ESIEndpoints.CHAR_FITTINGS, json_str, character_id=char.characterID) + + def delFitting(self, char, fittingID): + return self.delete(char, ESIEndpoints.CHAR_DEL_FIT, character_id=char.characterID, fitting_id=fittingID) + + @staticmethod + def update_token(char, tokenResponse): + """ helper function to update token data from SSO response """ + char.accessToken = tokenResponse['access_token'] + char.accessTokenExpires = datetime.datetime.fromtimestamp(time.time() + tokenResponse['expires_in']) + if 'refresh_token' in tokenResponse: + char.refreshToken = config.cipher.encrypt(tokenResponse['refresh_token'].encode()) + + def getLoginURI(self, redirect=None): + self.state = str(uuid.uuid4()) + + args = { + 'state': self.state, + 'pyfa_version': config.version, + 'login_method': self.settings.get('loginMode'), + 'client_hash': config.getClientSecret() + } + + if redirect is not None: + args['redirect'] = redirect + + return '%s?%s' % ( + oauth_authorize, + urlencode(args) + ) + + def get_oauth_header(self, token): + """ Return the Bearer Authorization header required in oauth calls + + :return: a dict with the authorization header + """ + return {'Authorization': 'Bearer %s' % token} + + def get_refresh_token_params(self, refreshToken): + """ Return the param object for the post() call to get the access_token + from the refresh_token + + :param code: the refresh token + :return: a dict with the url, params and header + """ + if refreshToken is None: + raise AttributeError('No refresh token is defined.') + + return { + 'data': { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }, + 'url': self.oauth_token, + } + + def refresh(self, ssoChar): + request_data = self.get_refresh_token_params(config.cipher.decrypt(ssoChar.refreshToken).decode()) + res = self._session.post(**request_data) + if res.status_code != 200: + raise APIException( + request_data['url'], + res.status_code, + res.json() + ) + json_res = res.json() + self.update_token(ssoChar, json_res) + return json_res + + def _before_request(self, ssoChar): + if ssoChar.is_token_expired(): + pyfalog.info("Refreshing token for {}".format(ssoChar.characterName)) + self.refresh(ssoChar) + + if ssoChar.accessToken is not None: + self._session.headers.update(self.get_oauth_header(ssoChar.accessToken)) + + def _after_request(self, resp): + if ("warning" in resp.headers): + pyfalog.warn("{} - {}".format(resp.headers["warning"], resp.url)) + + if resp.status_code >= 400: + raise APIException( + resp.url, + resp.status_code, + resp.json() + ) + + return resp + + def get(self, ssoChar, endpoint, *args, **kwargs): + self._before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._after_request(self._session.get("{}{}".format(esi_url, endpoint))) + + # check for warnings, also status > 400 + + def post(self, ssoChar, endpoint, json, *args, **kwargs): + self._before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._after_request(self._session.post("{}{}".format(esi_url, endpoint), data=json)) + + # check for warnings, also status > 400 + + def delete(self, ssoChar, endpoint, *args, **kwargs): + self._before_request(ssoChar) + endpoint = endpoint.format(**kwargs) + return self._after_request(self._session.delete("{}{}".format(esi_url, endpoint))) + + # check for warnings, also status > 400 +