diff --git a/README.md b/README.md index 4071a55..7cd86e9 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,38 @@ A simple python implementation of an EVM compatible faucet. -## API +## Python API ### Requirements -Python +3.x, NodeJS v18.x +Python +3.x -### Python API +### Installation ``` cd api python3 -m venv .venv . .venv/bin/activate -pip3 install -r requirements.txt - -python3 -m flask --app api run --port 8000 +pip3 install -r requirements-dev.txt ``` -#### Run application +### Run application + +Check .env.example for reference. ``` cd api python3 -m flask --app api run --port 8000 ``` - -#### Run tests +### Run tests ``` cd api python3 -m pytest -s ``` -#### Run Flake8 and isort +### Run Flake8 and isort ``` cd api @@ -43,7 +42,13 @@ isort **/*.py --atomic python3 -m flake8 ``` -### ReactJS Frontend +## ReactJS Frontend + +### Requirements + +NodeJS v18.x + +### Installation ``` nvm use @@ -52,7 +57,7 @@ cd app yarn ``` -#### Run application +### Run application ``` cd app diff --git a/api/.env.example b/api/.env.example index a444063..8c59633 100644 --- a/api/.env.example +++ b/api/.env.example @@ -4,5 +4,6 @@ FAUCET_RPC_URL=https://rpc.chiadochain.net FAUCET_CHAIN_ID=10200 FAUCET_ENABLED_TOKENS="[{\"address\": \"0x19C653Da7c37c66208fbfbE8908A5051B57b4C70\", \"name\":\"GNO\", \"maximumAmount\": 0.5}]" # FAUCET_ENABLED_TOKENS= +FAUCET_DATABASE_URI=sqlite:///:memory CAPTCHA_VERIFY_ENDPOINT=https://api.hcaptcha.com/siteverify CAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000 \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 2ae5386..236b1c5 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt COPY . /api WORKDIR /api -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "api:create_app()"] \ No newline at end of file +ENTRYPOINT ["/sbin/tini", "--"] \ No newline at end of file diff --git a/api/api/api.py b/api/api/api.py index bff7e1d..2a53fb0 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -2,10 +2,12 @@ from flask import Flask from flask_cors import CORS +from flask_migrate import Migrate +from .manage import create_access_keys_cmd from .routes import apiv1 from .services import Cache, Web3Singleton -from .services.database import db, migrate +from .services.database import db def setup_logger(log_level): @@ -36,13 +38,14 @@ def create_app(): app.config['FAUCET_CACHE'] = Cache(app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) # Initialize API Routes app.register_blueprint(apiv1, url_prefix="/api/v1") + # Add cli commands + app.cli.add_command(create_access_keys_cmd) with app.app_context(): db.init_app(app) - migrate.init_app(app, db) - db.create_all() # Create database tables for our data models + Migrate(app, db) - # Initialize Web3 class + # Initialize Web3 class for latter usage w3 = Web3Singleton(app.config['FAUCET_RPC_URL'], app.config['FAUCET_PRIVATE_KEY']) setup_cors(app) diff --git a/api/api/manage.py b/api/api/manage.py new file mode 100644 index 0000000..2d46961 --- /dev/null +++ b/api/api/manage.py @@ -0,0 +1,19 @@ +import logging + +import click +from flask.cli import with_appcontext + +from .services.database import AccessKey +from .utils import generate_access_key + + +@click.command(name='create_access_keys') +@with_appcontext +def create_access_keys_cmd(): + access_key_id, secret_access_key = generate_access_key() + access_key = AccessKey() + access_key.access_key_id = access_key_id + access_key.secret_access_key = secret_access_key + access_key.save() + logging.info(f'Access Key ID : ${access_key_id}') + logging.info(f'Secret access key: ${secret_access_key}') diff --git a/api/api/routes.py b/api/api/routes.py index c7d444d..c81c9bb 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -106,13 +106,14 @@ def _ask(request_data, validate_captcha): @apiv1.route("/ask", methods=["POST"]) def ask(): - return _ask(request.get_json(), validate_captcha=True) + data, status_code = _ask(request.get_json(), validate_captcha=True) + return data, status_code @apiv1.route("/cli/ask", methods=["POST"]) def cli_ask(): - access_key_id = request.headers.get('FAUCET_ACCESS_KEY_ID', None) - secret_access_key = request.headers.get('FAUCET_SECRET_ACCESS_KEY', None) + access_key_id = request.headers.get('X-faucet-access-key-id', None) + secret_access_key = request.headers.get('X-faucet-secret-access-key', None) validation_errors = [] @@ -127,4 +128,5 @@ def cli_ask(): validation_errors.append('Access denied') return jsonify(errors=validation_errors), 403 - return _ask(request.get_json(), validate_captcha=False) + data, status_code = _ask(request.get_json(), validate_captcha=False) + return data, status_code diff --git a/api/api/services/database.py b/api/api/services/database.py index 17a93d2..88d53df 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,10 +1,8 @@ import sqlite3 -from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() -migrate = Migrate() class Database: @@ -68,8 +66,8 @@ def delete(self, commit=True): class AccessKey(BaseModel): __tablename__ = "access_keys" access_key_id = db.Column(db.String(16), primary_key=True) - secret_access_key = db.Column(db.String(32)) - enabled = db.Column(db.Boolean(), default=True) + secret_access_key = db.Column(db.String(32), nullable=False) + enabled = db.Column(db.Boolean(), default=True, nullable=False) def __repr__(self): return f"" diff --git a/api/migrations/versions/71441c34724e_.py b/api/migrations/versions/71441c34724e_.py new file mode 100644 index 0000000..5360806 --- /dev/null +++ b/api/migrations/versions/71441c34724e_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 71441c34724e +Revises: +Create Date: 2024-02-28 14:11:13.601403 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '71441c34724e' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('access_keys', + sa.Column('access_key_id', sa.String(length=16), nullable=False), + sa.Column('secret_access_key', sa.String(length=32), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('access_key_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('access_keys') + # ### end Alembic commands ### diff --git a/api/scripts/local_run_migrations.sh b/api/scripts/local_run_migrations.sh new file mode 100644 index 0000000..daa87d0 --- /dev/null +++ b/api/scripts/local_run_migrations.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -x + +# DB MIGRATIONS: +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///:memory python3 -m flask db init # only the first time we initialize the DB +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///:memory python3 -m flask db migrate +# Reflect migrations into the database: +# FLASK_APP=api python3 -m flask db upgrade + +# Valid SQLite URL forms are: +# sqlite:///:memory: (or, sqlite://) +# sqlite:///relative/path/to/file.db +# sqlite:////absolute/path/to/file.db \ No newline at end of file diff --git a/api/scripts/production_run_api.sh b/api/scripts/production_run_api.sh new file mode 100644 index 0000000..3a749b6 --- /dev/null +++ b/api/scripts/production_run_api.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + + +echo "==> $(date +%H:%M:%S) ==> Migrating DB models... " +FLASK_APP=api python -m flask db upgrade + +echo "==> $(date +%H:%M:%S) ==> Running Gunicorn... " +exec gunicorn --bind 0.0.0.0:8000 "api:create_app()" \ No newline at end of file diff --git a/api/scripts/run.sh b/api/scripts/run.sh deleted file mode 100644 index 26db72c..0000000 --- a/api/scripts/run.sh +++ /dev/null @@ -1 +0,0 @@ -python3 -m flask --app api run --port 8000 \ No newline at end of file diff --git a/api/scripts/run_test_env.sh b/api/scripts/run_test_env.sh deleted file mode 100644 index 9ca35df..0000000 --- a/api/scripts/run_test_env.sh +++ /dev/null @@ -1,23 +0,0 @@ -touch /tmp/faucet_test.db - -# !! PRIVATE KEY FOR TEST PURPOSE ONLY !! -# 0x21b1ae205147d4e2fcdee7b3fc762aa21f955f3dace8a185306ac104be797081 -# !! DO NOT USE IN ANY OTHER CONTEXTS !! - -FAUCET_RPC_URL=https://rpc.chiadochain.net \ -FAUCET_PRIVATE_KEY="0x21b1ae205147d4e2fcdee7b3fc762aa21f955f3dace8a185306ac104be797081" \ -FAUCET_CHAIN_ID=10200 \ -FAUCET_DATABASE_URI=sqlite:///tmp/faucet_test.db \ -CAPTCHA_VERIFY_ENDPOINT=localhost \ -CAPTCHA_SECRET_KEY=testkey \ -python3 -m flask --app api run --port 8000 - -# DB MIGRATIONS: -## Generate migrations -### ENV_VARIABLES python3 -m flask --app api db init -### ENV_VARIABLES python3 -m flask --app api db migrate - -# Valid SQLite URL forms are: -# sqlite:///:memory: (or, sqlite://) -# sqlite:///relative/path/to/file.db -# sqlite:////absolute/path/to/file.db \ No newline at end of file diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 22420b4..68082ed 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,6 +1,7 @@ import os import pytest +from flask_migrate import upgrade from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, @@ -30,6 +31,7 @@ def app(self, mocker): mocker = self._mock(mocker, TEMP_ENV_VARS) app = self._create_app() with app.app_context(): + upgrade() yield app @pytest.fixture diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index ca6e29c..4122565 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -23,7 +23,7 @@ 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '10', 'FAUCET_ENABLED_TOKENS': json.dumps(FAUCET_ENABLED_TOKENS), - 'FAUCET_DATABASE_URI': 'sqlite:///', # run in-memory + 'FAUCET_DATABASE_URI': 'sqlite:///:memory', # run in-memory 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index bb2502f..3da6796 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -127,8 +127,8 @@ class TestCliAPI(BaseTest): def test_ask_route_parameters(self, client): access_key_id, secret_access_key = generate_access_key() http_headers = { - 'FAUCET_ACCESS_KEY_ID': access_key_id, - 'FAUCET_SECRET_ACCESS_KEY': secret_access_key + 'X-faucet-access-key-id': access_key_id, + 'X-faucet-secret-access-key': secret_access_key } response = client.post(api_prefix + '/cli/ask', json={}) diff --git a/api/tests/test_database.py b/api/tests/test_database.py index b70ff5e..5d3f645 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,15 +1,11 @@ from conftest import BaseTest -# from mock import patch -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) from api.services.database import AccessKey from api.utils import generate_access_key class TestDatabase(BaseTest): + def test_models(self, client): access_key_id, secret_access_key = generate_access_key() assert len(access_key_id) == 16