diff --git a/.dockerignore b/.dockerignore index 3aaeec7..2897fda 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ build/ **/*.pyo **/*.mo *.egg-info/ +*.png diff --git a/Dockerfile b/Dockerfile index 612231e..794c524 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.11-alpine +# argon2 needs these to build, and we need argon2 for secure password +# hashing/salting +RUN apk add gcc musl-dev libffi-dev +RUN python3 -m pip install -U cffi pip setuptools + # Enforcement to ensure passwords environment variables are not left blank. # This won't stop bad passwords from being used, but at least won't cause # errors or, worse, weaker crypt. @@ -21,9 +26,17 @@ RUN mkdir -p "${ENV_GHOSTFORGE_DATA_DIR}" WORKDIR /ghostforge COPY . . +# argon2 needs these to build, and we need argon2 for secure password +# hashing/salting +RUN apk add gcc musl-dev libffi-dev +RUN python3 -m pip install -U cffi pip setuptools + # Install ghostforge from the work directory. RUN pip install . +# Reclaim space from build-time packages +RUN apk del gcc musl-dev libffi-dev + # Expose the web "serve" port specific in the environment variables. ARG GHOSTFORGE_INTERNAL_WEB_PORT ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT} @@ -34,4 +47,4 @@ EXPOSE ${ENV_GHOSTFORGE_INTERNAL_WEB_PORT} # TODO: Replace with ghostforge_serve when it works. # This currently just keeps the container running for development. -CMD [ "python3", "-m", "http.server" ] +CMD ["sh", "-c", "uvicorn ghostforge.serve:gf --host 0.0.0.0 --port $GHOSTFORGE_INTERNAL_WEB_PORT"] diff --git a/README.md b/README.md index bf20b4a..8896469 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
-ghostforge Logo +ghostforge Logo # ghostforge diff --git a/alembic.ini b/alembic.ini index 9c73623..5684cf6 100644 --- a/alembic.ini +++ b/alembic.ini @@ -58,7 +58,6 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -# sqlalchemy.url = driver://user:pass@localhost/dbname sqlalchemy.url = postgresql+asyncpg://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_CONTAINER)s:5432/%(POSTGRES_DB)s [post_write_hooks] diff --git a/doc/ghostforge.png b/ghostforge.png similarity index 100% rename from doc/ghostforge.png rename to ghostforge.png diff --git a/ghostforge/auth.py b/ghostforge/auth.py new file mode 100644 index 0000000..7cbe6d0 --- /dev/null +++ b/ghostforge/auth.py @@ -0,0 +1,19 @@ +import os + +from fastapi_users.authentication import AuthenticationBackend +from fastapi_users.authentication import BearerTransport +from fastapi_users.authentication import JWTStrategy + +SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") +bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") + + +def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=SECRET, lifetime_seconds=3600) + + +auth_backend = AuthenticationBackend( + name="jwt", + transport=bearer_transport, + get_strategy=get_jwt_strategy, +) diff --git a/ghostforge/cli.py b/ghostforge/cli.py deleted file mode 100644 index e69de29..0000000 diff --git a/ghostforge/db.py b/ghostforge/db.py index 8956d98..4ee92ba 100644 --- a/ghostforge/db.py +++ b/ghostforge/db.py @@ -3,6 +3,7 @@ import os from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel database_url = ( f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:' @@ -18,3 +19,10 @@ async def get_session() -> AsyncSession: async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session() as session: yield session + + +async def create_db_and_tables(): + # TODO: Remove the drop, this is for dev + async with engine.begin() as conn: + # await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) diff --git a/ghostforge/htmljson.py b/ghostforge/htmljson.py new file mode 100644 index 0000000..40eee26 --- /dev/null +++ b/ghostforge/htmljson.py @@ -0,0 +1,88 @@ +from functools import wraps +from inspect import _ParameterKind +from inspect import Parameter +from inspect import signature +from typing import Callable + +from fastapi import Request +from pydantic import BaseModel + +from ghostforge.templates import templates + +# Original credit: https://github.com/acmpo6ou/fastapi_html_json/blob/master/html_json.py + + +class HtmlJson: + def __init__(self): + """ + Provides @html_or_json decorator, see its documentation for more info. + :param templates_dir: directory containing jinja2 templates. + """ + pass + + @staticmethod + def add_request_param(wrapper: Callable, f: Callable): + """ + Adds `request` parameter to signature of wrapper if it's not there already. + :param f: decorated function. + """ + sig = signature(f) + if "request" in sig.parameters: + return + + params = list(sig.parameters.values()) + request_param = Parameter("request", _ParameterKind.POSITIONAL_OR_KEYWORD, annotation=Request) + params.append(request_param) + wrapper.__signature__ = sig.replace(parameters=params) + + def render_template(self, template: str, request: Request, result): + """ + Renders jinja2 template no matter what the view function returns: be it dictionary, + pydantic model or a list. + + :param template: path to the template. + :param request: needed by TemplateResponse. + :param result: return value of the view function. + :param breadcrumbs: breadcrumbs for HTML rendering. + :return: rendered template. + """ + if isinstance(result, BaseModel): + result = result.dict() + elif isinstance(result, list): + result = {"data": result[:]} + + if hasattr(request.state, "ghostforge"): + if "crumbs" in request.state.ghostforge: + result["crumbs"] = request.state.ghostforge["crumbs"] + + result.update({"request": request}) + return templates.TemplateResponse(template, result) + + def html_or_json(self, template: str): + """ + A decorator that will make decorated async view function able to return jinja2 template + or json depending on Accept header of request. + :param template: path to jinja2 template. + """ + + def decorator(f: Callable): + @wraps(f) + async def wrapper(*args, **kwargs): + request = kwargs["request"] + + try: + result = await f(*args, **kwargs) + except TypeError: + kwargs.pop("request") + result = await f(*args, **kwargs) + + accept = request.headers["accept"].split(",")[0] + if accept == "text/html": + return self.render_template(template, request, result) + + return result + + self.add_request_param(wrapper, f) + return wrapper + + return decorator diff --git a/ghostforge/models.py b/ghostforge/models.py deleted file mode 100644 index 7d53115..0000000 --- a/ghostforge/models.py +++ /dev/null @@ -1,8 +0,0 @@ -from sqlmodel import Field -from sqlmodel import SQLModel - - -class User(SQLModel, table=True): - id: int = Field(default=None, primary_key=True) - name: str = Field(unique=True) - password: str diff --git a/ghostforge/serve.py b/ghostforge/serve.py index 069db2c..1557d9b 100644 --- a/ghostforge/serve.py +++ b/ghostforge/serve.py @@ -1,11 +1,17 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from users import gf as gf_users +from ghostforge.db import create_db_and_tables +from ghostforge.users import gf as gf_users # from ghostforge.models import User gf = FastAPI() -gf.mount("/static", StaticFiles(directory="static"), name="static") +gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static") gf.include_router(gf_users) + + +@gf.on_event("startup") +async def on_startup(): + await create_db_and_tables() diff --git a/ghostforge/templates.py b/ghostforge/templates.py index a027653..b717a82 100644 --- a/ghostforge/templates.py +++ b/ghostforge/templates.py @@ -2,7 +2,7 @@ import importlib.metadata from fastapi.templating import Jinja2Templates -templates = Jinja2Templates(directory="templates") +templates = Jinja2Templates(directory="ghostforge/templates") # Inject version string and Github URL into every template for navbar display. try: @@ -15,31 +15,31 @@ templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/gho # Same, but build the navbar from an ordered dictionary for centralization. # Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted. templates.env.globals["gf_navbar"] = { - "Management": { - "Dashboard": "/user/0", - "New Ghost": "/user/0", - "Active Ghosts": "/user/0", - "Archived Ghosts": "/user/0", + "Ghosts": { + "Dashboard": "/users/0", + "New": "/users/0", + "Active": "/users/0", + "Archived": "/users/0", }, "Research": { - "Guidebook": "/user/0", - "Cheat Sheet": "/user/0", + "Guidebook": "/users/0", + "Cheat Sheet": "/users/0", }, "Settings": { - "Your Profile": "/user/0", - "Configuration": "/user/0", - "Integrations": "/user/0", - "Manage Users": "/user/0", + "Your Profile": "/users/0", + "Configuration": "/users/0", + "Integrations": "/users/0", + "Manage Users": "/users/0", }, "Meta": { - "About GhostForge": "/user/0", - "System Logs": "/user/0", - "Logout": "/user/0", + "About GhostForge": "/users/0", + "System Logs": "/users/0", + "Logout": "/users/0", }, } templates.env.globals["avatar_menu"] = { - "Dashboard": "/user/0", - "Your Profile": "/user/0", - "Logout": "/user/0", + "Dashboard": "/users/0", + "Your Profile": "/users/0", + "Logout": "/users/0", } diff --git a/ghostforge/templates/user.html b/ghostforge/templates/user.html index 32dfebc..f5f680a 100644 --- a/ghostforge/templates/user.html +++ b/ghostforge/templates/user.html @@ -11,7 +11,8 @@
-
Avatar
+
Avatar
Bruce Banner
THE HULK
diff --git a/ghostforge/users.py b/ghostforge/users.py index 1ccb8f2..6f456c3 100644 --- a/ghostforge/users.py +++ b/ghostforge/users.py @@ -1,15 +1,109 @@ -from fastapi import APIRouter -from fastapi import Request -from fastapi.responses import HTMLResponse +from datetime import datetime +from typing import List +from typing import Optional -from templates import templates +from argon2 import PasswordHasher +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi import Query +from fastapi import Request +from sqlmodel import Field +from sqlmodel import select +from sqlmodel import Session +from sqlmodel import SQLModel + +from ghostforge.db import get_session +from ghostforge.htmljson import HtmlJson gf = APIRouter() +ph = PasswordHasher() +hj = HtmlJson() -@gf.get("/user/{id}", response_class=HTMLResponse) -async def get_user(request: Request, id: str): - crumbs = [("settings", False), ("users", "/users"), (id, False)] - return templates.TemplateResponse( - "user.html", {"request": request, "crumbs": crumbs, "user": "test", "id": id, "title": f"User {id}"} - ) +class UserBase(SQLModel): + name: str = Field() + + class Config: + orm_mode = True + + +class User(UserBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + creation: datetime = Field(default=datetime.now()) + password_hash: Optional[str] + + def verify_password(self, password: str): + return ph.verify(self.password_hash, password) + + def set_password(self, password: str): + self.password_hash = ph.hash(password) + + +class UserCreate(UserBase): + name: str + password: str + + +class UserRead(UserBase): + id: int + + +class UserUpdate(SQLModel): + name: Optional[str] = None + + +@gf.get("/users", response_model=List[UserRead]) +async def read_users( + offset: int = 0, limit: int = Query(default=100, lte=100), session: Session = Depends(get_session) +): + users = await session.execute(select(User).offset(offset).limit(limit)) + return [UserRead.from_orm(user[0]) for user in users.all()] + + +@gf.post("/users", response_model=UserRead) +async def create_hero(user: UserCreate, session: Session = Depends(get_session)): + new_user = User.from_orm(user) + if len(user.password) < 12: + raise HTTPException(status_code=442, detail="Password must be at least 12 characters") + new_user.set_password(user.password) + session.add(new_user) + await session.commit() + await session.refresh(new_user) + return new_user + + +@gf.get("/users/{user_id}", response_model=UserRead) +@hj.html_or_json("user.html") +async def read_user(user_id: int, session: Session = Depends(get_session), request: Request = None): + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + data = {"crumbs": [("settings", False), ("users", "/users"), (user_id, False)]} + request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) + return user + + +@gf.patch("/users/{user_id}", response_model=UserRead) +async def update_user(user_id: int, user: UserUpdate, session: Session = Depends(get_session), request: Request = None): + edit_user = await session.get(User, user_id) + if not edit_user: + raise HTTPException(status_code=404, detail="User not found") + data = user.dict(exclude_unset=True) + for key, value in data.items(): + setattr(edit_user, key, value) + session.add(edit_user) + await session.commit() + await session.refresh(edit_user) + return edit_user + + +@gf.delete("/users/{user_id}") +async def delete_user(user_id: int, session: Session = Depends(get_session), request: Request = None): + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + session.delete(user) + await session.commit() + return {"ok": True} diff --git a/pyproject.toml b/pyproject.toml index 0b90b8d..a3d3a41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,11 @@ dependencies = [ "sqlmodel==0.0.8", "greenlet==2.0.2", "jinja2==3.1.2", + "argon2-cffi==21.3.0", + "fastapi-users==11.0.0", + "fastapi-users-db-sqlalchemy==5.0.0", ] -[project.scripts] -ghostforge_serve = "ghostforge.serve:start" - [project.urls] homepage = "https://github.com/DarrylNixon/ghostforge" repository = "https://github.com/DarrylNixon/ghostforge"