235 lines
6.5 KiB
Python
Raw Normal View History

2026-05-23 10:03:27 +02:00
import asyncio
2026-05-23 01:50:31 +02:00
import html
2026-05-12 13:08:38 +02:00
import re
2026-05-10 09:08:12 +02:00
import secrets
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 passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
2026-05-23 06:20:27 +02:00
from devplacepy.cache import TTLCache
2026-05-10 09:08:12 +02:00
from devplacepy.database import get_table
2026-05-23 08:34:13 +02:00
from devplacepy.config import SESSION_MAX_AGE
2026-05-10 09:08:12 +02:00
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
return pbkdf2_sha256.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return pbkdf2_sha256.verify(password, hashed)
def create_session(user_uid: str) -> str:
token = secrets.token_hex(32)
2026-05-14 04:12:19 +02:00
expires_at = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
2026-05-10 09:08:12 +02:00
sessions = get_table("sessions")
sessions.insert({
"session_token": token,
"user_uid": user_uid,
2026-05-14 04:12:19 +02:00
"created_at": datetime.now(timezone.utc).isoformat(),
2026-05-10 09:08:12 +02:00
"expires_at": expires_at.isoformat(),
})
return token
2026-05-23 06:20:27 +02:00
_user_cache = TTLCache(ttl=300)
def clear_user_cache(user_uid: str) -> None:
for token, user in _user_cache.items():
if user.get("uid") == user_uid:
_user_cache.pop(token)
2026-05-11 03:14:43 +02:00
2026-05-10 09:08:12 +02:00
def get_current_user(request: Request):
token = request.cookies.get("session")
if not token:
return None
2026-05-11 03:14:43 +02:00
cached = _user_cache.get(token)
2026-05-23 06:20:27 +02:00
if cached is not None:
return cached
2026-05-11 03:14:43 +02:00
2026-05-10 09:08:12 +02:00
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
if not session:
return None
expires = datetime.fromisoformat(session["expires_at"])
2026-05-14 04:12:19 +02:00
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
2026-05-10 09:08:12 +02:00
sessions.delete(id=session["id"])
return None
users = get_table("users")
user = users.find_one(uid=session["user_uid"])
2026-05-11 05:30:51 +02:00
if user and not user.get("is_active", True):
sessions.delete(id=session["id"])
2026-05-23 06:20:27 +02:00
_user_cache.pop(token)
2026-05-11 05:30:51 +02:00
return None
2026-05-11 03:14:43 +02:00
if user:
2026-05-23 06:20:27 +02:00
_user_cache.set(token, user)
2026-05-10 09:08:12 +02:00
return user
def require_user(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/"})
return user
2026-05-11 05:30:51 +02:00
def require_admin(request: Request):
user = require_user(request)
if user.get("role") != "Admin":
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/feed"})
return user
2026-05-23 08:34:13 +02:00
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return user
2026-05-23 01:50:31 +02:00
def strip_html(text: str) -> str:
if not text:
return ""
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
return re.sub(r"\s+", " ", text).strip()
2026-05-10 09:08:12 +02:00
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9-]", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def generate_uid() -> str:
import uuid
return str(uuid.uuid4())
2026-05-11 20:49:45 +02:00
def make_combined_slug(text: str, uid: str) -> str:
short_uid = uid[:8]
slug_part = slugify(text)
if not slug_part:
return short_uid
return f"{short_uid}-{slug_part}"
2026-05-10 09:08:12 +02:00
def time_ago(dt_str: str) -> str:
dt = datetime.fromisoformat(dt_str)
2026-05-14 04:12:19 +02:00
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
2026-05-10 09:08:12 +02:00
diff = now - dt
days = diff.days
if days > 30:
2026-05-12 12:45:52 +02:00
return dt.strftime("%d/%m/%Y")
2026-05-10 09:08:12 +02:00
if days > 0:
return f"{days}d ago"
hours = diff.seconds // 3600
if hours > 0:
return f"{hours}h ago"
minutes = diff.seconds // 60
if minutes > 0:
return f"{minutes}m ago"
return "just now"
2026-05-12 12:45:52 +02:00
2026-05-12 13:08:38 +02:00
def extract_mentions(content: str) -> list[str]:
if not content:
return []
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
2026-05-23 10:03:27 +02:00
PUSH_ICON = "/static/apple-touch-icon.png"
DEFAULT_PUSH_URL = "/notifications"
_push_tasks: set[asyncio.Task] = set()
async def _safe_notify(user_uid: str, payload: dict[str, str]) -> None:
from devplacepy import push
try:
await push.notify_user(user_uid, payload)
except Exception as e:
logger.warning("Push delivery failed for %s: %s", user_uid, e)
def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
payload = {
"title": "DevPlace",
"message": message,
"icon": PUSH_ICON,
"url": target_url or DEFAULT_PUSH_URL,
}
task = loop.create_task(_safe_notify(user_uid, payload))
_push_tasks.add(task)
task.add_done_callback(_push_tasks.discard)
2026-05-23 08:34:13 +02:00
def create_notification(user_uid: str, notification_type: str, message: str, related_uid: str, target_url: str | None = None) -> None:
from devplacepy.templating import clear_unread_cache
get_table("notifications").insert({
"uid": generate_uid(),
"user_uid": user_uid,
"type": notification_type,
"message": message,
"related_uid": related_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(user_uid)
2026-05-23 10:03:27 +02:00
_schedule_push(user_uid, message, target_url)
2026-05-23 08:34:13 +02:00
def award_badge(user_uid: str, badge_name: str) -> bool:
badges = get_table("badges")
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
return False
badges.insert({
"uid": generate_uid(),
"user_uid": user_uid,
"badge_name": badge_name,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return True
2026-05-12 13:08:38 +02:00
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
if not usernames:
return
users = get_table("users")
seen = set()
for username in usernames:
if username in seen:
continue
seen.add(username)
mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid:
2026-05-23 08:34:13 +02:00
create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url)
2026-05-12 13:08:38 +02:00
2026-05-12 12:45:52 +02:00
def format_date(dt_str: str, include_time: bool = False) -> str:
if not dt_str:
return ""
try:
dt = datetime.fromisoformat(dt_str)
if include_time:
return dt.strftime("%d/%m/%Y %H:%M")
return dt.strftime("%d/%m/%Y")
except (ValueError, TypeError):
return dt_str