Skip to content

Commit

Permalink
Initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
clubby789 committed Feb 25, 2021
0 parents commit 69c9829
Show file tree
Hide file tree
Showing 11 changed files with 369 additions and 0 deletions.
138 changes: 138 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# HackTheBox API

Nothing yet
1 change: 1 addition & 0 deletions hackthebox/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .htb import HTBClient, Challenge
2 changes: 2 additions & 0 deletions hackthebox/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
API_BASE = "https://www.hackthebox.eu/api/v4/"
USER_AGENT = "htb-api/0.01"
145 changes: 145 additions & 0 deletions hackthebox/htb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations # 'Circular dependencies' in the typing
from typing import List, Callable
import base64
import json
import time

import requests

from .constants import API_BASE, USER_AGENT


def check_expired_jwt(token: str) -> bool:
payload = base64.b64decode(token.split('.')[1]).decode()
if time.time() > json.loads(payload)['exp']:
return True
else:
return False


class HTBClient:
username: str = None
_access_token: str = None
_refresh_token: str = None

# TODO: Handle token expiry
# Add a decorator to every API function to check?

def _do_request(self, endpoint, json_data=None, data=None, authorized=True) -> dict:
headers = {"User-Agent": USER_AGENT}
if authorized:
if check_expired_jwt(self._access_token):
r = requests.post(API_BASE + "login/refresh", json={
"refresh_token": self._refresh_token
}, headers=headers)
data = r.json()['message']
self._access_token = data['access_token']
self._refresh_token = data['refresh_token']
headers['Authorization'] = "Bearer " + self._access_token
if not json_data and not data:
r = requests.get(API_BASE + endpoint, headers=headers)
else:
r = requests.post(API_BASE + endpoint, json=json_data, data=data, headers=headers)
return r.json()

def __init__(self, username=None, password=None, email=None):
if username:
self.username = username
if not password and not email:
print("Must give an authentication method")
raise Exception
elif password and not email:
print("Missing email")
raise Exception
elif email and not password:
print("Missing password")
raise Exception
else:
data = self._do_request("login", json_data={
"email": email, "password": password
}, authorized=False)
self._access_token = data['message']['access_token']
self._refresh_token = data['message']['access_token']

def get_challenge(self, challenge_id: int) -> 'Challenge':
data = self._do_request(f"challenge/info/{challenge_id}")['challenge']
return Challenge(data, self)

def get_challenges(self, limit=None, retired=False) -> List['Challenge']:
if retired:
data = self._do_request("challenge/list/retired")
else:
data = self._do_request("challenge/list")
challenges = []
for challenge in data['challenges'][:limit]:
challenges.append(Challenge(challenge, self, summary=True))
return challenges


class HTBObject:
# Parent class of all other objects
_client: HTBClient
# Attributes not fetched by a summary
_detailed_attributes: List[str]
_detailed_func: Callable
id: int

def __getattr__(self, item):
# Missing items because of summary
if item in self._detailed_attributes:
new_obj = self._detailed_func(self.id)
for attr in self._detailed_attributes:
setattr(self, attr, getattr(new_obj, attr))
return getattr(self, item)
else:
raise AttributeError


class Challenge(HTBObject):
_detailed_attributes = ('description', 'category', 'author_id', 'author_name', 'has_download', 'has_docker')
name: str = None
retired: bool = None
difficulty: str = None
avg_difficulty: int = None
points: int = None
difficulty_ratings = None
solves: int = None
likes: int = None
dislikes: int = None
release_data: str = None
isCompleted: bool = None
solved: bool = None
is_liked: bool = None
is_disliked: bool = None
has_download: bool = None
has_docker: bool = None
recommended: bool = None

description: str
category_id: int
category: str
author_id: int
author_name: str
has_download: bool
has_docker: bool

def __init__(self, data: dict, client: HTBClient, summary: bool = False):
"""Initialise a `Challenge` using API data"""
self._client = client
self._detailed_func = client.get_challenge
self.id = data['id']
self.name = data['name']
self.retired = bool(data['retired'])
self.points = int(data['points'])
self.difficulty_ratings = data['difficulty_chart']
self.solves = data['solves']
self.solved = data['authUserSolve']
self.likes = data['likes']
self.dislikes = data['dislikes']
if not summary:
self.description = data['description']
self.category = data['category_name']
self.author_id = data['creator_id']
self.author_name = data['creator_name']
self.has_download = data['download']
self.has_docker = data['docker']
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
requests==2.25.1
pytest==6.2.2
python-dotenv==0.15.0
pytest-cov==2.11.1
20 changes: 20 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import setuptools

with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()

setuptools.setup(
name="htb-api-clubby789",
version="0.0.1",
author="[email protected]",
author_email="[email protected]",
description="A wrapper for the HTB API.",
long_description=long_description,
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
packages=setuptools.find_packages(),
python_requires='>=3.7',
)
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os
from os import getenv
import sys
from pytest import fixture
from hackthebox import HTBClient
from dotenv import load_dotenv

load_dotenv()

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))


@fixture
def htb_client() -> HTBClient:
return HTBClient(email=getenv("HTB_EMAIL"), password=getenv("HTB_PASSWORD"))
28 changes: 28 additions & 0 deletions tests/test_challenges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pytest import raises
from hackthebox import HTBClient


def test_get_challenge(htb_client: HTBClient):
"""Tests the ability to retrieve a specific challenge"""
challenge = htb_client.get_challenge(69)
assert challenge.id == 69
assert challenge.name == "TheFutureBender"
assert challenge.retired


def test_get_non_existent_challenge(htb_client: HTBClient):
"""Tests for a failure upon a non existent challenge"""
with raises(Exception):
htb_client.get_challenge(10000000)


def test_get_challenges(htb_client: HTBClient):
"""Tests that the challenges can be listed"""
challenges = htb_client.get_challenges(limit=30)
assert len(challenges) == 30


def test_fill_in_summary(htb_client: HTBClient):
"""Test that partial `Challenges` can be 'filled in'"""
challenge = htb_client.get_challenges(limit=1)[0]
assert challenge.description is not None
7 changes: 7 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from hackthebox.htb import HTBClient


def test_login(htb_client: HTBClient):
"""Tests the ability to login and receive a bearer token"""
assert htb_client._access_token is not None

0 comments on commit 69c9829

Please sign in to comment.