diff --git a/royalnet/alchemy/alchemy.py b/royalnet/alchemy/alchemy.py index 27704632..b4c1c2aa 100644 --- a/royalnet/alchemy/alchemy.py +++ b/royalnet/alchemy/alchemy.py @@ -1,4 +1,4 @@ -from typing import Set, Dict, Union +from typing import * from contextlib import contextmanager, asynccontextmanager from royalnet.utils import asyncify from royalnet.alchemy.errors import TableNotFoundError @@ -6,7 +6,7 @@ from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.schema import Table from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.ext.declarative.api import DeclarativeMeta +from sqlalchemy.ext.declarative.api import DeclarativeMeta, AbstractConcreteBase from sqlalchemy.orm import sessionmaker @@ -26,7 +26,7 @@ class Alchemy: raise NotImplementedError("sqlite databases aren't supported, as they can't be used in multithreaded" " applications") self._engine: Engine = create_engine(database_uri) - self._Base: DeclarativeMeta = declarative_base(bind=self._engine) + self._Base = declarative_base(bind=self._engine) self.Session: sessionmaker = sessionmaker(bind=self._engine) self._tables: Dict[str, Table] = {} for table in tables: @@ -38,7 +38,7 @@ class Alchemy: self._tables[name] = bound_table self._Base.metadata.create_all() - def get(self, table: Union[str, type]) -> Table: + def get(self, table: Union[str, type]) -> DeclarativeMeta: """Get the table with a specified name or class. Args: diff --git a/royalnet/backpack/stars/__init__.py b/royalnet/backpack/stars/__init__.py index b9fbd55a..3c869821 100644 --- a/royalnet/backpack/stars/__init__.py +++ b/royalnet/backpack/stars/__init__.py @@ -2,13 +2,16 @@ from .api_royalnet_version import ApiRoyalnetVersionStar from .api_login_royalnet import ApiLoginRoyalnetStar from .api_token_info import ApiTokenInfoStar - +from .api_token_passwd import ApiTokenPasswdStar +from .api_token_create import ApiTokenCreateStar # Enter the PageStars of your Pack here! available_page_stars = [ ApiRoyalnetVersionStar, ApiLoginRoyalnetStar, ApiTokenInfoStar, + ApiTokenPasswdStar, + ApiTokenCreateStar, ] # Don't change this, it should automatically generate __all__ diff --git a/royalnet/backpack/stars/api_token_create.py b/royalnet/backpack/stars/api_token_create.py new file mode 100644 index 00000000..5465b468 --- /dev/null +++ b/royalnet/backpack/stars/api_token_create.py @@ -0,0 +1,21 @@ +from typing import * +import datetime +import royalnet.utils as ru +from royalnet.constellation.api import * +from ..tables.tokens import Token +from sqlalchemy import and_ + + +class ApiTokenCreateStar(ApiStar): + path = "/api/token/create/v1" + + async def api(self, data: ApiData) -> dict: + user = await data.user() + try: + duration = int(data["duration"]) + except ValueError: + raise InvalidParameterError("Duration is not a valid integer") + new_token = Token.generate(self.alchemy, user, datetime.timedelta(seconds=duration)) + data.session.add(new_token) + await data.session_commit() + return new_token.json() diff --git a/royalnet/backpack/stars/api_token_passwd.py b/royalnet/backpack/stars/api_token_passwd.py new file mode 100644 index 00000000..22a63720 --- /dev/null +++ b/royalnet/backpack/stars/api_token_passwd.py @@ -0,0 +1,33 @@ +from typing import * +import datetime +import royalnet.utils as ru +from royalnet.constellation.api import * +from sqlalchemy import and_ +from ..tables.tokens import Token + + +class ApiTokenPasswdStar(ApiStar): + path = "/api/token/passwd/v1" + + async def api(self, data: ApiData) -> dict: + TokenT = self.alchemy.get(Token) + token = await data.token() + user = token.user + user.set_password(data["new_password"]) + tokens: List[Token] = await ru.asyncify( + data.session + .query(self.alchemy.get(Token)) + .filter( + and_( + TokenT.user == user, + TokenT.expiration >= datetime.datetime.now() + )) + .all + ) + for t in tokens: + if t.token != token.token: + t.expired = True + await data.session_commit() + return { + "revoked_tokens": len(tokens) - 1 + } diff --git a/royalnet/backpack/tables/tokens.py b/royalnet/backpack/tables/tokens.py index acada45e..f9e80cd8 100644 --- a/royalnet/backpack/tables/tokens.py +++ b/royalnet/backpack/tables/tokens.py @@ -4,7 +4,6 @@ from sqlalchemy import * from sqlalchemy.orm import * from sqlalchemy.ext.declarative import declared_attr import royalnet.utils as ru -from .users import User class Token: @@ -30,10 +29,18 @@ class Token: def expired(self): return datetime.datetime.now() > self.expiration + @expired.setter + def expired(self, value): + if value is True: + self.expiration = datetime.datetime.fromtimestamp(0) + else: + raise ValueError("'expired' can only be set to True.") + @classmethod - def generate(cls, user, expiration_delta: datetime.timedelta): + def generate(cls, alchemy, user, expiration_delta: datetime.timedelta): # noinspection PyArgumentList - token = cls(user=user, expiration=datetime.datetime.now() + expiration_delta, token=secrets.token_urlsafe()) + TokenT = alchemy.get(cls) + token = TokenT(user=user, expiration=datetime.datetime.now() + expiration_delta, token=secrets.token_urlsafe()) return token def json(self) -> dict: diff --git a/royalnet/commands/commanddata.py b/royalnet/commands/commanddata.py index fa7b1956..bee9d885 100644 --- a/royalnet/commands/commanddata.py +++ b/royalnet/commands/commanddata.py @@ -26,6 +26,7 @@ class CommandData: if self._session is None: if self._interface.alchemy is None: raise UnsupportedError("'alchemy' is not enabled on this Royalnet instance") + log.debug("Creating Session...") self._session = self._interface.alchemy.Session() return self._session @@ -34,11 +35,13 @@ class CommandData: if self._session: log.warning("Session had to be created to be committed") # noinspection PyUnresolvedReferences + log.debug("Committing Session...") await ru.asyncify(self.session.commit) async def session_close(self): """Asyncronously close the :attr:`.session` of this object.""" if self._session is not None: + log.debug("Closing Session...") await ru.asyncify(self._session.close) async def reply(self, text: str) -> None: @@ -71,9 +74,7 @@ class CommandData: Parameters: alias: the Alias to search for.""" - return await ru.asyncify( - Alias.find_user(self._interface.alchemy, self.session, alias) - ) + return await Alias.find_user(self._interface.alchemy, self.session, alias) @contextlib.asynccontextmanager async def keyboard(self, text, keys: List["KeyboardKey"]): diff --git a/royalnet/constellation/api/__init__.py b/royalnet/constellation/api/__init__.py index bff3af0b..fd04cb9e 100644 --- a/royalnet/constellation/api/__init__.py +++ b/royalnet/constellation/api/__init__.py @@ -1,7 +1,16 @@ from .apistar import ApiStar from .jsonapi import api_response, api_success, api_error from .apidata import ApiData -from .apierrors import ApiError, MissingParameterError, NotFoundError, ForbiddenError +from .apierrors import \ + ApiError, \ + NotFoundError, \ + ForbiddenError, \ + BadRequestError, \ + ParameterError, \ + MissingParameterError, \ + InvalidParameterError, \ + NotImplementedError, \ + UnsupportedError __all__ = [ @@ -13,4 +22,13 @@ __all__ = [ "ApiError", "MissingParameterError", "NotFoundError", + "ApiError", + "NotFoundError", + "ForbiddenError", + "BadRequestError", + "ParameterError", + "MissingParameterError", + "InvalidParameterError", + "NotImplementedError", + "UnsupportedError", ] diff --git a/royalnet/constellation/api/apidata.py b/royalnet/constellation/api/apidata.py index c20fee4f..a8915c50 100644 --- a/royalnet/constellation/api/apidata.py +++ b/royalnet/constellation/api/apidata.py @@ -1,7 +1,11 @@ +import logging from .apierrors import MissingParameterError from royalnet.backpack.tables.tokens import Token from royalnet.backpack.tables.users import User from .apierrors import * +import royalnet.utils as ru + +log = logging.getLogger(__name__) class ApiData(dict): @@ -19,7 +23,7 @@ class ApiData(dict): raise ForbiddenError("'token' is invalid") return token - async def user(self) -> Token: + async def user(self) -> User: return (await self.token()).user @property @@ -27,5 +31,20 @@ class ApiData(dict): if self._session is None: if self.star.alchemy is None: raise UnsupportedError("'alchemy' is not enabled on this Royalnet instance") + log.debug("Creating Session...") self._session = self.star.alchemy.Session() return self._session + + async def session_commit(self): + """Asyncronously commit the :attr:`.session` of this object.""" + if self._session: + log.warning("Session had to be created to be committed") + # noinspection PyUnresolvedReferences + log.debug("Committing Session...") + await ru.asyncify(self.session.commit) + + async def session_close(self): + """Asyncronously close the :attr:`.session` of this object.""" + if self._session is not None: + log.debug("Closing Session...") + await ru.asyncify(self._session.close) diff --git a/royalnet/constellation/api/apierrors.py b/royalnet/constellation/api/apierrors.py index b09fadeb..b566a602 100644 --- a/royalnet/constellation/api/apierrors.py +++ b/royalnet/constellation/api/apierrors.py @@ -10,7 +10,19 @@ class ForbiddenError(ApiError): pass -class MissingParameterError(ApiError): +class BadRequestError(ApiError): + pass + + +class ParameterError(BadRequestError): + pass + + +class MissingParameterError(ParameterError): + pass + + +class InvalidParameterError(ParameterError): pass diff --git a/royalnet/constellation/api/apistar.py b/royalnet/constellation/api/apistar.py index d6ea4770..2d9a6c4e 100644 --- a/royalnet/constellation/api/apistar.py +++ b/royalnet/constellation/api/apistar.py @@ -7,6 +7,7 @@ from ..pagestar import PageStar from .jsonapi import api_error, api_success from .apidata import ApiData from .apierrors import * +from royalnet.utils import sentry_exc class ApiStar(PageStar, ABC): @@ -18,19 +19,24 @@ class ApiStar(PageStar, ABC): data = await request.json() except JSONDecodeError: data = {} + apidata = ApiData(data, self) try: - response = await self.api(ApiData(data, self)) + response = await self.api(apidata) except NotFoundError as e: return api_error(e, code=404) except ForbiddenError as e: return api_error(e, code=403) except NotImplementedError as e: return api_error(e, code=501) - except ApiError as e: + except BadRequestError as e: return api_error(e, code=400) except Exception as e: + sentry_exc(e) return api_error(e, code=500) - return api_success(response) + else: + return api_success(response) + finally: + await apidata.session_close() async def api(self, data: dict) -> dict: raise NotImplementedError() diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index 022763ff..db22afca 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -47,7 +47,7 @@ class Serf: } except ImportError as e: log.error(f"{e.__class__.__name__} during the import of {pack_name}:\n" - f"{traceback.format_exception(*sys.exc_info())}") + f"{''.join(traceback.format_exception(*sys.exc_info()))}") log.info(f"Packs: {len(packs)} imported") self.alchemy: Optional[ra.Alchemy] = None