|
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import Response
|
|
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.config import SECONDS_PER_DAY
|
|
from devplacepy.database import get_table, get_setting
|
|
from devplacepy.utils import verify_password_async, register_account_async
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.devrant.params import merge_params
|
|
from devplacepy.services.devrant.tokens import issue_token, resolve_user, revoke_all
|
|
from devplacepy.services.devrant.profile import build_profile
|
|
from devplacepy.services.devrant.ids import user_by_id
|
|
from devplacepy.services.devrant.avatar import render_png
|
|
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
|
|
PASSWORD_MIN = 6
|
|
|
|
_avatar_cache = TTLCache(ttl=SECONDS_PER_DAY, max_size=4096)
|
|
|
|
|
|
@router.post("/users/auth-token")
|
|
async def auth_token(request: Request):
|
|
params = await merge_params(request)
|
|
identifier = (params.get("username") or "").strip()
|
|
password = params.get("password") or ""
|
|
invalid = "Invalid login credentials entered. Please try again."
|
|
if not identifier or not password:
|
|
return dr_error(invalid, 400)
|
|
users = get_table("users")
|
|
user = users.find_one(username=identifier) or users.find_one(
|
|
email=identifier.lower()
|
|
)
|
|
if (
|
|
not user
|
|
or not user.get("is_active", True)
|
|
or not await verify_password_async(password, user["password_hash"])
|
|
):
|
|
audit.record(
|
|
request,
|
|
"auth.login.failure",
|
|
user=None,
|
|
actor_kind="guest",
|
|
result="failure",
|
|
origin="devrant",
|
|
metadata={"identifier": identifier},
|
|
summary=f"failed devrant login for {identifier}",
|
|
)
|
|
return dr_error(invalid, 400)
|
|
token = issue_token(user)
|
|
logger.info("devrant login for %s", user["username"])
|
|
audit.record(
|
|
request,
|
|
"auth.login.success",
|
|
user=user,
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=user["username"],
|
|
origin="devrant",
|
|
summary=f"user {user['username']} logged in via devrant",
|
|
links=[audit.target("user", user["uid"], user["username"])],
|
|
)
|
|
return dr_ok(auth_token=token)
|
|
|
|
|
|
@router.post("/users")
|
|
async def register(request: Request):
|
|
params = await merge_params(request)
|
|
username = (params.get("username") or "").strip()
|
|
email = (params.get("email") or "").strip().lower()
|
|
password = params.get("password") or ""
|
|
if get_setting("registration_open", "1") != "1":
|
|
return dr_error("Registration is closed.", error_field="username")
|
|
if not USERNAME_PATTERN.match(username):
|
|
return dr_error(
|
|
"Username must be 3-32 letters, numbers, hyphens or underscores.",
|
|
error_field="username",
|
|
)
|
|
if "@" not in email:
|
|
return dr_error("Please enter a valid email address.", error_field="email")
|
|
if len(password) < PASSWORD_MIN:
|
|
return dr_error(
|
|
"Password must be at least 6 characters.", error_field="password"
|
|
)
|
|
users = get_table("users")
|
|
if users.find_one(username=username):
|
|
return dr_error("That username is already taken.", error_field="username")
|
|
if users.find_one(email=email):
|
|
return dr_error("That email is already registered.", error_field="email")
|
|
uid, role, is_first = await register_account_async(username, email, password)
|
|
logger.info("devrant registration for %s", username)
|
|
audit.record(
|
|
request,
|
|
"auth.signup",
|
|
user={"uid": uid, "username": username, "role": role},
|
|
target_type="user",
|
|
target_uid=uid,
|
|
target_label=username,
|
|
new_value=role,
|
|
origin="devrant",
|
|
metadata={"first_user": is_first},
|
|
summary=f"user {username} registered via devrant",
|
|
links=[audit.target("user", uid, username)],
|
|
)
|
|
user = users.find_one(uid=uid)
|
|
return dr_ok(auth_token=issue_token(user))
|
|
|
|
|
|
@router.get("/get-user-id")
|
|
async def get_user_id(request: Request):
|
|
params = await merge_params(request)
|
|
username = (params.get("username") or "").strip()
|
|
user = get_table("users").find_one(username=username) if username else None
|
|
if not user:
|
|
return dr_error("User not found.")
|
|
return dr_ok(user_id=int(user["id"]))
|
|
|
|
|
|
@router.get("/users/{user_id}")
|
|
async def profile(request: Request, user_id: str):
|
|
params = await merge_params(request)
|
|
user = user_by_id(user_id)
|
|
if not user:
|
|
return dr_error("User not found.")
|
|
viewer = resolve_user(params)
|
|
return dr_ok(profile=build_profile(user, viewer))
|
|
|
|
|
|
@router.post("/users/me/edit-profile")
|
|
async def edit_profile(request: Request):
|
|
params = await merge_params(request)
|
|
user = resolve_user(params)
|
|
if not user:
|
|
return unauthorized()
|
|
updates = {"uid": user["uid"]}
|
|
field_map = {
|
|
"profile_about": "bio",
|
|
"profile_location": "location",
|
|
"profile_github": "git_link",
|
|
"profile_website": "website",
|
|
}
|
|
for source, column in field_map.items():
|
|
if source in params:
|
|
updates[column] = (params.get(source) or "")[:500]
|
|
if len(updates) > 1:
|
|
get_table("users").update(updates, ["uid"])
|
|
audit.record(
|
|
request,
|
|
"profile.update",
|
|
user=user,
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=user["username"],
|
|
origin="devrant",
|
|
summary=f"{user['username']} edited profile via devrant",
|
|
links=[audit.target("user", user["uid"], user["username"])],
|
|
)
|
|
return dr_ok()
|
|
|
|
|
|
@router.post("/users/forgot-password")
|
|
async def forgot_password(request: Request):
|
|
await merge_params(request)
|
|
return dr_ok()
|
|
|
|
|
|
@router.post("/users/me/mark-news-read")
|
|
async def mark_news_read(request: Request):
|
|
await merge_params(request)
|
|
return dr_ok()
|
|
|
|
|
|
@router.post("/users/me/resend-confirm")
|
|
async def resend_confirm(request: Request):
|
|
await merge_params(request)
|
|
return dr_ok()
|
|
|
|
|
|
@router.delete("/users/me")
|
|
async def delete_account(request: Request):
|
|
params = await merge_params(request)
|
|
user = resolve_user(params)
|
|
if not user:
|
|
return unauthorized()
|
|
get_table("users").update(
|
|
{"uid": user["uid"], "is_active": False}, ["uid"]
|
|
)
|
|
revoke_all(user["uid"])
|
|
logger.info("devrant account deactivated for %s", user["username"])
|
|
audit.record(
|
|
request,
|
|
"auth.account.disable",
|
|
user=user,
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=user["username"],
|
|
origin="devrant",
|
|
summary=f"{user['username']} deactivated account via devrant",
|
|
links=[audit.target("user", user["uid"], user["username"])],
|
|
)
|
|
return dr_ok()
|
|
|
|
|
|
@router.get("/avatars/u/{seed}.png")
|
|
async def avatar_image(request: Request, seed: str, size: int = 128):
|
|
size = max(16, min(512, size))
|
|
cache_key = f"{seed}:{size}"
|
|
png = _avatar_cache.get(cache_key)
|
|
if png is None:
|
|
png = render_png(seed, size)
|
|
_avatar_cache.set(cache_key, png)
|
|
headers = {"Cache-Control": f"public, max-age={SECONDS_PER_DAY}, immutable"}
|
|
return Response(content=png, media_type="image/png", headers=headers)
|