Build reusable bot framework
This commit is contained in:
205
core/users.py
Normal file
205
core/users.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import os
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import bcrypt
|
||||
from psycopg2.errors import UniqueViolation
|
||||
|
||||
import core.postgres as postgres
|
||||
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_BYTES = 72
|
||||
REGISTRATION_FIELDS = {"timezone"}
|
||||
USER_UPDATE_FIELDS = {"timezone"}
|
||||
|
||||
|
||||
def getUser(userUUID):
|
||||
return postgres.select_one("users", {"id": userUUID})
|
||||
|
||||
|
||||
def getUserUUID(username):
|
||||
if not username:
|
||||
return False
|
||||
userRecord = postgres.select_one("users", {"username": username})
|
||||
if userRecord:
|
||||
return userRecord["id"]
|
||||
return False
|
||||
|
||||
|
||||
def getUserFirstName(userUUID):
|
||||
userRecord = getUser(userUUID)
|
||||
if userRecord:
|
||||
return userRecord.get("username")
|
||||
return None
|
||||
|
||||
|
||||
def getUserTimezone(userUUID):
|
||||
userRecord = getUser(userUUID)
|
||||
if userRecord:
|
||||
return userRecord.get("timezone") or "UTC"
|
||||
return None
|
||||
|
||||
|
||||
def isUsernameAvailable(username):
|
||||
if not username:
|
||||
return False
|
||||
return not postgres.exists("users", {"username": username})
|
||||
|
||||
|
||||
def doesUserUUIDExist(userUUID):
|
||||
if not userUUID:
|
||||
return False
|
||||
return postgres.exists("users", {"id": userUUID})
|
||||
|
||||
|
||||
def isValidTimezone(timezoneName):
|
||||
if not isinstance(timezoneName, str) or not timezoneName.strip():
|
||||
return False
|
||||
try:
|
||||
ZoneInfo(timezoneName.strip())
|
||||
return True
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def normalizeTimezone(timezoneName):
|
||||
timezoneName = timezoneName.strip() if isinstance(timezoneName, str) else ""
|
||||
if not isValidTimezone(timezoneName):
|
||||
raise ValueError("timezone must be a valid IANA timezone name")
|
||||
return timezoneName
|
||||
|
||||
|
||||
def getDefaultTimezone():
|
||||
return normalizeTimezone(os.getenv("DEFAULT_TIMEZONE", "UTC"))
|
||||
|
||||
|
||||
def validatePassword(password):
|
||||
errors = []
|
||||
if not isinstance(password, str) or not password:
|
||||
return False, ["password"]
|
||||
|
||||
encodedPassword = password.encode("utf-8")
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
errors.append(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
if len(encodedPassword) > MAX_PASSWORD_BYTES:
|
||||
errors.append(f"password must be at most {MAX_PASSWORD_BYTES} UTF-8 bytes")
|
||||
return not errors, errors
|
||||
|
||||
|
||||
def registerUser(username, password, data=None):
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
raise ValueError("username is required")
|
||||
username = username.strip()
|
||||
if len(username) > 255:
|
||||
raise ValueError("username must be at most 255 characters")
|
||||
|
||||
isValid, errors = validatePassword(password)
|
||||
if not isValid:
|
||||
raise ValueError(f"Invalid password: {', '.join(errors)}")
|
||||
if not isUsernameAvailable(username):
|
||||
return False
|
||||
|
||||
userData = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"password_hashed": bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()),
|
||||
}
|
||||
if data is not None:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("registration data must be an object")
|
||||
for key in REGISTRATION_FIELDS:
|
||||
if key in data:
|
||||
userData[key] = data[key]
|
||||
|
||||
try:
|
||||
createUser(userData)
|
||||
except UniqueViolation:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def updateUser(userUUID, data_dict):
|
||||
if not getUser(userUUID) or not isinstance(data_dict, dict):
|
||||
return False
|
||||
|
||||
updates = {key: data_dict[key] for key in USER_UPDATE_FIELDS if key in data_dict}
|
||||
if "timezone" in updates:
|
||||
updates["timezone"] = normalizeTimezone(updates["timezone"])
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
postgres.update("users", updates, {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def changePassword(userUUID, new_password):
|
||||
if not getUser(userUUID):
|
||||
return False
|
||||
isValid, errors = validatePassword(new_password)
|
||||
if not isValid:
|
||||
raise ValueError(f"Invalid password: {', '.join(errors)}")
|
||||
|
||||
hashedPassword = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
|
||||
postgres.update("users", {"password_hashed": hashedPassword}, {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def deleteUser(userUUID):
|
||||
if not getUser(userUUID):
|
||||
return False
|
||||
postgres.delete("users", {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def createUser(data_dict):
|
||||
if not isinstance(data_dict, dict):
|
||||
raise ValueError("user data must be an object")
|
||||
|
||||
allowedFields = {"id", "username", "password_hashed", "timezone", "created_at"}
|
||||
userData = {key: value for key, value in data_dict.items() if key in allowedFields}
|
||||
if "timezone" not in userData:
|
||||
userData["timezone"] = getDefaultTimezone()
|
||||
|
||||
isValid, errors = validateUser(userData)
|
||||
if not isValid:
|
||||
raise ValueError(f"Invalid user data: {', '.join(errors)}")
|
||||
|
||||
return postgres.insert("users", userData)
|
||||
|
||||
|
||||
def validateUser(user):
|
||||
errors = []
|
||||
if not isinstance(user, dict):
|
||||
return False, ["user"]
|
||||
|
||||
userUUID = user.get("id")
|
||||
if not userUUID:
|
||||
errors.append("id")
|
||||
else:
|
||||
try:
|
||||
uuid.UUID(str(userUUID))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
errors.append("id must be a valid UUID")
|
||||
|
||||
username = user.get("username")
|
||||
passwordHash = user.get("password_hashed")
|
||||
if username is None and passwordHash is not None:
|
||||
errors.append("username")
|
||||
elif username is not None and passwordHash is None:
|
||||
errors.append("password_hashed")
|
||||
elif username is not None:
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
errors.append("username")
|
||||
elif len(username) > 255:
|
||||
errors.append("username must be at most 255 characters")
|
||||
if isinstance(passwordHash, memoryview):
|
||||
passwordHash = bytes(passwordHash)
|
||||
if not isinstance(passwordHash, bytes) or not passwordHash:
|
||||
errors.append("password_hashed")
|
||||
|
||||
timezoneName = user.get("timezone", "UTC")
|
||||
if not isValidTimezone(timezoneName):
|
||||
errors.append("timezone must be a valid IANA timezone name")
|
||||
|
||||
return not errors, errors
|
||||
Reference in New Issue
Block a user