217 lines
7.6 KiB
Python
Raw Normal View History

2026-05-12 12:45:52 +02:00
import hashlib
2026-05-11 00:41:41 +02:00
import secrets
2026-05-10 09:08:12 +02:00
import logging
2026-05-23 05:44:04 +02:00
from typing import Annotated
2026-05-14 04:12:19 +02:00
from datetime import datetime, timedelta, timezone
2026-05-23 05:44:04 +02:00
from fastapi import APIRouter, Request, Form
2026-05-10 09:08:12 +02:00
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
2026-05-11 05:30:51 +02:00
from devplacepy.seo import base_seo_context
2026-05-23 05:44:04 +02:00
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
2026-05-10 09:08:12 +02:00
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/signup", response_class=HTMLResponse)
async def signup_page(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/feed", status_code=302)
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(
request,
title="Join DevPlace",
description="Create your DevPlace account and start connecting with developers.",
robots="noindex,nofollow",
)
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request})
2026-05-10 09:08:12 +02:00
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/feed", status_code=302)
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(
request,
title="Sign In",
description="Sign in to DevPlace to connect with developers.",
robots="noindex,nofollow",
)
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "login.html", {**seo_ctx, "request": request})
2026-05-10 09:08:12 +02:00
@router.post("/signup")
2026-05-23 05:44:04 +02:00
async def signup(request: Request, data: Annotated[SignupForm, Form()]):
username = data.username
email = data.email.strip().lower()
password = data.password
2026-05-10 09:08:12 +02:00
errors = []
users = get_table("users")
if users.find_one(username=username):
errors.append("Username already taken")
if users.find_one(email=email):
errors.append("Email already registered")
if errors:
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow")
2026-05-10 09:08:12 +02:00
return templates.TemplateResponse(
2026-05-13 23:03:06 +02:00
request, "signup.html",
2026-05-11 05:30:51 +02:00
{**seo_ctx, "request": request, "errors": errors, "username": username, "email": email},
2026-05-10 09:08:12 +02:00
)
uid = generate_uid()
2026-05-11 05:30:51 +02:00
is_first = len(list(users.all())) == 0
2026-05-10 09:08:12 +02:00
users.insert({
"uid": uid,
"username": username,
"email": email,
"password_hash": hash_password(password),
"bio": "",
"location": "",
"git_link": "",
"website": "",
2026-05-11 05:30:51 +02:00
"role": "Admin" if is_first else "Member",
"is_active": True,
2026-05-10 09:08:12 +02:00
"level": 1,
"xp": 0,
"stars": 0,
2026-05-14 04:12:19 +02:00
"created_at": datetime.now(timezone.utc).isoformat(),
2026-05-10 09:08:12 +02:00
})
2026-05-11 00:41:41 +02:00
badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
2026-05-14 04:12:19 +02:00
"created_at": datetime.now(timezone.utc).isoformat(),
2026-05-11 00:41:41 +02:00
})
2026-05-10 09:08:12 +02:00
token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302)
2026-05-12 12:45:52 +02:00
response.set_cookie(key="session", value=token, max_age=86400 * 7, httponly=True, samesite="lax")
2026-05-10 09:08:12 +02:00
logger.info(f"User {username} signed up")
return response
@router.post("/login")
2026-05-23 05:44:04 +02:00
async def login(request: Request, data: Annotated[LoginForm, Form()]):
email = data.email.strip().lower()
password = data.password
remember_me = data.remember_me == "on"
2026-05-10 09:08:12 +02:00
errors = []
users = get_table("users")
2026-05-23 05:44:04 +02:00
user = users.find_one(email=email)
2026-05-10 09:08:12 +02:00
if not user or not verify_password(password, user["password_hash"]):
errors.append("Invalid email or password")
if errors:
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
2026-05-10 09:08:12 +02:00
return templates.TemplateResponse(
2026-05-13 23:03:06 +02:00
request, "login.html",
2026-05-11 05:30:51 +02:00
{**seo_ctx, "request": request, "errors": errors, "email": email},
2026-05-10 09:08:12 +02:00
)
token = create_session(user["uid"])
max_age = 86400 * 30 if remember_me else 86400 * 7
response = RedirectResponse(url="/feed", status_code=302)
2026-05-12 12:45:52 +02:00
response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax")
2026-05-10 09:08:12 +02:00
logger.info(f"User {user['username']} logged in")
return response
2026-05-11 00:41:41 +02:00
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(
request,
title="Reset Password",
robots="noindex,nofollow",
)
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "forgot_password.html", {**seo_ctx, "request": request})
2026-05-11 00:41:41 +02:00
@router.post("/forgot-password")
2026-05-23 05:44:04 +02:00
async def forgot_password(request: Request, data: Annotated[ForgotPasswordForm, Form()]):
email = data.email.strip().lower()
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(request, title="Reset Password", robots="noindex,nofollow")
2026-05-11 00:41:41 +02:00
users = get_table("users")
user = users.find_one(email=email)
if user:
token = secrets.token_hex(32)
2026-05-12 12:45:52 +02:00
token_hash = hashlib.sha256(token.encode()).hexdigest()
2026-05-11 00:41:41 +02:00
resets = get_table("password_resets")
resets.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
2026-05-12 12:45:52 +02:00
"token": token_hash,
2026-05-14 04:12:19 +02:00
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
2026-05-11 00:41:41 +02:00
"used": False,
2026-05-14 04:12:19 +02:00
"created_at": datetime.now(timezone.utc).isoformat(),
2026-05-11 00:41:41 +02:00
})
2026-05-12 12:45:52 +02:00
logger.info(f"Password reset requested for {email}")
2026-05-11 00:41:41 +02:00
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "forgot_password.html", {
2026-05-11 05:30:51 +02:00
**seo_ctx, "request": request, "sent": True,
2026-05-11 00:41:41 +02:00
})
@router.get("/reset-password/{token}", response_class=HTMLResponse)
async def reset_password_page(request: Request, token: str):
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(
request,
title="Set New Password",
robots="noindex,nofollow",
)
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "reset_password.html", {
2026-05-11 05:30:51 +02:00
**seo_ctx, "request": request, "token": token,
2026-05-11 00:41:41 +02:00
})
@router.post("/reset-password/{token}")
2026-05-23 05:44:04 +02:00
async def reset_password(request: Request, token: str, data: Annotated[ResetPasswordForm, Form()]):
password = data.password
2026-05-11 00:41:41 +02:00
errors = []
resets = get_table("password_resets")
users = get_table("users")
2026-05-12 12:45:52 +02:00
token_hash = hashlib.sha256(token.encode()).hexdigest()
matched = resets.find_one(token=token_hash, used=False)
2026-05-14 04:12:19 +02:00
if not matched:
errors.append("Invalid or expired reset token")
return templates.TemplateResponse(request, "reset_password.html", {
"request": request, "token": token, "errors": errors,
})
expires = datetime.fromisoformat(matched["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
2026-05-11 00:41:41 +02:00
errors.append("Invalid or expired reset token")
2026-05-13 23:03:06 +02:00
return templates.TemplateResponse(request, "reset_password.html", {
2026-05-11 00:41:41 +02:00
"request": request, "token": token, "errors": errors,
})
users.update({"uid": matched["user_uid"], "password_hash": hash_password(password)}, ["uid"])
resets.update({"id": matched["id"], "used": True}, ["id"])
logger.info(f"Password reset completed for user {matched['user_uid']}")
return RedirectResponse(url="/auth/login", status_code=302)
2026-05-10 09:08:12 +02:00
@router.get("/logout")
async def logout(request: Request):
token = request.cookies.get("session")
if token:
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
if session:
sessions.delete(id=session["id"])
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("session")
return response