246 lines
8.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-11 00:41:41 +02:00
from datetime import datetime, timedelta
2026-05-10 09:08:12 +02:00
from fastapi import APIRouter, Request
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-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",
)
return templates.TemplateResponse("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",
)
return templates.TemplateResponse("login.html", {**seo_ctx, "request": request})
2026-05-10 09:08:12 +02:00
@router.post("/signup")
async def signup(request: Request):
form = await request.form()
username = form.get("username", "").strip()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
confirm_password = form.get("confirm_password", "")
errors = []
if len(username) < 3 or len(username) > 32:
errors.append("Username must be between 3 and 32 characters")
if not username.isascii() or not all(c.isalnum() or c in ("-", "_") for c in username):
errors.append("Username can only contain letters, numbers, hyphens, and underscores")
if not email or "@" not in email or len(email) > 255:
errors.append("Valid email is required")
if len(password) < 6:
errors.append("Password must be at least 6 characters")
if password != confirm_password:
errors.append("Passwords do not match")
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(
"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,
"created_at": datetime.utcnow().isoformat(),
})
2026-05-11 00:41:41 +02:00
badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.utcnow().isoformat(),
})
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")
async def login(request: Request):
form = await request.form()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
remember_me = form.get("remember_me") == "on"
errors = []
if not email or not password:
errors.append("Email and password are required")
users = get_table("users")
user = users.find_one(email=email) if not errors else None
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(
"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",
)
return templates.TemplateResponse("forgot_password.html", {**seo_ctx, "request": request})
2026-05-11 00:41:41 +02:00
@router.post("/forgot-password")
async def forgot_password(request: Request):
form = await request.form()
email = form.get("email", "").strip().lower()
errors = []
if not email or "@" not in email:
errors.append("Valid email is required")
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
if errors:
return templates.TemplateResponse("forgot_password.html", {
2026-05-11 05:30:51 +02:00
**seo_ctx, "request": request, "errors": errors,
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-11 00:41:41 +02:00
"expires_at": (datetime.utcnow() + timedelta(hours=1)).isoformat(),
"used": False,
"created_at": datetime.utcnow().isoformat(),
})
2026-05-12 12:45:52 +02:00
logger.info(f"Password reset requested for {email}")
2026-05-11 00:41:41 +02:00
return templates.TemplateResponse("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-11 00:41:41 +02:00
return templates.TemplateResponse("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}")
async def reset_password(request: Request, token: str):
form = await request.form()
password = form.get("password", "")
confirm = form.get("confirm_password", "")
errors = []
if len(password) < 6:
errors.append("Password must be at least 6 characters")
if password != confirm:
errors.append("Passwords do not match")
if errors:
2026-05-11 05:30:51 +02:00
seo_ctx = base_seo_context(request, title="Set New Password", robots="noindex,nofollow")
2026-05-11 00:41:41 +02:00
return templates.TemplateResponse("reset_password.html", {
2026-05-11 05:30:51 +02:00
**seo_ctx, "request": request, "token": token, "errors": errors,
2026-05-11 00:41:41 +02:00
})
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)
if not matched or datetime.fromisoformat(matched["expires_at"]) < datetime.utcnow():
2026-05-11 00:41:41 +02:00
errors.append("Invalid or expired reset token")
return templates.TemplateResponse("reset_password.html", {
"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