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-14 04:12:19 +02:00
|
|
|
from datetime import datetime, timedelta, timezone
|
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",
|
|
|
|
|
)
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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",
|
|
|
|
|
)
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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")
|
|
|
|
|
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(
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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")
|
|
|
|
|
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(
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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",
|
|
|
|
|
)
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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")
|
|
|
|
|
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:
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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, "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-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
|
|
|
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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",
|
|
|
|
|
)
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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}")
|
|
|
|
|
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")
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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, "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)
|
|
|
|
|
|
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")
|
feat: pass request as first positional arg to all TemplateResponse calls across routers
The diff shows a systematic refactor across 14 router files and main.py where every invocation of `templates.TemplateResponse` now receives the `request` object as its first positional argument, shifting the template name from first to second position. This change touches 30+ call sites in auth, feed, gists, messages, news, notifications, posts, profile, projects, services, bugs, admin, and the main error handlers, ensuring consistent Jinja2 template rendering with explicit request context injection for all HTTP responses.
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
|