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
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 @@
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"