API Security, Logging, & Restructure

- Added a .gitignore
- Implemented security.py (apikeys.py from server-api)
- Implemented wrapper.py (wrapper from radicalbot
- Added proper logging using log.conf
- Split tracker API endpoints into a router
- Renamed Target classes to Tracker
This commit is contained in:
riley 2021-08-23 02:14:40 -04:00
parent 13075f7fa3
commit f5e0ac8cac
11 changed files with 278 additions and 111 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*/__pycache__/*
*.log

75
api.py
View file

@ -1,70 +1,27 @@
import sys
import importlib
import logging
from target import Target
from fastapi import FastAPI, status
from response import Response
from pathlib import Path
from fastapi import FastAPI
logger = logging.getLogger("api")
api = FastAPI()
api_data = {"version": "2021.8.4.0", "author": "Riley Housden"}
api_data = {"version": "2021.8.23.0", "author": "Riley Housden"}
@api.post("/update")
def update():
sys.exit(36)
def load_module(name: str):
module = importlib.import_module(name)
api.include_router(module.router)
logger.info(f"Loaded module: {module.__name__}")
@api.post("/restart")
def stop():
sys.exit(26)
for m_path in Path("routers").glob("*"):
if "__pycache__" in m_path.parts or "__init__" == m_path.stem:
continue
load_module(str(m_path).replace("/", ".").replace(".py", ""))
@api.get("/")
def quack():
return "quack!"
@api.get("/target/points")
def list_points(name: str):
try:
target = Target.from_data(name)
return Response(status.HTTP_200_OK, target.points)
except FileNotFoundError:
return Response(status.HTTP_204_NO_CONTENT, {})
@api.get("/target/points/add")
def add_point(name: str, datestr: str, value: int):
try:
target = Target.from_data(name)
except FileNotFoundError:
target = Target(name, [])
target.add_point(datestr, value)
return Response(status.HTTP_200_OK, target).to_json()
@api.put("/target/points/modify")
def modify_point(name: str, datestr: str, value: int):
target = Target.from_data(name)
target.modify_point(datestr, value)
return 200
@api.delete("/target/points/delete")
def delete_point(name: str, datestr: str):
target = Target.from_data(name)
target.delete_point(datestr)
return 200
@api.get("/target/rename")
def rename_target(name: str, new_name: str):
target = Target.from_data(name)
target.rename(new_name)
return 200
@api.delete("/target/delete")
def delete_target(name: str):
target = Target.from_data(name)
target.delete()
return api_data

0
core/__init__.py Normal file
View file

View file

@ -3,43 +3,52 @@ from datetime import datetime
from json.encoder import JSONEncoder
from pathlib import Path
DATA_DIR = Path.home().joinpath(".targetdata")
DATA_DIR = Path.home().joinpath(".tracker_api/trackers")
class TargetEncoder(JSONEncoder):
def object_hook(dct):
if "date_str" in dct:
return TrackerPoint(dct["date_str"], dct["value"])
if "name" in dct:
return Tracker(dct["name"], dct["points"])
class TrackerEncoder(JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return o.strftime("%m/%d/%Y - %H:%M")
if isinstance(o, Tracker):
return dict(name=o.name, points=o.points)
elif isinstance(o, TrackerPoint):
return dict(date_str=o.datetime.isoformat(), value=o.value)
else:
return o.__dict__
return json.JSONEncoder.default(self, o)
class TargetPoint:
def __init__(self, datestr: str, value: int) -> None:
self.datetime = datetime.strptime(datestr, "%m/%d/%Y - %H:%M")
class TrackerPoint:
def __init__(self, date_str: str, value: int) -> None:
self.datetime = datetime.fromisoformat(date_str)
self.value = value
class Target:
def __init__(self, name: str, points: list[TargetPoint]):
class Tracker:
def __init__(self, name: str, points: list[TrackerPoint]):
self.name = name
self.points = points
def rename(self, name):
"""Rename the Target."""
"""Rename the Tracker."""
DATA_DIR.joinpath(f"{self.name}.json").rename(
DATA_DIR.joinpath(f"{name}.json"))
self.name = name
self.save()
def delete(self):
"""Delete the Target."""
"""Delete the Tracker."""
filepath = DATA_DIR.joinpath(f"{self.name}.json")
filepath.unlink()
def to_json(self):
"""Convert Target object to JSON."""
return json.dumps(self, indent=4, cls=TargetEncoder)
"""Convert Tracker object to JSON."""
return json.dumps(self, indent=4, cls=TrackerEncoder)
def save(self):
"""Save the tracker to JSON."""
@ -48,24 +57,24 @@ class Target:
filepath.touch(exist_ok=True)
filepath.write_text(self.to_json())
def modify_point(self, datestr: str, value: int):
def modify_point(self, date_str: str, value: int):
"""Modify a point. Change its assigned value to the one given."""
date_time = datetime.strptime(datestr, "%m/%d/%Y - %H:%M")
date_time = datetime.fromisoformat(date_str)
for point in self.points:
if point.datetime == date_time:
point.value = value
break
self.save()
def add_point(self, datestr: str, value: int):
def add_point(self, date_str: str, value: int):
"""Add a point to the tracker."""
point = TargetPoint(datestr, value)
point = TrackerPoint(date_str, value)
self.points.append(point)
self.save()
def delete_point(self, datestr: str):
def delete_point(self, date_str: str):
"""Remove a point from the tracker."""
date_time = datetime.strptime(datestr, "%m/%d/%Y - %H:%M")
date_time = datetime.fromisoformat(date_str)
for point in self.points:
if point.datetime == date_time:
self.points.remove(point)
@ -74,12 +83,11 @@ class Target:
@classmethod
def from_data(cls, name):
"""Load a target from the DATA_DIR."""
"""Load a tracker from the DATA_DIR."""
filepath = DATA_DIR.joinpath(f"{name}.json")
return cls.from_json(filepath.read_text())
@classmethod
def from_json(cls, json_str):
"""Load a target from a JSON string."""
obj = json.loads(json_str)
return cls(obj['name'], obj['points'])
@staticmethod
def from_json(json_str):
"""Load a tracker from a JSON string."""
return json.loads(json_str, object_hook=object_hook)

29
core/wrapper.py Normal file
View file

@ -0,0 +1,29 @@
"""Wrapper Class to handle reboots"""
import subprocess
import sys
class Wrapper:
def __init__(self, args, stdout):
"""
Wraps another program and helps with restarting should the program request.
:param args: Arguments used to run program
:param stdout: The stdout stream to send output to
"""
self._args = args
self.process = None
self._stdout = stdout
def start(self):
"""
Start the wrapper.
:return: None
"""
self.process = subprocess.Popen(self._args, stdout=self._stdout)
while True:
while self.process.poll() is None:
pass
if self.process.returncode == 26:
self.process = subprocess.Popen(self._args, stdout=self._stdout)
else:
sys.exit()

46
log.conf Normal file
View file

@ -0,0 +1,46 @@
[loggers]
keys=root,uvicorn,api,security
[handlers]
keys=console,file
[formatters]
keys=simple
[logger_root]
level=DEBUG
handlers=console,file
[logger_uvicorn]
level=INFO
handlers=console,file
qualname=uvicorn
propagate=0
[logger_api]
level=DEBUG
handlers=console,file
qualname=api
propagate=0
[logger_security]
level=INFO
handlers=console,file
qualname=security
propagate=0
[handler_console]
class=StreamHandler
level=DEBUG
formatter=simple
args=(sys.stdout,)
[handler_file]
class=FileHandler
level=DEBUG
formatter=simple
args=('api.log', 'a+')
[formatter_simple]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
datefmt=

15
main.py
View file

@ -1,13 +1,6 @@
import subprocess
import sys
script = "tracker_api/api.py"
from core.wrapper import Wrapper
while True:
try:
subprocess.run(f"python {script}", check=True)
except subprocess.CalledProcessError as err:
if err.returncode == -1:
print("Stopping.")
break
if err.returncode == -2:
print("Run update check!")
api = Wrapper(['gunicorn', "-k", "uvicorn.workers.UvicornWorker", "--log-config", "log.conf", 'api:api'], sys.stdout)
api.start()

View file

@ -1,15 +0,0 @@
import json
class ResponseEncoder(json.JSONEncoder):
def default(self, o):
return o.__dict__
class Response:
def __init__(self, status_code, obj):
self.status_code = status_code
self.response = obj
def to_json(self):
return json.dumps(self, indent=4, cls=ResponseEncoder)

0
routers/__init__.py Normal file
View file

91
routers/security.py Normal file
View file

@ -0,0 +1,91 @@
from fastapi import Security, HTTPException, APIRouter, Depends
from fastapi.security.api_key import APIKeyQuery, APIKeyCookie, APIKeyHeader, APIKey
from starlette.status import HTTP_403_FORBIDDEN
from pathlib import Path
import logging
import uuid
import json
router = APIRouter()
logger = logging.getLogger("security")
ROOT_PATH = Path.home().joinpath(".tracker_api/keys")
API_KEY_NAME = "access_token"
api_key_query = APIKeyQuery(name=API_KEY_NAME, auto_error=False)
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
api_key_cookie = APIKeyCookie(name=API_KEY_NAME, auto_error=False)
system_key_path = ROOT_PATH.joinpath("system_key")
if not system_key_path.exists():
logger.info("System Key not found, generating.")
system_key_path.parent.mkdir(parents=True, exist_ok=True)
system_key_path.touch()
system_uuid = str(uuid.uuid4())
system_key_path.write_text(system_uuid)
logger.info(f"System UUID: {system_uuid}")
def load_keys():
path = ROOT_PATH.joinpath("api_keys.json")
if not path.exists():
path.touch()
path.write_text("{}")
with path.open("r") as f:
data = json.load(f)
return data
def get_system_key(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
api_key_cookie: str = Security(api_key_cookie)
):
path = ROOT_PATH.joinpath("system_key")
if api_key_query == path.read_text():
return api_key_query
elif api_key_header == path.read_text():
return api_key_header
elif api_key_cookie == path.read_text():
return api_key_cookie
else:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
def get_api_key(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
api_key_cookie: str = Security(api_key_cookie)
):
if api_key_query in load_keys():
return api_key_query
elif api_key_header in load_keys():
return api_key_header
elif api_key_cookie in load_keys():
return api_key_header
else:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
@router.get("/key/create")
def create_key(name, system_key: APIKey = Depends(get_system_key)):
keys: dict = load_keys()
new_key = str(uuid.uuid4())
keys[new_key] = {}
keys[new_key]["name"] = name
path = ROOT_PATH.joinpath("api_keys.json")
with path.open("w") as f:
json.dump(keys, f, indent=4, sort_keys=True)
return new_key
@router.get("/key/me")
def key_info(api_key: APIKey = Depends(get_api_key)):
data = load_keys()
return data[api_key]

56
routers/tracker.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from fastapi.security.api_key import APIKey
from .security import get_api_key
from core.tracker import Tracker
router = APIRouter()
@router.get("/tracker/points")
def list_points(name: str, access_token: APIKey = Depends(get_api_key)):
try:
tracker = Tracker.from_data(name)
return tracker.points
except FileNotFoundError:
return {}
@router.get("/tracker/points/add")
def add_point(name: str, value: int, date_str: str = datetime.now().isoformat(),
access_token: APIKey = Depends(get_api_key)):
try:
tracker = Tracker.from_data(name)
except FileNotFoundError:
tracker = Tracker(name, [])
tracker.add_point(date_str, value)
return tracker.to_json()
@router.put("/tracker/points/modify")
def modify_point(name: str, date_str: str, value: int, access_token: APIKey = Depends(get_api_key)):
tracker = Tracker.from_data(name)
tracker.modify_point(date_str, value)
return 200
@router.delete("/tracker/points/delete")
def delete_point(name: str, date_str: str, access_token: APIKey = Depends(get_api_key)):
tracker = Tracker.from_data(name)
tracker.delete_point(date_str)
return 200
@router.get("/tracker/rename")
def rename_tracker(name: str, new_name: str, access_token: APIKey = Depends(get_api_key)):
tracker = Tracker.from_data(name)
tracker.rename(new_name)
return 200
@router.delete("/tracker/delete")
def delete_tracker(name: str, access_token: APIKey = Depends(get_api_key)):
tracker = Tracker.from_data(name)
tracker.delete()