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