diff --git a/backend/moonstream/README.md b/backend/README.md similarity index 100% rename from backend/moonstream/README.md rename to backend/README.md diff --git a/backend/moonstream/api.py b/backend/moonstream/api.py index 06dfa581..9160b086 100644 --- a/backend/moonstream/api.py +++ b/backend/moonstream/api.py @@ -2,43 +2,21 @@ The Moonstream HTTP API """ import logging -from typing import Any, Dict, List, Optional -import uuid -from fastapi import ( - BackgroundTasks, - Depends, - FastAPI, - Form, - HTTPException, - Path, - Query, - Request, - Response, -) +from fastapi import FastAPI, Form from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import OAuth2PasswordRequestForm from . import data -from .settings import DOCS_TARGET_PATH, ORIGINS +from .routes.subscriptions import app as subscriptions_api +from .routes.users import app as users_api +from .settings import ORIGINS from .version import MOONSTREAM_VERSION logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -tags_metadata = [{"name": "users", "description": "Operations with users."}] +app = FastAPI(openapi_url=None) -app = FastAPI( - title=f"Moonstream API.", - description="The Bugout blockchain inspector API.", - version=MOONSTREAM_VERSION, - openapi_tags=tags_metadata, - openapi_url="/openapi.json", - docs_url=None, - redoc_url=f"/{DOCS_TARGET_PATH}", -) - -# CORS settings app.add_middleware( CORSMiddleware, allow_origins=ORIGINS, @@ -49,10 +27,14 @@ app.add_middleware( @app.get("/ping", response_model=data.PingResponse) -async def ping() -> data.PingResponse: +async def ping_handler() -> data.PingResponse: return data.PingResponse(status="ok") @app.get("/version", response_model=data.VersionResponse) -async def version() -> data.VersionResponse: +async def version_handler() -> data.VersionResponse: return data.VersionResponse(version=MOONSTREAM_VERSION) + + +app.mount("/subscriptions", subscriptions_api) +app.mount("/users", users_api) diff --git a/backend/moonstream/data.py b/backend/moonstream/data.py index d9319de3..aba1ca8b 100644 --- a/backend/moonstream/data.py +++ b/backend/moonstream/data.py @@ -1,7 +1,9 @@ """ Pydantic schemas for the Moonstream HTTP API """ -from pydantic import BaseModel +from typing import List + +from pydantic import BaseModel, Field class PingResponse(BaseModel): @@ -18,3 +20,24 @@ class VersionResponse(BaseModel): """ version: str + + +class SubscriptionRequest(BaseModel): + """ + Schema for data retrieving from frontend about subscription. + """ + + blockchain: str + + +class SubscriptionResponse(BaseModel): + """ + User subscription storing in Bugout resources. + """ + + user_id: str + blockchain: str + + +class SubscriptionsListResponse(BaseModel): + subscriptions: List[SubscriptionResponse] = Field(default_factory=list) diff --git a/backend/moonstream/middleware.py b/backend/moonstream/middleware.py new file mode 100644 index 00000000..e2a15175 --- /dev/null +++ b/backend/moonstream/middleware.py @@ -0,0 +1,68 @@ +import logging +from typing import Awaitable, Callable, Dict, Optional + +from bugout.data import BugoutUser +from bugout.exceptions import BugoutResponseException +from starlette.middleware.base import BaseHTTPMiddleware +from fastapi import Request, Response + +from .settings import MOONSTREAM_APPLICATION_ID, bugout_client as bc + +logger = logging.getLogger(__name__) + + +class BroodAuthMiddleware(BaseHTTPMiddleware): + """ + Checks the authorization header on the request. If it represents a verified Brood user, + create another request and get groups user belongs to, after this + adds a brood_user attribute to the request.state. Otherwise raises a 403 error. + """ + + def __init__(self, app, whitelist: Optional[Dict[str, str]] = None): + self.whitelist: Dict[str, str] = {} + if whitelist is not None: + self.whitelist = whitelist + super().__init__(app) + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ): + # Filter out endpoints with proper method to work without Bearer token (as create_user, login, etc) + path = request.url.path.rstrip("/") + method = request.method + if path in self.whitelist.keys() and self.whitelist[path] == method: + return await call_next(request) + + authorization_header = request.headers.get("authorization") + if authorization_header is None: + return Response( + status_code=403, content="No authorization header passed with request" + ) + user_token_list = authorization_header.split() + if len(user_token_list) != 2: + return Response(status_code=403, content="Wrong authorization header") + user_token: str = user_token_list[-1] + + try: + user: BugoutUser = bc.get_user(user_token) + if not user.verified: + logger.info( + f"Attempted journal access by unverified Brood account: {user.id}" + ) + return Response( + status_code=403, + content="Only verified accounts can access journals", + ) + if str(user.application_id) != str(MOONSTREAM_APPLICATION_ID): + return Response( + status_code=403, content="User does not belong to this application" + ) + except BugoutResponseException as e: + return Response(status_code=e.status_code, content=e.detail) + except Exception as e: + logger.error(f"Error processing Brood response: {str(e)}") + return Response(status_code=500, content="Internal server error") + + request.state.user = user + request.state.token = user_token + return await call_next(request) diff --git a/backend/moonstream/routes/__init__.py b/backend/moonstream/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/moonstream/routes/subscriptions.py b/backend/moonstream/routes/subscriptions.py new file mode 100644 index 00000000..22ea4b34 --- /dev/null +++ b/backend/moonstream/routes/subscriptions.py @@ -0,0 +1,100 @@ +""" +The Moonstream subscriptions HTTP API +""" +import logging +from typing import Dict + +from bugout.data import BugoutResource, BugoutResources +from bugout.exceptions import BugoutResponseException +from fastapi import Body, FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware + +from .. import data +from ..middleware import BroodAuthMiddleware +from ..settings import ( + MOONSTREAM_APPLICATION_ID, + DOCS_TARGET_PATH, + ORIGINS, + DOCS_PATHS, + bugout_client as bc, +) +from ..version import MOONSTREAM_VERSION + +logger = logging.getLogger(__name__) + +tags_metadata = [ + {"name": "subscriptions", "description": "Operations with subscriptions."}, +] + +app = FastAPI( + title=f"Moonstream subscriptions API.", + description="User subscriptions endpoints.", + version=MOONSTREAM_VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +whitelist_paths: Dict[str, str] = {} +whitelist_paths.update(DOCS_PATHS) +app.add_middleware(BroodAuthMiddleware, whitelist=whitelist_paths) + + +@app.post("/", tags=["subscriptions"], response_model=data.SubscriptionResponse) +async def add_subscription_handler( + request: Request, subscription_data: data.SubscriptionRequest = Body(...) +) -> data.SubscriptionResponse: + """ + Add subscription to blockchain stream data for user. + """ + token = request.state.token + user = request.state.user + resource_data = {"user_id": str(user.id)} + resource_data.update(subscription_data) + try: + resource: BugoutResource = bc.create_resource( + token=token, + application_id=MOONSTREAM_APPLICATION_ID, + resource_data=resource_data, + ) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return data.SubscriptionResponse( + user_id=resource.resource_data["user_id"], + blockchain=resource.resource_data["blockchain"], + ) + + +@app.get("/", tags=["subscriptions"], response_model=data.SubscriptionsListResponse) +async def get_subscriptions_handler(request: Request) -> data.SubscriptionsListResponse: + """ + Get user's subscriptions. + """ + token = request.state.token + params = {"user_id": str(request.state.user.id)} + try: + resources: BugoutResources = bc.list_resources(token=token, params=params) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return data.SubscriptionsListResponse( + subscriptions=[ + data.SubscriptionResponse( + user_id=resource.resource_data["user_id"], + blockchain=resource.resource_data["blockchain"], + ) + for resource in resources.resources + ] + ) diff --git a/backend/moonstream/routes/users.py b/backend/moonstream/routes/users.py new file mode 100644 index 00000000..276fcbb1 --- /dev/null +++ b/backend/moonstream/routes/users.py @@ -0,0 +1,172 @@ +""" +The Moonstream users HTTP API +""" +import logging +from typing import Any, Dict +import uuid + +from bugout.data import BugoutToken, BugoutUser +from bugout.exceptions import BugoutResponseException +from fastapi import ( + FastAPI, + Form, + HTTPException, + Request, +) +from fastapi.middleware.cors import CORSMiddleware + +from ..middleware import BroodAuthMiddleware +from ..settings import ( + MOONSTREAM_APPLICATION_ID, + DOCS_TARGET_PATH, + ORIGINS, + DOCS_PATHS, + bugout_client as bc, +) +from ..version import MOONSTREAM_VERSION + +logger = logging.getLogger(__name__) + +tags_metadata = [ + {"name": "users", "description": "Operations with users."}, + {"name": "tokens", "description": "Operations with user tokens."}, +] + +app = FastAPI( + title=f"Moonstream users API.", + description="User, token and password handlers.", + version=MOONSTREAM_VERSION, + openapi_tags=tags_metadata, + openapi_url="/openapi.json", + docs_url=None, + redoc_url=f"/{DOCS_TARGET_PATH}", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +whitelist_paths: Dict[str, str] = {} +whitelist_paths.update(DOCS_PATHS) +whitelist_paths.update( + { + "/users": "POST", + "/users/token": "POST", + "/users/password/restore": "POST", + "/users/password/reset": "POST", + } +) +app.add_middleware(BroodAuthMiddleware, whitelist=whitelist_paths) + + +@app.post("/", tags=["users"], response_model=BugoutUser) +async def create_user_handler( + username: str = Form(...), email: str = Form(...), password: str = Form(...) +) -> BugoutUser: + try: + user: BugoutUser = bc.create_user( + username=username, + email=email, + password=password, + application_id=MOONSTREAM_APPLICATION_ID, + ) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return user + + +@app.get("/", tags=["users"], response_model=BugoutUser) +async def get_user_handler(request: Request) -> BugoutUser: + user: BugoutUser = request.state.user + return user + + +@app.post("/password/restore", tags=["users"], response_model=Dict[str, Any]) +async def restore_password_handler(request: Request) -> Dict[str, Any]: + user = request.state.user + try: + response = bc.restore_password(email=user.email) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return response + + +@app.post("/password/reset", tags=["users"], response_model=BugoutUser) +async def reset_password_handler( + reset_id: str = Form(...), new_password: str = Form(...) +) -> BugoutUser: + try: + response = bc.reset_password(reset_id=reset_id, new_password=new_password) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return response + + +@app.post("/password/change", tags=["users"], response_model=BugoutUser) +async def change_password_handler( + request: Request, current_password: str = Form(...), new_password: str = Form(...) +) -> BugoutUser: + token = request.state.token + try: + user = bc.change_password( + token=token, current_password=current_password, new_password=new_password + ) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return user + + +@app.delete("/", tags=["users"], response_model=BugoutUser) +async def delete_user_handler( + request: Request, password: str = Form(...) +) -> BugoutUser: + user = request.state.user + token = request.state.token + try: + user = bc.delete_user(token=token, user_id=user.id, password=password) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return user + + +@app.post("/token", tags=["tokens"], response_model=BugoutToken) +async def login_handler( + username: str = Form(...), password: str = Form(...) +) -> BugoutToken: + try: + token: BugoutToken = bc.create_token( + username=username, + password=password, + application_id=MOONSTREAM_APPLICATION_ID, + ) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code) + except Exception as e: + raise HTTPException(status_code=500) + return token + + +@app.delete("/token", tags=["tokens"], response_model=uuid.UUID) +async def logout_handler(request: Request) -> uuid.UUID: + token = request.state.token + try: + token_id: uuid.UUID = bc.revoke_token(token=token) + except BugoutResponseException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500) + return token_id diff --git a/backend/moonstream/settings.py b/backend/moonstream/settings.py index ae8f3cbe..c524be5a 100644 --- a/backend/moonstream/settings.py +++ b/backend/moonstream/settings.py @@ -1,12 +1,25 @@ import os +from bugout.app import Bugout + +# Bugout +bugout_client = Bugout() + +MOONSTREAM_APPLICATION_ID = os.environ.get("MOONSTREAM_APPLICATION_ID") +if MOONSTREAM_APPLICATION_ID is None: + raise ValueError("MOONSTREAM_APPLICATION_ID environment variable must be set") + +MOONSTREAM_DATA_JOURNAL_ID = os.environ.get("MOONSTREAM_DATA_JOURNAL_ID") +if MOONSTREAM_DATA_JOURNAL_ID is None: + raise ValueError("MOONSTREAM_DATA_JOURNAL_ID environment variable must be set") + # Origin -RAW_ORIGIN = os.environ.get("MOONSTREAM_CORS_ALLOWED_ORIGINS") -if RAW_ORIGIN is None: +RAW_ORIGINS = os.environ.get("MOONSTREAM_CORS_ALLOWED_ORIGINS") +if RAW_ORIGINS is None: raise ValueError( - "MOONSTREAM_CORS_ALLOWED_ORIGINS environment variable must be set (comma-separated list of CORS allowed origins" + "MOONSTREAM_CORS_ALLOWED_ORIGINS environment variable must be set (comma-separated list of CORS allowed origins)" ) -ORIGINS = RAW_ORIGIN.split(",") +ORIGINS = RAW_ORIGINS.split(",") # OpenAPI DOCS_TARGET_PATH = "docs" @@ -14,3 +27,8 @@ MOONSTREAM_OPENAPI_LIST = [] MOONSTREAM_OPENAPI_LIST_RAW = os.environ.get("MOONSTREAM_OPENAPI_LIST") if MOONSTREAM_OPENAPI_LIST_RAW is not None: MOONSTREAM_OPENAPI_LIST = MOONSTREAM_OPENAPI_LIST_RAW.split(",") + +DOCS_PATHS = {} +for path in MOONSTREAM_OPENAPI_LIST: + DOCS_PATHS[f"/{path}/{DOCS_TARGET_PATH}"] = "GET" + DOCS_PATHS[f"/{path}/{DOCS_TARGET_PATH}/openapi.json"] = "GET" diff --git a/backend/moonstream/requirements.txt b/backend/requirements.txt similarity index 91% rename from backend/moonstream/requirements.txt rename to backend/requirements.txt index fa0f14a6..649acbd9 100644 --- a/backend/moonstream/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ asgiref==3.4.1 black==21.7b0 boto3==1.18.1 botocore==1.21.1 -bugout==0.1.12 +bugout==0.1.14 certifi==2021.5.30 charset-normalizer==2.0.3 click==8.0.1 @@ -14,9 +14,9 @@ jmespath==0.10.0 mypy==0.910 mypy-extensions==0.4.3 pathspec==0.9.0 -pkg-resources==0.0.0 pydantic==1.8.2 python-dateutil==2.8.2 +python-multipart==0.0.5 regex==2021.7.6 requests==2.26.0 s3transfer==0.5.0 diff --git a/backend/sample.env b/backend/sample.env index 58995f0e..8fa5f798 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -1,2 +1,4 @@ export MOONSTREAM_CORS_ALLOWED_ORIGINS="http://localhost:3000,https://moonstream.to" -export MOONSTREAM_OPENAPI_LIST="" +export MOONSTREAM_OPENAPI_LIST="users,subscriptions" +export MOONSTREAM_APPLICATION_ID="" +export MOONSTREAM_DATA_JOURNAL_ID=""