206 lines
5.9 KiB
Python
206 lines
5.9 KiB
Python
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
|