Sync dev changes into release-1.0
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||
@@ -8,7 +10,17 @@ from ..config import settings as env_settings
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
get_all_users,
|
||||
get_invite_profile,
|
||||
list_invite_profiles,
|
||||
create_invite_profile,
|
||||
create_invite,
|
||||
list_invites,
|
||||
disable_invite,
|
||||
delete_invite,
|
||||
delete_user,
|
||||
get_all_contacts,
|
||||
get_request_cache_overview,
|
||||
save_announcement,
|
||||
get_settings_overrides,
|
||||
get_user_by_username,
|
||||
set_setting,
|
||||
@@ -27,10 +39,12 @@ from ..clients.radarr import RadarrClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||
from ..services.jellyseerr_sync import sync_jellyseerr_users
|
||||
import logging
|
||||
from ..logging_config import configure_logging
|
||||
from ..routers import requests as requests_router
|
||||
from ..routers.branding import save_branding_image
|
||||
from ..services.notifications import send_notification
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,6 +56,17 @@ SENSITIVE_KEYS = {
|
||||
"radarr_api_key",
|
||||
"prowlarr_api_key",
|
||||
"qbittorrent_password",
|
||||
"smtp_password",
|
||||
"hcaptcha_secret_key",
|
||||
"recaptcha_secret_key",
|
||||
"turnstile_secret_key",
|
||||
"telegram_bot_token",
|
||||
"matrix_password",
|
||||
"matrix_access_token",
|
||||
"pushover_token",
|
||||
"pushover_user_key",
|
||||
"pushbullet_token",
|
||||
"gotify_token",
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
@@ -74,6 +99,59 @@ SETTING_KEYS: List[str] = [
|
||||
"requests_cleanup_time",
|
||||
"requests_cleanup_days",
|
||||
"requests_data_source",
|
||||
"invites_enabled",
|
||||
"invites_require_captcha",
|
||||
"invite_default_profile_id",
|
||||
"signup_allow_referrals",
|
||||
"referral_default_uses",
|
||||
"password_min_length",
|
||||
"password_require_upper",
|
||||
"password_require_lower",
|
||||
"password_require_number",
|
||||
"password_require_symbol",
|
||||
"password_reset_enabled",
|
||||
"captcha_provider",
|
||||
"hcaptcha_site_key",
|
||||
"hcaptcha_secret_key",
|
||||
"recaptcha_site_key",
|
||||
"recaptcha_secret_key",
|
||||
"turnstile_site_key",
|
||||
"turnstile_secret_key",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_user",
|
||||
"smtp_password",
|
||||
"smtp_from",
|
||||
"smtp_tls",
|
||||
"smtp_starttls",
|
||||
"notify_email_enabled",
|
||||
"notify_discord_enabled",
|
||||
"notify_telegram_enabled",
|
||||
"notify_matrix_enabled",
|
||||
"notify_pushover_enabled",
|
||||
"notify_pushbullet_enabled",
|
||||
"notify_gotify_enabled",
|
||||
"notify_ntfy_enabled",
|
||||
"telegram_bot_token",
|
||||
"telegram_chat_id",
|
||||
"matrix_homeserver",
|
||||
"matrix_user",
|
||||
"matrix_password",
|
||||
"matrix_access_token",
|
||||
"matrix_room_id",
|
||||
"pushover_token",
|
||||
"pushover_user_key",
|
||||
"pushbullet_token",
|
||||
"gotify_url",
|
||||
"gotify_token",
|
||||
"ntfy_url",
|
||||
"ntfy_topic",
|
||||
"expiry_default_days",
|
||||
"expiry_default_action",
|
||||
"expiry_warning_days",
|
||||
"expiry_check_interval_minutes",
|
||||
"jellyseerr_sync_users",
|
||||
"jellyseerr_sync_interval_minutes",
|
||||
]
|
||||
|
||||
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
|
||||
@@ -208,6 +286,12 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
|
||||
return {"status": "ok", "imported": imported}
|
||||
|
||||
|
||||
@router.post("/jellyseerr/users/sync")
|
||||
async def jellyseerr_users_sync() -> Dict[str, Any]:
|
||||
imported = await sync_jellyseerr_users()
|
||||
return {"status": "ok", "imported": imported}
|
||||
|
||||
|
||||
@router.post("/requests/sync")
|
||||
async def requests_sync() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
@@ -365,3 +449,157 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
||||
)
|
||||
set_user_password(username, new_password.strip())
|
||||
return {"status": "ok", "username": username}
|
||||
|
||||
|
||||
@router.post("/users/bulk")
|
||||
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = str(payload.get("action") or "").strip().lower()
|
||||
usernames = payload.get("usernames")
|
||||
if not isinstance(usernames, list) or not usernames:
|
||||
raise HTTPException(status_code=400, detail="User list required")
|
||||
if action not in {"block", "unblock", "delete", "role"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
updated = 0
|
||||
for username in usernames:
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
continue
|
||||
name = username.strip()
|
||||
if action == "block":
|
||||
set_user_blocked(name, True)
|
||||
elif action == "unblock":
|
||||
set_user_blocked(name, False)
|
||||
elif action == "delete":
|
||||
delete_user(name)
|
||||
elif action == "role":
|
||||
role = str(payload.get("role") or "").strip().lower()
|
||||
if role not in {"admin", "user"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
set_user_role(name, role)
|
||||
updated += 1
|
||||
return {"status": "ok", "updated": updated}
|
||||
|
||||
|
||||
@router.get("/invite-profiles")
|
||||
async def invite_profiles() -> Dict[str, Any]:
|
||||
return {"profiles": list_invite_profiles()}
|
||||
|
||||
|
||||
@router.post("/invite-profiles")
|
||||
async def create_profile(
|
||||
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
||||
) -> Dict[str, Any]:
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Profile name required")
|
||||
profile_id = create_invite_profile(
|
||||
name=name,
|
||||
description=str(payload.get("description") or "").strip() or None,
|
||||
max_uses=payload.get("max_uses"),
|
||||
expires_in_days=payload.get("expires_in_days"),
|
||||
require_captcha=bool(payload.get("require_captcha")),
|
||||
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
|
||||
allow_referrals=bool(payload.get("allow_referrals")),
|
||||
referral_uses=payload.get("referral_uses"),
|
||||
user_expiry_days=payload.get("user_expiry_days"),
|
||||
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
|
||||
)
|
||||
return {"status": "ok", "id": profile_id}
|
||||
|
||||
|
||||
@router.get("/invites")
|
||||
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
|
||||
return {"invites": list_invites(limit)}
|
||||
|
||||
|
||||
@router.post("/invites")
|
||||
async def create_invite_endpoint(
|
||||
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
||||
) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
profile_id = payload.get("profile_id")
|
||||
profile = None
|
||||
if profile_id is not None:
|
||||
try:
|
||||
profile = get_invite_profile(int(profile_id))
|
||||
except (TypeError, ValueError):
|
||||
profile = None
|
||||
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
|
||||
expires_at = None
|
||||
if expires_in_days:
|
||||
try:
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
|
||||
).isoformat()
|
||||
except (TypeError, ValueError):
|
||||
expires_at = None
|
||||
require_captcha = bool(payload.get("require_captcha"))
|
||||
if not require_captcha and profile:
|
||||
require_captcha = bool(profile.get("require_captcha"))
|
||||
if not require_captcha:
|
||||
require_captcha = runtime.invites_require_captcha
|
||||
password_rules = payload.get("password_rules")
|
||||
if not isinstance(password_rules, dict):
|
||||
password_rules = profile.get("password_rules") if profile else None
|
||||
allow_referrals = bool(payload.get("allow_referrals"))
|
||||
if not allow_referrals and profile:
|
||||
allow_referrals = bool(profile.get("allow_referrals"))
|
||||
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
|
||||
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
|
||||
code = secrets.token_urlsafe(8)
|
||||
create_invite(
|
||||
code=code,
|
||||
created_by=user.get("username"),
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
|
||||
require_captcha=require_captcha,
|
||||
password_rules=password_rules if isinstance(password_rules, dict) else None,
|
||||
allow_referrals=allow_referrals,
|
||||
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
|
||||
user_expiry_days=user_expiry_days,
|
||||
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
|
||||
is_referral=bool(payload.get("is_referral")),
|
||||
)
|
||||
return {"status": "ok", "code": code}
|
||||
|
||||
|
||||
@router.post("/invites/{code}/disable")
|
||||
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
|
||||
disable_invite(code)
|
||||
return {"status": "ok", "code": code, "disabled": True}
|
||||
|
||||
|
||||
@router.delete("/invites/{code}")
|
||||
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
|
||||
delete_invite(code)
|
||||
return {"status": "ok", "code": code, "deleted": True}
|
||||
|
||||
|
||||
@router.post("/announcements")
|
||||
async def send_announcement(
|
||||
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
|
||||
) -> Dict[str, Any]:
|
||||
subject = str(payload.get("subject") or "").strip()
|
||||
body = str(payload.get("body") or "").strip()
|
||||
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
|
||||
if not subject or not body:
|
||||
raise HTTPException(status_code=400, detail="Subject and message required")
|
||||
results: Dict[str, Any] = {}
|
||||
email_count = 0
|
||||
email_failed = 0
|
||||
if "email" in [str(c).lower() for c in channels]:
|
||||
for contact in get_all_contacts():
|
||||
email = contact.get("email")
|
||||
if not email:
|
||||
continue
|
||||
outcome = await send_notification(subject, body, channels=["email"], email=email)
|
||||
if outcome.get("email") == "sent":
|
||||
email_count += 1
|
||||
else:
|
||||
email_failed += 1
|
||||
results["email"] = {"sent": email_count, "failed": email_failed}
|
||||
other_channels = [c for c in channels if str(c).lower() != "email"]
|
||||
if other_channels:
|
||||
results.update(await send_notification(subject, body, channels=other_channels))
|
||||
save_announcement(user.get("username"), subject, body, ",".join(channels))
|
||||
return {"status": "ok", "results": results}
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
import secrets
|
||||
|
||||
from ..db import (
|
||||
verify_user_password,
|
||||
create_user_if_missing,
|
||||
create_user,
|
||||
set_last_login,
|
||||
get_user_by_username,
|
||||
get_user_by_email,
|
||||
set_user_password,
|
||||
get_invite_by_code,
|
||||
get_invite_profile,
|
||||
increment_invite_use,
|
||||
list_invites_by_creator,
|
||||
create_invite,
|
||||
upsert_user_contact,
|
||||
get_user_contact,
|
||||
set_user_expiry,
|
||||
create_password_reset,
|
||||
get_password_reset,
|
||||
mark_password_reset_used,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..security import create_access_token
|
||||
from ..auth import get_current_user
|
||||
from ..services.captcha import verify_captcha
|
||||
from ..services.notifications import send_notification
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
def _validate_password(password: str, rules: dict | None = None) -> Optional[str]:
|
||||
runtime = get_runtime_settings()
|
||||
rules = rules or {}
|
||||
min_length = int(rules.get("min_length") or runtime.password_min_length or 8)
|
||||
require_upper = bool(rules.get("require_upper", runtime.password_require_upper))
|
||||
require_lower = bool(rules.get("require_lower", runtime.password_require_lower))
|
||||
require_number = bool(rules.get("require_number", runtime.password_require_number))
|
||||
require_symbol = bool(rules.get("require_symbol", runtime.password_require_symbol))
|
||||
|
||||
if len(password) < min_length:
|
||||
return f"Password must be at least {min_length} characters."
|
||||
if require_upper and password.lower() == password:
|
||||
return "Password must include an uppercase letter."
|
||||
if require_lower and password.upper() == password:
|
||||
return "Password must include a lowercase letter."
|
||||
if require_number and not any(char.isdigit() for char in password):
|
||||
return "Password must include a number."
|
||||
if require_symbol and password.isalnum():
|
||||
return "Password must include a symbol."
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
@@ -103,12 +142,216 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
if len(new_password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||
)
|
||||
error = _validate_password(new_password.strip())
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
user = verify_user_password(current_user["username"], current_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||
set_user_password(current_user["username"], new_password.strip())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/contact")
|
||||
async def get_contact(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
contact = get_user_contact(current_user["username"])
|
||||
return {"contact": contact or {}}
|
||||
|
||||
|
||||
@router.post("/contact")
|
||||
async def update_contact(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
upsert_user_contact(
|
||||
current_user["username"],
|
||||
email=str(payload.get("email") or "").strip() or None,
|
||||
discord=str(payload.get("discord") or "").strip() or None,
|
||||
telegram=str(payload.get("telegram") or "").strip() or None,
|
||||
matrix=str(payload.get("matrix") or "").strip() or None,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(payload: dict, request: Request) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.invites_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invites are disabled")
|
||||
invite_code = str(payload.get("invite_code") or "").strip()
|
||||
username = str(payload.get("username") or "").strip()
|
||||
password = str(payload.get("password") or "").strip()
|
||||
contact = payload.get("contact") if isinstance(payload, dict) else None
|
||||
captcha_token = str(payload.get("captcha_token") or "").strip()
|
||||
if not invite_code or not username or not password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite, username, and password required")
|
||||
if get_user_by_username(username):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
|
||||
invite = get_invite_by_code(invite_code)
|
||||
if not invite or invite.get("disabled"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite not found or disabled")
|
||||
profile = None
|
||||
if invite.get("profile_id"):
|
||||
profile = get_invite_profile(int(invite["profile_id"]))
|
||||
max_uses = invite.get("max_uses")
|
||||
if max_uses is not None and invite.get("uses_count", 0) >= max_uses:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has been fully used")
|
||||
expires_at = invite.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
if datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has expired")
|
||||
except ValueError:
|
||||
pass
|
||||
require_captcha = (
|
||||
bool(invite.get("require_captcha"))
|
||||
or (bool(profile.get("require_captcha")) if profile else False)
|
||||
or runtime.invites_require_captcha
|
||||
)
|
||||
if require_captcha:
|
||||
ok = await verify_captcha(captcha_token, request.client.host if request.client else None)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Captcha failed")
|
||||
rules = invite.get("password_rules") or (profile.get("password_rules") if profile else None)
|
||||
error = _validate_password(password, rules)
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
try:
|
||||
create_user(username, password, role="user", auth_provider="local")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
if isinstance(contact, dict):
|
||||
upsert_user_contact(
|
||||
username,
|
||||
email=str(contact.get("email") or "").strip() or None,
|
||||
discord=str(contact.get("discord") or "").strip() or None,
|
||||
telegram=str(contact.get("telegram") or "").strip() or None,
|
||||
matrix=str(contact.get("matrix") or "").strip() or None,
|
||||
)
|
||||
expiry_days = (
|
||||
invite.get("user_expiry_days")
|
||||
or (profile.get("user_expiry_days") if profile else None)
|
||||
or runtime.expiry_default_days
|
||||
)
|
||||
expiry_action = (
|
||||
invite.get("user_expiry_action")
|
||||
or (profile.get("user_expiry_action") if profile else None)
|
||||
or runtime.expiry_default_action
|
||||
)
|
||||
if expiry_days and expiry_action:
|
||||
try:
|
||||
expiry_days_float = float(expiry_days)
|
||||
except (TypeError, ValueError):
|
||||
expiry_days_float = 0
|
||||
if expiry_days_float > 0:
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days=expiry_days_float)
|
||||
).isoformat()
|
||||
set_user_expiry(username, expires_at, str(expiry_action))
|
||||
increment_invite_use(invite_code)
|
||||
token = create_access_token(username, "user")
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
||||
|
||||
|
||||
@router.post("/password/reset")
|
||||
async def request_password_reset(payload: dict) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.password_reset_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset disabled")
|
||||
identifier = str(payload.get("identifier") or "").strip()
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email required")
|
||||
user = get_user_by_username(identifier)
|
||||
if not user:
|
||||
user = get_user_by_email(identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset for local users only")
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
|
||||
create_password_reset(token, user["username"], expires_at)
|
||||
contact = get_user_contact(user["username"])
|
||||
email = contact.get("email") if isinstance(contact, dict) else None
|
||||
if not runtime.notify_email_enabled or not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email notifications are not configured for password resets.",
|
||||
)
|
||||
await send_notification(
|
||||
"Password reset request",
|
||||
f"Your reset token is: {token}",
|
||||
channels=["email"],
|
||||
email=email,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/password/reset/confirm")
|
||||
async def confirm_password_reset(payload: dict) -> dict:
|
||||
token = str(payload.get("token") or "").strip()
|
||||
new_password = str(payload.get("new_password") or "").strip()
|
||||
if not token or not new_password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token and new password required")
|
||||
reset = get_password_reset(token)
|
||||
if not reset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reset token not found")
|
||||
if reset.get("used_at"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token already used")
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(reset["expires_at"])
|
||||
if expires_at <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
|
||||
except ValueError:
|
||||
pass
|
||||
error = _validate_password(new_password)
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
set_user_password(reset["username"], new_password)
|
||||
mark_password_reset_used(token)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/signup/config")
|
||||
async def signup_config() -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
return {
|
||||
"invites_enabled": runtime.invites_enabled,
|
||||
"captcha_provider": runtime.captcha_provider,
|
||||
"hcaptcha_site_key": runtime.hcaptcha_site_key,
|
||||
"recaptcha_site_key": runtime.recaptcha_site_key,
|
||||
"turnstile_site_key": runtime.turnstile_site_key,
|
||||
"password_min_length": runtime.password_min_length,
|
||||
"password_require_upper": runtime.password_require_upper,
|
||||
"password_require_lower": runtime.password_require_lower,
|
||||
"password_require_number": runtime.password_require_number,
|
||||
"password_require_symbol": runtime.password_require_symbol,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/referrals")
|
||||
async def list_referrals(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
invites = list_invites_by_creator(current_user["username"], is_referral=True)
|
||||
return {"invites": invites}
|
||||
|
||||
|
||||
@router.post("/referrals")
|
||||
async def create_referral(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.signup_allow_referrals:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Referrals are disabled")
|
||||
code = secrets.token_urlsafe(8)
|
||||
create_invite(
|
||||
code=code,
|
||||
created_by=current_user["username"],
|
||||
profile_id=runtime.invite_default_profile_id,
|
||||
expires_at=None,
|
||||
max_uses=int(runtime.referral_default_uses or 1),
|
||||
require_captcha=runtime.invites_require_captcha,
|
||||
password_rules=None,
|
||||
allow_referrals=False,
|
||||
referral_uses=None,
|
||||
user_expiry_days=None,
|
||||
user_expiry_action=None,
|
||||
is_referral=True,
|
||||
)
|
||||
return {"status": "ok", "code": code}
|
||||
|
||||
Reference in New Issue
Block a user