diff --git a/app/handlers/base.py b/app/handlers/base.py index 2ebbe606..35173e98 100644 --- a/app/handlers/base.py +++ b/app/handlers/base.py @@ -1,4 +1,5 @@ import inspect +import sys import time import uuid from contextlib import contextmanager @@ -25,7 +26,14 @@ from ..env import load_env from ..flow import Flow from ..json_util import to_json -from ..models import DBSession, User, VerifiedSession, bulk_verify, session_context_id +from ..models import ( + APICall, + DBSession, + User, + VerifiedSession, + bulk_verify, + session_context_id, +) env, cfg = load_env() log = make_log("basehandler") @@ -40,6 +48,18 @@ # https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html#auth-apis +def sizeof(obj): + """Estimates total memory usage of (possibly nested) `obj` by recursively calling sys.getsizeof() for list/tuple/dict/set containers + and adding up the results. Does NOT handle circular object references! + """ + size = sys.getsizeof(obj) + if isinstance(obj, dict): + return size + sum(map(sizeof, obj.keys())) + sum(map(sizeof, obj.values())) + if isinstance(obj, (list, tuple, set, frozenset)): + return size + sum(map(sizeof, obj)) + return size + + class NoValue: pass @@ -305,6 +325,30 @@ def error(self, message, data={}, status=400, extra={}): self.set_status(status) self.write({"status": "error", "message": message, "data": data, **extra}) + if self.current_user is None: + return + with DBSession() as session: + session.merge(self.current_user) + if hasattr(self.current_user, "created_by_id"): + user_id = int(self.current_user.created_by_id) + else: + user_id = int(self.current_user.id) + + uri_split = self.request.uri.replace("api/", "").split("?") + uri = uri_split[0] + params = "/".join(uri_split[1:]) + + api_call = APICall( + user_id=user_id, + method=self.request.method, + uri=uri, + params=params, + size=sizeof(data), + success=False, + ) + session.add(api_call) + session.commit() + def action(self, action, payload={}): """Push an action to the frontend via WebSocket connection. @@ -354,6 +398,30 @@ def success(self, data={}, action=None, payload={}, status=200, extra={}): self.set_status(status) self.write(to_json({"status": "success", "data": data, **extra})) + if self.current_user is None: + return + with DBSession() as session: + session.merge(self.current_user) + if hasattr(self.current_user, "created_by_id"): + user_id = int(self.current_user.created_by_id) + else: + user_id = int(self.current_user.id) + + uri_split = self.request.uri.replace("api/", "").split("?") + uri = uri_split[0] + params = "/".join(uri_split[1:]) + + api_call = APICall( + user_id=user_id, + method=self.request.method, + uri=uri, + params=params, + size=sizeof(data), + success=True, + ) + session.add(api_call) + session.commit() + def write_error(self, status_code, exc_info=None): if exc_info is not None: err_cls, err, traceback = exc_info diff --git a/app/models.py b/app/models.py index f7c0a8ec..98414d0c 100644 --- a/app/models.py +++ b/app/models.py @@ -1889,6 +1889,12 @@ class User(Base): doc="ACLs granted to user, separate from role-level ACLs", lazy="selectin", ) + apicalls = relationship( + "APICall", + passive_deletes=True, + doc="API Calls by the user", + lazy="selectin", + ) expiration_date = sa.Column( sa.DateTime, nullable=True, @@ -2035,3 +2041,49 @@ class CronJobRun(Base): sa.String, doc="Cron job's subprocess output, or exception string.", ) + + +class APICall(Base): + """An API call by a User.""" + + create = read = update = delete = accessible_by_user + + user_id = sa.Column( + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + doc="The ID of the User that made the API call.", + ) + user = relationship( + "User", + back_populates="apicalls", + lazy="selectin", + doc="The User that made the API call.", + ) + + uri = sa.Column( + sa.String, + nullable=False, + doc="The endpoint of the API call.", + ) + + params = sa.Column( + sa.String, + nullable=True, + doc="The parameters of the API call.", + ) + + method = sa.Column( + sa.String, + nullable=False, + doc="The HTTP method of the API call.", + ) + + size = sa.Column( + sa.Float, + nullable=False, + doc="The size of the query in bytes.", + ) + + success = sa.Column( + sa.Boolean, nullable=False, doc="Was the call successful or not?" + )