diff --git a/README.md b/README.md index 8896469..836784b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ cp .env.sample .env && \ PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \ JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env'; docker-compose up --detach --build; -docker exec --interactive --tty ghostforge ghostforge_adduser; +docker exec --interactive --tty ghostforge ghostforge_adduser --superuser; ``` Follow the prompts to create an administrator user. Assuming you didn't change the default port, browse to [http://localhost:1337/](http://localhost:1337/) to begin using `ghostforge` with your new credentials. diff --git a/ghostforge/cli.py b/ghostforge/cli.py index f1c7e8f..1d7ea69 100644 --- a/ghostforge/cli.py +++ b/ghostforge/cli.py @@ -1,23 +1,17 @@ import argparse import asyncio -import contextlib import getpass import re from typing import Tuple from fastapi_users.exceptions import UserAlreadyExists -from ghostforge.db import get_session -from ghostforge.db import get_user_db -from ghostforge.users import get_user_manager +from ghostforge.users import get_async_session_context +from ghostforge.users import get_user_db_context +from ghostforge.users import get_user_manager_context from ghostforge.users import UserCreate -get_async_session_context = contextlib.asynccontextmanager(get_session) -get_user_db_context = contextlib.asynccontextmanager(get_user_db) -get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) - - def is_valid_email(email: str) -> bool: email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(email_regex, email)) diff --git a/ghostforge/ghosts.py b/ghostforge/ghosts.py index 40f23f9..5cc3fd8 100644 --- a/ghostforge/ghosts.py +++ b/ghostforge/ghosts.py @@ -19,7 +19,6 @@ from sqlalchemy import or_ from sqlalchemy import select from sqlalchemy import Text from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session from sqlmodel import and_ from sqlmodel import AutoString from sqlmodel import Column @@ -116,7 +115,7 @@ async def read_ghost( if not can_view: raise HTTPException(status_code=403, detail="You're not authorized to see this ghost") result = await session.execute( - select(Ghost, User.username.label("owner_username")) + select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid")) .join(User, Ghost.owner_id == User.id) .where(Ghost.id == ghost_id) ) @@ -189,7 +188,10 @@ async def get_ghosts( # Retrieve filtered ghosts from database query = ( - select(Ghost, User.email.label("owner_email")).offset(offset).limit(limit).join(User, Ghost.owner_id == User.id) + select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid")) + .offset(offset) + .limit(limit) + .join(User, Ghost.owner_id == User.id) ) permission_filter = or_( Ghost.owner_id == current_user.id, @@ -231,7 +233,7 @@ async def read_users( current_user: Annotated[User, Depends(get_current_user())], offset: int = 0, limit: int = Query(default=100, lte=100), - session: Session = Depends(get_session), + session: AsyncSession = Depends(get_session), request: Request = None, ): subquery = ( @@ -242,6 +244,6 @@ async def read_users( ) query = await session.execute(select(Ghost).where(Ghost.id.in_(subquery)).offset(offset).limit(limit)) ghosts = query.scalars().all() - data = {"ghosts": ghosts, "user": current_user, "crumbs": [("ghosts", "/ghosts")]} + data = {"ghosts": ghosts, "user": current_user, "crumbs": [("ghosts", False)]} request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) return ghosts diff --git a/ghostforge/htmljson.py b/ghostforge/htmljson.py index 7c694ba..4505849 100644 --- a/ghostforge/htmljson.py +++ b/ghostforge/htmljson.py @@ -33,7 +33,7 @@ class HtmlJson: params = list(sig.parameters.values()) request_param = Parameter("request", _ParameterKind.POSITIONAL_OR_KEYWORD, annotation=Request) - params.append(request_param) + params.insert(0, request_param) wrapper.__signature__ = sig.replace(parameters=params) def render_template(self, template: str, request: Request, result): diff --git a/ghostforge/serve.py b/ghostforge/serve.py index a5898bb..4619881 100644 --- a/ghostforge/serve.py +++ b/ghostforge/serve.py @@ -16,6 +16,7 @@ from ghostforge.pages import gf as gf_pages from ghostforge.templates import templates from ghostforge.users import fastapi_users from ghostforge.users import get_current_user +from ghostforge.users import gf as gf_users from ghostforge.users import jwt_backend from ghostforge.users import UserCreate from ghostforge.users import UserRead @@ -25,6 +26,7 @@ from ghostforge.users import web_backend gf = FastAPI() gf.mount("/static", StaticFiles(directory="ghostforge/static"), name="static") +gf.include_router(gf_users) gf.include_router(gf_ghosts) gf.include_router(gf_pages) gf.include_router(gf_ethnicities) diff --git a/ghostforge/templates/ghosts/ghosts.html b/ghostforge/templates/ghosts/ghosts.html index 3633175..935390b 100644 --- a/ghostforge/templates/ghosts/ghosts.html +++ b/ghostforge/templates/ghosts/ghosts.html @@ -88,7 +88,12 @@ return month + '/' + day + '/' + year; } }, - { "data": "owner_email" }, + { + "data": "owner_username", + "render": function (data, type, row, meta) { + return '' + data + ''; + } + } ] }); }); diff --git a/ghostforge/templates/user.html b/ghostforge/templates/user.html index f5f680a..c54c284 100644 --- a/ghostforge/templates/user.html +++ b/ghostforge/templates/user.html @@ -1,55 +1,66 @@ {% extends "base.html" %} +{% block title %}{{ target_user.username }}{% endblock title %} + {% block content %}
-

Words!

+

Manage User

-
-
+
+
Avatar
-
Bruce Banner
-
THE HULK
+
{{ target_user.username }}
+
+ {% if target_user.is_superuser %} + Administrator + {% else %} + Normal User + {% endif %} + {% if not target_user.is_active %} + Deactivated + {% endif %} +
-
E-mail
-
bruce.banner@hulk.com
+
Username
+
{{ target_user.username }}
+ data-tooltip="Change Username" id="edit_username">
-
Skype
-
bruce.banner
+
E-mail
+
{{ target_user.email }}
- +
-
Location
-
Dayton, Ohio
+
New Password
+
blocked
- +
@@ -58,6 +69,6 @@
-
+
{% endblock content %} diff --git a/ghostforge/templates/users.html b/ghostforge/templates/users.html new file mode 100644 index 0000000..699584c --- /dev/null +++ b/ghostforge/templates/users.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Browse Ghosts{% endblock title %} + +{% block js %} + + + + + + + + + + + + + + + +{% endblock js %} + +{% block css %} + + + + + + + + +{% endblock css %} + +{% block content %} +
+
+

Manage Users

+
+
+
+
+ + + + + + + + + + + + +
IDUsernameActiveAdministratorE-mail
+
+
+{% endblock content %} + +{% block bottomjs %} + +{% endblock bottomjs %} diff --git a/ghostforge/users.py b/ghostforge/users.py index bd6cb06..b217a6f 100644 --- a/ghostforge/users.py +++ b/ghostforge/users.py @@ -1,10 +1,18 @@ +import contextlib import os import uuid +from typing import Annotated +from typing import List from typing import Optional from fastapi import APIRouter from fastapi import Depends +from fastapi import Form +from fastapi import HTTPException +from fastapi import Query from fastapi import Request +from fastapi.responses import HTMLResponse +from fastapi.responses import RedirectResponse from fastapi_users import BaseUserManager from fastapi_users import FastAPIUsers from fastapi_users import schemas @@ -13,15 +21,37 @@ from fastapi_users.authentication import AuthenticationBackend from fastapi_users.authentication import BearerTransport from fastapi_users.authentication import CookieTransport from fastapi_users.authentication import JWTStrategy +from fastapi_users.exceptions import UserAlreadyExists from fastapi_users_db_sqlmodel import SQLModelUserDatabase +from sqlalchemy import asc +from sqlalchemy import desc +from sqlalchemy import func +from sqlalchemy import inspect +from sqlalchemy import or_ +from sqlalchemy import select +from sqlalchemy import Text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import AutoString +from ghostforge.db import get_session from ghostforge.db import get_user_db from ghostforge.db import User +from ghostforge.htmljson import HtmlJson SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET") + +async def get_user_manager(user_db: SQLModelUserDatabase = Depends(get_user_db)): + yield UserManager(user_db) + + +get_async_session_context = contextlib.asynccontextmanager(get_session) +get_user_db_context = contextlib.asynccontextmanager(get_user_db) +get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) + gf = APIRouter() +hj = HtmlJson() class UserRead(schemas.BaseUser[uuid.UUID]): @@ -30,10 +60,11 @@ class UserRead(schemas.BaseUser[uuid.UUID]): class UserCreate(schemas.BaseUserCreate): username: str + email: str class UserUpdate(schemas.BaseUserUpdate): - username: str + pass class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): @@ -50,10 +81,6 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): print(f"Verification requested for user {user.id}. Verification token: {token}") -async def get_user_manager(user_db: SQLModelUserDatabase = Depends(get_user_db)): - yield UserManager(user_db) - - bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") cookie_transport = CookieTransport(cookie_httponly=True, cookie_name="ghostforge", cookie_samesite="strict") @@ -75,3 +102,142 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [web_backend, jw def get_current_user(active: bool = True, optional: bool = False) -> User: return fastapi_users.current_user(active=active, optional=optional) + + +@gf.get("/manage", response_model=List[UserRead]) +@hj.html_or_json("users.html") +async def read_users( + current_user: Annotated[User, Depends(get_current_user())], + offset: int = 0, + limit: int = Query(default=100, lte=100), + session: AsyncSession = Depends(get_session), + request: Request = None, +): + query = await session.execute(select(User).offset(offset).limit(limit)) + users = query.scalars().all() + data = {"users": users, "user": current_user, "crumbs": [("settings", False), ("users", False)]} + request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) + return users + + +@gf.post("/manage/new", response_model=UserRead) +async def create_user( + user: UserCreate, + current_user: Annotated[User, Depends(get_current_user())], + session: AsyncSession = Depends(get_session), +): + try: + async with get_async_session_context() as session: + async with get_user_db_context(session) as user_db: + async with get_user_manager_context(user_db) as user_manager: + if not current_user.is_superuser: + user.is_superuser = False + user = await user_manager.create( + UserCreate( + email=user.email, + username=user.username, + password=user.password, + is_superuser=user.is_superuser, + ) + ) + return UserRead(user) + except UserAlreadyExists: + return {"error": "User exists"} + + +@gf.get("/manage/{user_guid}", response_model=UserRead) +@hj.html_or_json("user.html") +async def get_user( + user_guid: str, + current_user: Annotated[User, Depends(get_current_user())], + user_manager: UserManager = Depends(get_user_manager), + session: AsyncSession = Depends(get_session), + request: Request = None, +): + async with get_user_db_context(session) as user_db: + async with get_user_manager_context(user_db) as user_manager: + user = await user_manager.get(user_guid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + data = { + "target_user": user, + "user": current_user, + "crumbs": [("settings", False), ("users", "/manage"), (user.id, False)], + } + request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) + return user + + +@gf.put("/manage/{user_guid}", response_model=UserRead) +async def update_user( + user_guid: str, + user_update: UserUpdate, + current_user: Annotated[User, Depends(get_current_user())], + user_manager: UserManager = Depends(get_user_manager), + session: AsyncSession = Depends(get_session), +): + if not current_user.is_superuser and current_user.id != user_guid: + raise HTTPException(status_code=403, detail="No permission to edit this user") + db_user = await user_manager.get(user_guid, session) + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + update_data = user_update.dict(exclude_unset=True) + if "is_superuser" in update_data and not current_user.is_superuser: + del update_data["is_superuser"] + if "password" in update_data: + update_data["hashed_password"] = await user_manager.hash_password(update_data["password"]) + del update_data["password"] + await user_manager.update(db_user, session, **update_data) + + await session.refresh(db_user) + return db_user + + +@gf.get("/profile", response_class=HTMLResponse) +async def home(request: Request, current_user=Depends(get_current_user())): + return RedirectResponse(url=f"/manage/{current_user.id}") + + +@gf.post("/manage") +async def get_users( + current_user: Annotated[User, Depends(get_current_user())], + start: int = Form(None, alias="start"), + length: int = Form(None, alias="length"), + search: Optional[str] = Form(None, alias="search[value]"), + order_col: int = Form(0, alias="order[0][column]"), + order_dir: str = Form("asc", alias="order[0][dir]"), + session: AsyncSession = Depends(get_session), + request: Request = None, +): + offset = start + limit = offset + length + + # Get the column name based on the order_col integer value + user_columns = [c.key for c in inspect(User).c] + order_col_name = user_columns[order_col] + + # Retrieve filtered users from database + count = select(func.count(User.id)) + total_users = (await session.execute(count)).scalar() + + query = select(User).offset(offset).limit(limit) + + if search: + conditions = [] + for col in user_columns: + column_attr = getattr(User, col) + if isinstance(column_attr.type, (AutoString, Text)): + conditions.append(column_attr.ilike(f"%{search}%")) + query = query.where(or_(*conditions)) + if order_dir == "asc": + query = query.order_by(asc(getattr(User, order_col_name))) + else: + query = query.order_by(desc(getattr(User, order_col_name))) + users = (await session.execute(query)).all() + + return { + "recordsTotal": total_users, + "recordsFiltered": total_users, + "data": users, + }