This commit is contained in:
copyrighttxt
2026-07-06 13:28:00 -04:00
commit 05211f73ef
441 changed files with 94128 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
from app.extensions import redis_controller, db
from app.util import redislock
from app.models.usereconomy import UserEconomy
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.groups import Group, GroupEconomy
from app.models.asset import Asset
from app.models.asset_rap import AssetRap
from app.services.groups import GetGroupFromId, GetUserFromId
import redis_lock
import math
class InvalidCurrencyTypeException(Exception):
pass
class EconomyLockAcquireException(Exception):
pass
class InsufficientFundsException(Exception):
pass
class AssetNotLimitedException(Exception):
pass
class AssetDoesNotExistException(Exception):
pass
def TaxCurrencyAmount( Amount : int ) -> int:
return math.floor( Amount * 0.7 )
def GetAssetFromId( assetid : int | Asset ) -> Asset | None:
"""
Returns the Asset object for the given assetid
"""
if isinstance(assetid, Asset):
return assetid
AssetObj : Asset = Asset.query.filter_by(id=assetid).first()
if AssetObj is None:
raise AssetDoesNotExistException("Asset does not exist")
return AssetObj
def GetUserEconomyObj( TargetUser : User ) -> UserEconomy | None:
"""
Returns the UserEconomy object for the given user
"""
return UserEconomy.query.filter_by( userid=TargetUser.id ).first()
def GetGroupEconomyObj( TargetGroup : Group ) -> GroupEconomy | None:
"""
Returns the GroupEconomy object for the given group
"""
return GroupEconomy.query.filter_by( group_id=TargetGroup.id ).first()
def GetUserBalance( TargetUser : User ) -> tuple[int, int]:
"""
Returns the User's Robux and Tickets balance
"""
EconomyObj : UserEconomy = GetUserEconomyObj( TargetUser )
return EconomyObj.robux, EconomyObj.tix
def GetGroupBalance( TargetGroup : Group ) -> tuple[int, int]:
"""
Returns the Group's Robux and Tickets balance
"""
EconomyObj : GroupEconomy = GetGroupEconomyObj( TargetGroup )
return EconomyObj.robux_balance, EconomyObj.tix_balance
def UnsafeIncrementTargetBalance( Target : User | Group, Amount : int, CurrencyType : int ): # CurrencyType is 0 for Robux, 1 for Tickets
"""
Increments the Target Balance ( Not Recommended for normal use please instead use IncrementTargetBalance)
"""
if isinstance(Target, User):
TargetEconomyObj : UserEconomy = GetUserEconomyObj( Target )
if CurrencyType == 0:
TargetEconomyObj.robux += Amount
elif CurrencyType == 1:
TargetEconomyObj.tix += Amount
else:
raise InvalidCurrencyTypeException("Invalid Currency Type")
db.session.commit()
elif isinstance(Target, Group):
TargetEconomyObj : GroupEconomy = GetGroupEconomyObj( Target )
if CurrencyType == 0:
TargetEconomyObj.robux_balance += Amount
elif CurrencyType == 1:
TargetEconomyObj.tix_balance += Amount
else:
raise InvalidCurrencyTypeException("Invalid Currency Type")
db.session.commit()
else:
raise TypeError("Invalid Target Type")
def UnsafeDecrementTargetBalance( Target : User | Group, Amount : int, CurrencyType : int ): # CurrencyType is 0 for Robux, 1 for Tickets
"""
Decrements the Target Balance ( Not Recommended for normal use please instead use DecrementTargetBalance)
"""
if isinstance(Target, User):
TargetEconomyObj : UserEconomy = GetUserEconomyObj( Target )
if CurrencyType == 0:
TargetEconomyObj.robux -= Amount
elif CurrencyType == 1:
TargetEconomyObj.tix -= Amount
else:
raise InvalidCurrencyTypeException("Invalid Currency Type")
db.session.commit()
elif isinstance(Target, Group):
TargetEconomyObj : GroupEconomy = GetGroupEconomyObj( Target )
if CurrencyType == 0:
TargetEconomyObj.robux_balance -= Amount
elif CurrencyType == 1:
TargetEconomyObj.tix_balance -= Amount
else:
raise InvalidCurrencyTypeException("Invalid Currency Type")
db.session.commit()
else:
raise TypeError("Invalid Target Type")
def IncrementTargetBalance( Target : User | Group, Amount : int, CurrencyType : int ): # CurrencyType is 0 for Robux, 1 for Tickets
"""
Increments the Target Balance
"""
if Amount < 0:
raise ValueError("Amount must be positive")
if isinstance(Target, User):
with redis_lock.Lock( redis_client = redis_controller, name = f"economy:{Target.id}", expire = 1, auto_renewal = True ):
UnsafeIncrementTargetBalance( Target, Amount, CurrencyType )
return
elif isinstance(Target, Group):
with redis_lock.Lock( redis_client = redis_controller, name = f"economy_group:{Target.id}", expire = 1, auto_renewal = True ):
UnsafeIncrementTargetBalance( Target, Amount, CurrencyType )
return
else:
raise TypeError("Invalid Target Type")
def DecrementTargetBalance( Target : User | Group, Amount : int, CurrencyType : int ): # CurrencyType is 0 for Robux, 1 for Tickets
"""
Decrements the Target Balance
"""
if Amount < 0:
raise ValueError("Amount must be positive")
if isinstance(Target, User):
with redis_lock.Lock( redis_client = redis_controller, name = f"economy:{Target.id}", expire = 1, auto_renewal = True ):
TargetEconomyObj : UserEconomy = GetUserEconomyObj( Target )
if CurrencyType == 0:
if TargetEconomyObj.robux < Amount:
raise InsufficientFundsException("Insufficient Funds")
elif CurrencyType == 1:
if TargetEconomyObj.tix < Amount:
raise InsufficientFundsException("Insufficient Funds")
UnsafeDecrementTargetBalance( Target, Amount, CurrencyType )
return
elif isinstance(Target, Group):
with redis_lock.Lock( redis_client = redis_controller, name = f"economy_group:{Target.id}", expire = 1, auto_renewal = True ):
TargetEconomyObj : GroupEconomy = GroupEconomy.query.filter_by( group_id=Target.id ).first()
if CurrencyType == 0:
if TargetEconomyObj.robux_balance < Amount:
raise InsufficientFundsException("Insufficient Funds")
elif CurrencyType == 1:
if TargetEconomyObj.tix_balance < Amount:
raise InsufficientFundsException("Insufficient Funds")
UnsafeDecrementTargetBalance( Target, Amount, CurrencyType )
return
else:
raise TypeError("Invalid Target Type")
def TransferFunds( Source : User | Group, Target : User | Group, Amount : int, CurrencyType : int, ApplyTax : bool = False): # CurrencyType is 0 for Robux, 1 for Tickets
"""
Transfers funds from the source to the target
"""
if Amount < 0:
raise ValueError("Amount must be positive")
if Source == Target:
raise ValueError("Source and Target must be different")
if CurrencyType not in [0,1]:
raise InvalidCurrencyTypeException("Invalid Currency Type")
if isinstance(Source, User):
SourceEconomyObj : UserEconomy = GetUserEconomyObj( Source )
if CurrencyType == 0:
if SourceEconomyObj.robux < Amount:
raise InsufficientFundsException("Insufficient Funds")
elif CurrencyType == 1:
if SourceEconomyObj.tix < Amount:
raise InsufficientFundsException("Insufficient Funds")
elif isinstance(Source, Group):
SourceEconomyObj : GroupEconomy = GroupEconomy.query.filter_by( group_id=Source.id ).first()
if CurrencyType == 0:
if SourceEconomyObj.robux_balance < Amount:
raise InsufficientFundsException("Insufficient Funds")
elif CurrencyType == 1:
if SourceEconomyObj.tix_balance < Amount:
raise InsufficientFundsException("Insufficient Funds")
else:
raise TypeError("Invalid Source Type")
TakenAmount : int = Amount
GivenAmount : int = Amount
if ApplyTax:
GivenAmount = TaxCurrencyAmount( Amount )
try:
DecrementTargetBalance( Source, TakenAmount, CurrencyType )
except InsufficientFundsException:
raise InsufficientFundsException("Insufficient Funds")
IncrementTargetBalance( Target, GivenAmount, CurrencyType )
return
def AdjustAssetRap(AssetObj : Asset | int, robux : int):
""" Adjusts the RAP of an asset
https://roblox.fandom.com/wiki/Recent_Average_Price
This will only work with assets that are limited
"""
AssetObj : Asset = GetAssetFromId(AssetObj)
AssetRapObject : AssetRap = AssetRap.query.filter_by(assetid=AssetObj.id).first()
if AssetRapObject is None:
if not AssetObj.is_limited:
raise AssetNotLimitedException("Asset is not limited")
AssetRapObject = AssetRap(assetid=AssetObj.id, rap=robux)
db.session.add(AssetRapObject)
db.session.commit()
return True
if AssetRapObject.rap <= 0:
AssetRapObject.rap = robux
CurrentRAP = AssetRapObject.rap
AssetRapObject.rap = math.floor(CurrentRAP - ( CurrentRAP - robux ) / 10)
db.session.commit()
return True
def GetAssetRap(AssetObj : Asset | int ) -> int:
"""
Returns the RAP of an asset
"""
AssetObj : Asset = GetAssetFromId(AssetObj)
if not AssetObj.is_limited:
raise AssetNotLimitedException("Asset is not limited")
AssetRapObject : AssetRap = AssetRap.query.filter_by(assetid=AssetObj.id).first()
if AssetRapObject is None:
AssetRapObject = AssetRap(assetid=AssetObj.id, rap=0)
db.session.add(AssetRapObject)
db.session.commit()
return AssetRapObject.rap
def GetCreatorOfAsset( AssetObj : Asset | int ) -> User | Group | None:
"""
Returns the creator of an asset
"""
AssetObj : Asset = GetAssetFromId(AssetObj)
if AssetObj.creator_type == 1:
return GetGroupFromId(AssetObj.creator_id)
elif AssetObj.creator_type == 0:
return GetUserFromId(AssetObj.creator_id)
else:
return None
def CalculateUserRAP( UserObj : User | int, skipCache : bool = False ) -> int:
"""
Calculates the RAP of a user
"""
if redis_controller.exists(f"rap_calculation:{UserObj.id}") and not skipCache:
return int(redis_controller.get(f"rap_calculation:{UserObj.id}"))
UserObj : User = GetUserFromId(UserObj)
UserRAP : int = 0
UserLimitedAssets : list[UserAsset] = UserAsset.query.filter_by(userid=UserObj.id).outerjoin( Asset, Asset.id == UserAsset.assetid ).filter( Asset.is_limited == True ).all()
for UserLimitedAsset in UserLimitedAssets:
UserRAP += GetAssetRap(UserLimitedAsset.assetid)
redis_controller.set(f"rap_calculation:{UserObj.id}", UserRAP, ex = 60)
return UserRAP
+117
View File
@@ -0,0 +1,117 @@
import requests
import time
import base64
import json
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from app.models.gameservers import GameServer
from config import Config
config = Config()
def sign_content( content : bytes ) -> str:
"""
Signs the given content using the gameserver private key
:param content: The content to sign
:returns: str
"""
assert isinstance( content, bytes ), "content must be a bytes object"
with open( config.GAMESERVER_COMM_PRIVATE_KEY_LOCATION, "rb" ) as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
signature = private_key.sign(
content,
padding.PKCS1v15(),
hashes.SHA256()
)
return base64.b64encode( signature ).decode( "utf-8" )
def perform_get(
TargetGameserver : GameServer,
Endpoint : str,
AdditionalHeaders : dict = {},
RequestTimeout : int = 10
) -> requests.Response:
"""
Performs a GET request to the given gameserver
:param TargetGameserver: The gameserver to send the request to
:param Endpoint: The endpoint to send the request to
:param AdditionalHeaders: Additional headers to send with the request
:param RequestTimeout: The amount of time before the request times out
:returns: requests.Response
"""
assert isinstance( TargetGameserver, GameServer ), "TargetGameserver must be an instance of GameServer"
assert isinstance( Endpoint, str ), "Endpoint must be a string"
assert isinstance( AdditionalHeaders, dict ), "AdditionalHeaders must be a dictionary"
RequestTimestamp = time.time()
signed_content = sign_content( f'{RequestTimestamp}\nGET'.encode( 'utf-8' ) )
ReqSignature = f"{str(RequestTimestamp)}|{ signed_content }"
headers = {
"User-Agent": "SYNTAX-Gameserver-Communication/1.0",
"X-Syntax-Request-Signature": ReqSignature
}
return requests.get(
url = f"http://{TargetGameserver.serverIP}:{TargetGameserver.serverPort}/{Endpoint}",
headers = headers,
timeout = RequestTimeout
)
def perform_post(
TargetGameserver : GameServer,
Endpoint : str,
JSONData : dict | list = {},
AdditionalHeaders : dict = {},
RequestTimeout : int = 10
) -> requests.Response:
"""
Performs a POST request to the given gameserver
:param TargetGameserver: The gameserver to send the request to
:param Endpoint: The endpoint to send the request to
:param JSONData: The JSON data to send with the request
:param AdditionalHeaders: Additional headers to send with the request
:param RequestTimeout: The amount of time before the request times out
:returns: requests.Response
"""
assert isinstance( TargetGameserver, GameServer ), "TargetGameserver must be an instance of GameServer"
assert isinstance( Endpoint, str ), "Endpoint must be a string"
assert isinstance( JSONData, ( dict, list ) ), "JSONData must be a dictionary or list"
assert isinstance( AdditionalHeaders, dict ), "AdditionalHeaders must be a dictionary"
RequestTimestamp = time.time()
signed_content = sign_content( f"{RequestTimestamp}\nPOST\n{json.dumps(JSONData)}".encode( "utf-8" ))
ReqSignature = f"{str(RequestTimestamp)}|{signed_content}"
headers = {
"User-Agent": "SYNTAX-Gameserver-Communication/1.0",
"X-Syntax-Request-Signature": ReqSignature,
"Content-Type": "application/json"
}
headers.update( AdditionalHeaders )
return requests.post(
url = f"http://{TargetGameserver.serverIP}:{TargetGameserver.serverPort}/{Endpoint}",
headers = headers,
data = json.dumps( JSONData ),
timeout = RequestTimeout
)
+690
View File
@@ -0,0 +1,690 @@
import logging
from app.extensions import db, redis_controller
from app.util import redislock
from app.models.user import User
from app.models.groups import Group, GroupIcon, GroupSettings, GroupRole, GroupRolePermission, GroupMember, GroupEconomy, GroupJoinRequest, GroupStatus, GroupWallPost
class GroupExceptions:
class RolesetDoesNotExist(Exception):
pass
class GroupDoesNotExist(Exception):
pass
class UserDoesNotExist(Exception):
pass
class RolesetNameNotUnique(Exception):
pass
class InvalidRankNumber(Exception):
pass
class UserNotInGroup(Exception):
pass
class UserAlreadyInsideGroup(Exception):
pass
class CorruptedGroup(Exception):
pass
class GroupNameAlreadyTaken(Exception):
pass
class GroupNameNotAllowed(Exception):
pass
class InsufficientPermssions(Exception):
pass
class UserGroupEntry:
group_id : int = 0
group_name : str = ""
group_description : str = ""
group_owner_id : int = 0
group_member_count : int = 0
user_rank : int = 0
user_roleset : GroupRole | None = None
def __init__(self, Group : Group, UserRoleset : GroupRole | None):
self.group_id = Group.id
self.group_name = Group.name
self.group_description = Group.description
self.group_owner_id = Group.owner_id
self.user_rank = UserRoleset.rank
self.user_roleset = UserRoleset
self.group_member_count = GetGroupMemberCount(Group)
def GetUserFromId( UserObj : User | int ) -> User | None:
"""
Returns a User object from a User ID.
"""
if isinstance(UserObj, User):
return UserObj
else:
TargetUser : User | None = User.query.filter_by(id=UserObj).first()
if TargetUser is None:
raise GroupExceptions.UserDoesNotExist("User does not exist.")
return TargetUser
def GetRolesetFromId( Role : GroupRole | int ) -> GroupRole | None:
"""
Returns a GroupRole object from a GroupRole ID.
"""
if isinstance(Role, GroupRole):
return Role
else:
TargetGroupRole : GroupRole | None = GroupRole.query.filter_by(id=Role).first()
if TargetGroupRole is None:
raise GroupExceptions.RolesetDoesNotExist("Roleset does not exist.")
return TargetGroupRole
def GetGroupFromId( GroupObj : Group | int ) -> Group | None:
"""
Returns a Group object from a Group ID.
"""
if isinstance(GroupObj, Group):
return GroupObj
else:
TargetGroup : Group | None = Group.query.filter_by(id=GroupObj).first()
if TargetGroup is None:
raise GroupExceptions.GroupDoesNotExist("Group does not exist.")
return TargetGroup
def RefreshRolesetCount( Role : GroupRole | int ) -> int:
"""
Recounts the amount of users in a GroupRole and updates the database.
then returns the amount of users in the role.
"""
Role : GroupRole | None = GetRolesetFromId(Role)
TotalMembers : int = GroupMember.query.filter_by(group_id=Role.group_id, group_role_id=Role.id).count()
Role.member_count = TotalMembers
db.session.commit()
return TotalMembers
def GetGroupMemberCount( Group : Group | int ) -> int:
"""
Returns the amount of members in a group.
"""
Group : Group | None = GetGroupFromId(Group)
TotalMembers : int = 0
for Role in GroupRole.query.filter_by(group_id=Group.id).all():
TotalMembers += Role.member_count
return TotalMembers
def GetUserRankInGroup( TargetUser : User | int, TargetGroup : Group | int ) -> int:
"""
Returns a integer between 0-255 representing the user rank in the group.
0 - Guest ( Not in the group )
1 - 244 - Custom ranks
255 - Group Owner
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
UserGroupMembership : GroupMember | None = GroupMember.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
if UserGroupMembership is None:
return 0 # Guest
try:
UserAssignedRoleset : GroupRole = GetRolesetFromId(UserGroupMembership.group_role_id)
except GroupExceptions.RolesetDoesNotExist:
logging.warn(f"User {TargetUser.id} is in group {TargetGroup.id} but the roleset {UserGroupMembership.group_role_id} does not exist.")
return 0 # Weird edge case where the user is in the group but the roleset doesn't exist
return UserAssignedRoleset.rank
def GetUserRolesetInGroup( TargetUser : User | int, TargetGroup : Group | int ) -> GroupRole | None:
"""
Returns a GroupRole object representing the user's roleset in the group.
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
UserGroupMembership : GroupMember | None = GroupMember.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
if UserGroupMembership is None:
GuestRoleset : GroupRole | None = GroupRole.query.filter_by(group_id=TargetGroup.id, rank=0).first()
if GuestRoleset is None:
raise GroupExceptions.CorruptedGroup("Group has no guest roleset.")
return GuestRoleset
try:
UserAssignedRoleset : GroupRole = GetRolesetFromId(UserGroupMembership.group_role_id)
except GroupExceptions.RolesetDoesNotExist:
logging.warn(f"User {TargetUser.id} is in group {TargetGroup.id} but the roleset {UserGroupMembership.group_role_id} does not exist.")
return None
return UserAssignedRoleset
def GetUserGroups( TargetUser : User | int ) -> list[UserGroupEntry]:
"""
Returns a list of UserGroupEntry objects representing the user's groups.
"""
TargetUser : User = GetUserFromId(TargetUser)
UserGroups : list[UserGroupEntry] = []
for UserGroupMembership in GroupMember.query.filter_by(user_id=TargetUser.id).all():
try:
UserAssignedRoleset : GroupRole = GetRolesetFromId(UserGroupMembership.group_role_id)
except GroupExceptions.RolesetDoesNotExist:
logging.warn(f"User {TargetUser.id} is in group {UserGroupMembership.group_id} but the roleset {UserGroupMembership.group_role_id} does not exist.")
continue
GroupObj : Group = GetGroupFromId(UserGroupMembership.group_id)
UserGroups.append(UserGroupEntry(
Group=GroupObj,
UserRoleset=UserAssignedRoleset
))
return UserGroups
def GetGroupRolesets( TargetGroup : Group | int ) -> list[GroupRole]:
"""
Returns a list of GroupRole objects representing the group's rolesets.
"""
TargetGroup : Group = GetGroupFromId(TargetGroup)
return GroupRole.query.filter_by(group_id=TargetGroup.id).order_by(GroupRole.rank.asc()).all()
def CreateGroupRoleset( TargetGroup : Group | int, Name : str, Description : str, Rank : int ) -> GroupRole:
"""
Creates a new roleset in the group and returns the GroupRole object.
"""
TargetGroup : Group = GetGroupFromId(TargetGroup)
if GroupRole.query.filter_by(group_id=TargetGroup.id, name=Name).first() is not None:
raise GroupExceptions.RolesetNameNotUnique("A roleset with that name already exists.")
if Rank < 0 or Rank > 255:
raise GroupExceptions.InvalidRankNumber("Rank must be between 0 and 255.")
if len(Name) > 255:
raise ValueError("Name must be less than 255 characters.")
if len(Description) > 255:
raise ValueError("Description must be less than 255 characters.")
NewRoleset : GroupRole = GroupRole(
group_id=TargetGroup.id,
name=Name,
description=Description,
rank=Rank,
member_count=0
)
db.session.add(NewRoleset)
db.session.commit()
RolesetPermission : GroupRolePermission = GroupRolePermission(
group_role_id=NewRoleset.id
)
RolesetPermission.view_status = True
db.session.add(RolesetPermission)
db.session.commit()
return NewRoleset
def ChangeUserRole( TargetUser : User | int , TargetRoleset : GroupRole | int ) -> None:
"""
Changes the user's roleset in the group if possible.
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetRoleset : GroupRole = GetRolesetFromId(TargetRoleset)
TargetGroup : Group = GetGroupFromId(TargetRoleset.group_id)
if GetUserRankInGroup(TargetUser, TargetRoleset.group_id) == 0:
raise GroupExceptions.UserNotInGroup("User is not in the group.")
if GetUserRolesetInGroup(TargetUser, TargetRoleset.group_id) == TargetRoleset:
return
if TargetGroup.owner_id == TargetUser.id and TargetRoleset.rank != 255:
raise ValueError("Cannot change the owner's roleset.")
UserGroupMembership : GroupMember = GroupMember.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
OldRolesetId : int = UserGroupMembership.group_role.id
UserGroupMembership.group_role_id = TargetRoleset.id
db.session.commit()
RefreshRolesetCount(OldRolesetId)
RefreshRolesetCount(TargetRoleset.id)
return
def AddUserToGroup( TargetUser : User | int , TargetGroup : Group | int, ForceJoin : bool = False ) -> GroupMember | GroupJoinRequest | None:
"""
Adds the user to the group if possible.
ForceJoin : bool ( If the group requires manual approval, this will bypass it. )
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
if GetUserRankInGroup(TargetUser, TargetGroup) != 0:
raise GroupExceptions.UserAlreadyInsideGroup("User is already in the group.")
TargetGroupSettings : GroupSettings | None = GroupSettings.query.filter_by(group_id=TargetGroup.id).first()
if TargetGroupSettings is None:
raise ValueError("Group settings do not exist.")
if TargetGroupSettings.approval_required and not ForceJoin:
ExisitingJoinRequest : GroupJoinRequest | None = GroupJoinRequest.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
if ExisitingJoinRequest is not None:
return ExisitingJoinRequest
NewJoinRequest : GroupJoinRequest = GroupJoinRequest(
user_id=TargetUser.id,
group_id=TargetGroup.id
)
db.session.add(NewJoinRequest)
db.session.commit()
return GroupJoinRequest
LowestRankRoleset : GroupRole | None = GroupRole.query.filter_by(group_id=TargetGroup.id).filter(GroupRole.rank > 0).order_by(GroupRole.rank.asc()).first()
if LowestRankRoleset is None:
raise GroupExceptions.CorruptedGroup("Group has no rolesets.")
if LowestRankRoleset.rank == 255:
raise GroupExceptions.CorruptedGroup("Group has no member roleset.")
NewGroupMember : GroupMember = GroupMember(
user_id=TargetUser.id,
group_id=TargetGroup.id,
group_role_id=LowestRankRoleset.id
)
db.session.add(NewGroupMember)
db.session.commit()
RefreshRolesetCount(LowestRankRoleset)
return NewGroupMember
def GetJoinRequest( TargetUser : User | int, TargetGroup : Group | int ) -> GroupJoinRequest | None:
"""
Returns a GroupJoinRequest object from a user and group.
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
return GroupJoinRequest.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
def AcceptJoinRequest( TargetUser : User | int, TargetGroup : Group | int ) -> GroupMember | None:
"""
Accepts the user's join request.
"""
ExisitingJoinRequest : GroupJoinRequest | None = GroupJoinRequest.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
if ExisitingJoinRequest is None:
raise ValueError("Join request does not exist.")
db.session.delete(ExisitingJoinRequest)
db.session.commit()
return AddUserToGroup(TargetUser, TargetGroup, ForceJoin=True)
def GetRolesetPermission( GroupRoleset : GroupRole | int ) -> GroupRolePermission:
"""
Returns a GroupRolePermission object from a GroupRole.
"""
GroupRoleset : GroupRole = GetRolesetFromId(GroupRoleset)
RolesetPermission : GroupRolePermission | None = GroupRolePermission.query.filter_by(group_role_id=GroupRoleset.id).first()
if RolesetPermission is None:
RolesetPermission : GroupRolePermission = GroupRolePermission(
group_role_id=GroupRoleset.id
)
db.session.add(RolesetPermission)
db.session.commit()
return RolesetPermission
def ModifyRolesetPermission(
GroupRoleset : GroupRole,
DeleteFromWall : bool = None,
PostToWall : bool = None,
InviteMembers : bool = None,
PostToStatus : bool = None,
RemoveMembers : bool = None,
ViewStatus : bool = None,
ViewWall : bool = None,
ChangeRank : bool = None,
AdvertiseGroup : bool = None,
ManageRelationships : bool = None,
AddGroupPlaces : bool = None,
ViewAuditLogs : bool = None,
CreateItems : bool = None,
ManageItems : bool = None,
SpendGroupFunds : bool = None,
ManageClan : bool = None,
ManageGroupGames : bool = None
) -> None:
"""
Modifies the roleset's permissions.
"""
RolesetPermission : GroupRolePermission = GetRolesetPermission(GroupRoleset)
if DeleteFromWall is not None:
RolesetPermission.delete_from_wall = DeleteFromWall
if PostToWall is not None:
RolesetPermission.post_to_wall = PostToWall
if InviteMembers is not None:
RolesetPermission.invite_members = InviteMembers
if PostToStatus is not None:
RolesetPermission.post_to_status = PostToStatus
if RemoveMembers is not None:
RolesetPermission.remove_members = RemoveMembers
if ViewStatus is not None:
RolesetPermission.view_status = ViewStatus
if ViewWall is not None:
RolesetPermission.view_wall = ViewWall
if ChangeRank is not None:
RolesetPermission.change_rank = ChangeRank
if AdvertiseGroup is not None:
RolesetPermission.advertise_group = AdvertiseGroup
if ManageRelationships is not None:
RolesetPermission.manage_relationships = ManageRelationships
if AddGroupPlaces is not None:
RolesetPermission.add_group_places = AddGroupPlaces
if ViewAuditLogs is not None:
RolesetPermission.view_audit_logs = ViewAuditLogs
if CreateItems is not None:
RolesetPermission.create_items = CreateItems
if ManageItems is not None:
RolesetPermission.manage_items = ManageItems
if SpendGroupFunds is not None:
RolesetPermission.spend_group_funds = SpendGroupFunds
if ManageClan is not None:
RolesetPermission.manage_clan = ManageClan
if ManageGroupGames is not None:
RolesetPermission.manage_group_games = ManageGroupGames
db.session.commit()
return
from app.util.textfilter import FilterText, TextNotAllowedException
from sqlalchemy import func
def SearchGroupByName( GroupName : str ) -> Group | None :
"""
Returns a Group object from a group name.
"""
GroupName = GroupName.lower()
return Group.query.filter(func.lower(Group.name) == GroupName).first()
def isGroupNameAllowed( GroupName : str ) -> bool:
"""
Returns True if the group name is allowed.
"""
if len(GroupName) > 255:
return False
if len(GroupName) < 3:
return False
AlphanumericCharacters : int = 0
for Character in GroupName:
if Character.isalnum():
AlphanumericCharacters += 1
if AlphanumericCharacters < 3:
return False
try:
FilterText( Text = GroupName, ThrowException = True)
except TextNotAllowedException:
return False
return True
def AssertUserHasPermission( TargetUser : User | int, TargetGroup : Group | int, Permission ) -> None: # Permission should be like GroupRolePermission.post_to_status
"""
Raises an exception if the user does not have the permission.
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
UserRoleset : GroupRole | None = GetUserRolesetInGroup(TargetUser, TargetGroup)
if UserRoleset is None:
raise GroupExceptions.CorruptedGroup("Group does not have a guest roleset.")
RolesetPermission : GroupRolePermission = GetRolesetPermission(UserRoleset)
if not getattr(RolesetPermission, Permission.name):
raise GroupExceptions.InsufficientPermssions("User does not have permission.")
return
def CreateGroup( GroupName : str, GroupDescription : str, GroupOwner : User | int, GroupIconContentHash : str ) -> Group | None :
"""
Creates a new group and returns the Group object.
"""
CreateLock = redislock.acquire_lock("group_creation_lock", 30, 5)
if not CreateLock:
raise ValueError("Failed to acquire global creation lock.")
if SearchGroupByName(GroupName) is not None:
redislock.release_lock("group_creation_lock", CreateLock)
raise GroupExceptions.GroupNameAlreadyTaken("Group name is already taken.")
if isGroupNameAllowed(GroupName) is False:
redislock.release_lock("group_creation_lock", CreateLock)
raise GroupExceptions.GroupNameNotAllowed("Group name is not allowed.")
if len(GroupDescription) > 1024:
redislock.release_lock("group_creation_lock", CreateLock)
raise ValueError("Group description must be less than 1024 characters.")
GroupOwner : User = GetUserFromId(GroupOwner)
NewGroup : Group = Group(
name=GroupName,
description=GroupDescription,
owner_id=GroupOwner.id
)
db.session.add(NewGroup)
db.session.commit()
redislock.release_lock("group_creation_lock", CreateLock)
NewGroupSettings : GroupSettings = GroupSettings(
group_id=NewGroup.id
)
db.session.add(NewGroupSettings)
NewGroupIcon : GroupIcon = GroupIcon(
group_id=NewGroup.id,
content_hash=GroupIconContentHash,
moderation_status=1,
creator_id=GroupOwner.id
)
db.session.add(NewGroupIcon)
NewGroupEconomy : GroupEconomy = GroupEconomy(
group_id=NewGroup.id
)
db.session.add(NewGroupEconomy)
db.session.commit()
GuestGroupRoleset : GroupRole = CreateGroupRoleset(
TargetGroup=NewGroup,
Name="Guest",
Description="A non-group member.",
Rank=0
)
ModifyRolesetPermission(GuestGroupRoleset, ViewStatus=True, ViewWall=True)
MemberGroupRoleset : GroupRole = CreateGroupRoleset(
TargetGroup=NewGroup,
Name="Member",
Description="A regular group member.",
Rank=1
)
ModifyRolesetPermission(MemberGroupRoleset, ViewStatus=True, ViewWall=True, PostToWall=True)
AdminGroupRoleset : GroupRole = CreateGroupRoleset(
TargetGroup=NewGroup,
Name="Admin",
Description="A group administrator.",
Rank=254
)
ModifyRolesetPermission(AdminGroupRoleset, ViewStatus=True, ViewWall=True, PostToWall=True)
OwnerGroupRoleset : GroupRole = CreateGroupRoleset(
TargetGroup=NewGroup,
Name="Owner",
Description="The group's owner.",
Rank=255
)
ModifyRolesetPermission(
OwnerGroupRoleset,
DeleteFromWall=True,
PostToWall=True,
InviteMembers=True,
PostToStatus=True,
RemoveMembers=True,
ViewStatus=True,
ViewWall=True,
ChangeRank=True,
AdvertiseGroup=True,
ManageRelationships=True,
AddGroupPlaces=True,
ViewAuditLogs=True,
CreateItems=True,
ManageItems=True,
SpendGroupFunds=True,
ManageClan=True,
ManageGroupGames=True
)
AddUserToGroup(GroupOwner, NewGroup, ForceJoin=True)
ChangeUserRole(GroupOwner, OwnerGroupRoleset)
return NewGroup
def PostToGroupStatus( Poster : User | int, TargetGroup : Group | int, StatusMessage : str, ForcePost : bool = False ) -> GroupStatus:
"""
Posts a status to the group.
ForcePost : bool ( If True this function will not check for the user permissions. )
"""
Poster : User = GetUserFromId(Poster)
TargetGroup : Group = GetGroupFromId(TargetGroup)
if not ForcePost:
AssertUserHasPermission(Poster, TargetGroup, GroupRolePermission.post_to_status)
FilteredMessage : str = FilterText(Text=StatusMessage)
if len(FilteredMessage) > 1024:
raise ValueError("Status message must be less than 1024 characters.")
NewStatus : GroupStatus = GroupStatus(
group_id=TargetGroup.id,
poster_id=Poster.id,
content=FilteredMessage
)
db.session.add(NewStatus)
db.session.commit()
return NewStatus
def PostToGroupWall( Poster : User | int, TargetGroup : Group | int, WallMessage : str, ForcePost : bool = False ) -> GroupWallPost:
"""
Posts a message to the group wall.
ForcePost : bool ( If True this function will not check for the user permissions. )
"""
Poster : User = GetUserFromId(Poster)
TargetGroup : Group = GetGroupFromId(TargetGroup)
if not ForcePost:
AssertUserHasPermission(Poster, TargetGroup, GroupRolePermission.post_to_wall)
FilteredMessage : str = FilterText(Text=WallMessage)
if len(FilteredMessage) > 1024:
raise ValueError("Wall message must be less than 1024 characters.")
NewWallPost : GroupWallPost = GroupWallPost(
group_id=TargetGroup.id,
poster_id=Poster.id,
content=FilteredMessage
)
db.session.add(NewWallPost)
db.session.commit()
return NewWallPost
def GetGroupWallPosts( TargetGroup : Group | int, Page : int = 1, PerPage : int= 10 ) -> list[GroupWallPost]:
"""
Returns a list of GroupWallPost objects representing the group's wall posts.
"""
TargetGroup : Group = GetGroupFromId(TargetGroup)
return GroupWallPost.query.filter_by(group_id=TargetGroup.id).order_by(GroupWallPost.created_at.desc()).paginate(Page, PerPage, False).items
def RemoveUserFromGroup( TargetUser : User | int, TargetGroup : Group | int, AllowOwnerRemove : bool = False ) -> bool:
"""
Removes the user from the group.
"""
TargetUser : User = GetUserFromId(TargetUser)
TargetGroup : Group = GetGroupFromId(TargetGroup)
if GetUserRankInGroup(TargetUser, TargetGroup.id) == 0:
raise GroupExceptions.UserNotInGroup("User is not in the group.")
if TargetGroup.owner_id == TargetUser.id and not AllowOwnerRemove:
raise ValueError("Cannot remove the owner from the group.")
if TargetGroup.owner_id == TargetUser.id:
TargetGroup.owner_id = None
UserGroupMembership : GroupMember = GroupMember.query.filter_by(user_id=TargetUser.id, group_id=TargetGroup.id).first()
OldRolesetId : int = UserGroupMembership.group_role.id
db.session.delete(UserGroupMembership)
db.session.commit()
RefreshRolesetCount(OldRolesetId)
return True
def isGroupNameAllowed( GroupName : str ) -> tuple[bool, str]:
"""
Returns True if the group name is allowed.
If not it returns a bool and a string which is the reason why its not allowed
"""
if len(GroupName) > 50:
return False, "Group name must be less than 40 characters."
if len(GroupName) < 3:
return False, "Group name must be more than 3 characters."
AlphanumericCharacters : int = 0
for Character in GroupName:
if Character.isalnum():
AlphanumericCharacters += 1
if AlphanumericCharacters < 3:
return False, "Group name must contain at least 3 alphanumeric characters."
try:
FilterText( Text = GroupName, ThrowException = True)
except TextNotAllowedException:
return False, "Group name is not allowed."
return True, ""
def DeleteGroupWallPost( TargetPost : GroupWallPost, Remover : User | int, ForceDelete : bool = False ) -> None:
"""
Deletes a group wall post.
ForceDelete : bool ( If True this function will not check for the user permissions. )
"""
TargetPost : GroupWallPost = TargetPost
Remover : User = GetUserFromId(Remover)
TargetGroup : Group = GetGroupFromId(TargetPost.group_id)
if not ForceDelete:
AssertUserHasPermission(Remover, TargetGroup, GroupRolePermission.delete_from_wall)
db.session.delete(TargetPost)
db.session.commit()
return
def GetLatestGroupStatus( TargetGroup : Group | int, ignoreEmpty : bool = True ) -> GroupWallPost | None:
"""
Returns the latest group status.
"""
TargetGroup : Group = GetGroupFromId(TargetGroup)
LatestGroupStatusPost : GroupStatus | None = GroupStatus.query.filter_by(group_id=TargetGroup.id).order_by(GroupStatus.created_at.desc()).first()
if LatestGroupStatusPost is None:
return None
if ignoreEmpty and LatestGroupStatusPost.content == "":
return None
return LatestGroupStatusPost
def SetNewGroupIcon( TargetGroup : Group | int, NewIconHash : str, Uploader : User | int ) -> GroupIcon:
"""
Sets a new group icon.
"""
TargetGroup : Group = GetGroupFromId(TargetGroup)
Uploader : User = GetUserFromId(Uploader)
if TargetGroup.icon is not None:
db.session.delete(TargetGroup.icon)
db.session.commit()
NewGroupIcon : GroupIcon = GroupIcon(
group_id=TargetGroup.id,
content_hash=NewIconHash,
moderation_status=1,
creator_id=Uploader.id
)
db.session.add(NewGroupIcon)
db.session.commit()
return NewGroupIcon
def GetUserGroupCount( UserObj : User | int ) -> int:
"""
Gets the amount of groups a user is in.
"""
UserObj : User = GetUserFromId(UserObj)
return GroupMember.query.filter_by(user_id=UserObj.id).count()
+70
View File
@@ -0,0 +1,70 @@
import random
import string
import uuid
from datetime import datetime
from app.extensions import db
from app.models.invite_key import InviteKey
from app.models.user import User
class InviteExceptions:
class InvalidInviteKey(Exception):
pass
class UserDoesNotExist(Exception):
pass
class InviteKeyAlreadyUsed(Exception):
pass
def _GenerateInviteKey() -> str:
"""
Returns a randomly generated invite key
"""
RandomUUID = str(uuid.uuid4())
invite_key = "syntax" + RandomUUID[6:]
InviteKeyObj : InviteKey = InviteKey.query.filter_by(key=invite_key).first()
if InviteKeyObj is not None:
return _GenerateInviteKey()
return invite_key
def GetUserFromId( UserObj : User | int ) -> User | None:
"""
Returns a User object from a User ID.
"""
if isinstance(UserObj, User):
return UserObj
else:
TargetUser : User | None = User.query.filter_by(id=UserObj).first()
if TargetUser is None:
raise InviteExceptions.UserDoesNotExist("User does not exist.")
return TargetUser
def GetInviteKey( key : str ) -> InviteKey:
InviteKeyObj : InviteKey = InviteKey.query.filter_by(key=key).first()
if InviteKeyObj is None:
raise InviteExceptions.InvalidInviteKey()
return InviteKeyObj
def UseInviteKey( key : str, user : User ) -> InviteKey:
InviteKeyObj : InviteKey = GetInviteKey(key)
if InviteKeyObj.used_by is not None:
raise InviteExceptions.InviteKeyAlreadyUsed()
InviteKeyObj.used_by = user.id
InviteKeyObj.used_on = datetime.utcnow()
db.session.add(InviteKeyObj)
db.session.commit()
return InviteKeyObj
def CreateInviteKey( creator : User | int | None ) -> InviteKey:
"""
Create a new invite key.
"""
if creator is not None:
creator = GetUserFromId(creator)
invite_key = _GenerateInviteKey()
InviteKeyObj = InviteKey(
key = invite_key,
created_by = creator.id if creator is not None else None
)
db.session.add(InviteKeyObj)
db.session.commit()
return InviteKeyObj
+82
View File
@@ -0,0 +1,82 @@
import requests
import logging
import json
from app.extensions import redis_controller
from app import Config
config = Config()
class IPHubQueryFailed(Exception):
pass
class IPHubRateLimited(Exception):
pass
def lookup_address_info( address : str, skip_cache : bool = False, lookup_timeout : int = 10 ) -> dict:
if not skip_cache:
try:
CachedResponse : str = redis_controller.get(f"iphub_lookup_{address}")
if CachedResponse is not None:
return json.loads(CachedResponse)
except Exception as e:
logging.error(f"lookup_address_info : IPHub cache lookup failed: {e}")
redis_controller.delete(f"iphub_lookup_{address}")
try:
LookupResponse : requests.Response = requests.get(
f"https://api.ipapi.is/?q={address}&key={config.IPAPI_AUTH_KEY}", # We NEED to put this in env (and not hardcode it)
headers = { },
timeout = lookup_timeout
)
if LookupResponse.status_code == 403 or LookupResponse.status_code == 429:
logging.error("lookup_address_info : IPHub rate limited")
raise IPHubRateLimited("IPHub rate limited")
elif LookupResponse.status_code != 200:
logging.error(f"lookup_address_info : IPHub query failed: {LookupResponse.status_code} {LookupResponse.text}")
raise IPHubQueryFailed(f"IPHub query failed: {LookupResponse.status_code} {LookupResponse.text}")
LookupResponseJSON : dict = LookupResponse.json()
redis_controller.setex(
name = f"iphub_lookup_{address}",
time = config.IPAPI_CACHE_LIFETIME,
value = json.dumps(LookupResponseJSON)
)
return LookupResponseJSON
except requests.exceptions.Timeout:
logging.error(f"lookup_address_info : IPHub query timed out after {lookup_timeout} seconds")
raise IPHubQueryFailed("IPHub query timed out")
except Exception as e:
logging.error(f"lookup_address_info : IPHub query failed: {e}")
raise IPHubQueryFailed(f"IPHub query failed: {e}")
def fetch_address_risk( address : str, skip_cache : bool = False, lookup_timeout : int = 10, fallback_on_exception : bool = True ) -> int:
"""
:param address: The IP address to check
:param skip_cache: Whether to skip the cache and query IPHub directly
:param lookup_timeout: The timeout for the query
:param fallback_on_exception: Whether to return 0 if the query fails
:return: The risk level of the IP address (0, 1, or 2)
https://iphub.info/api
0 - Residential or business IP (i.e. safe IP)
1 - Non-residential IP (hosting provider, proxy, etc.)
2 - Non-residential & residential IP (warning, may flag innocent people)
"""
try:
LookupResponseJSON: dict = lookup_address_info(address, skip_cache, lookup_timeout)
if LookupResponseJSON.get("is_crawler", True) \
or LookupResponseJSON.get("is_datacenter", False) \
or LookupResponseJSON.get("is_tor", False) \
or LookupResponseJSON.get("is_proxy", False) \
or LookupResponseJSON.get("is_vpn", False) \
or LookupResponseJSON.get("is_abuser", False):
return 1
else:
return
except Exception as e:
if fallback_on_exception:
logging.error(f"fetch_address_risk : Exception raised falling back: {e}")
return 3 # Why would we let them pass????????????
else:
raise e
@@ -0,0 +1,216 @@
from app.models.follow_relationship import FollowRelationship
from app.models.user import User
from app.extensions import db, redis_controller
from app.util.websiteFeatures import GetWebsiteFeature
import redis_lock
class FollowingExceptions():
class AlreadyFollowing(Exception):
pass
class CannotFollowSelf(Exception):
pass
class RedisLockAcquireError(Exception):
pass
class UserRateLimited(Exception):
pass
class UserNotFollowing(Exception):
pass
class FollowingIsDisabled(Exception):
pass
def follow_user(
follower_user : User,
followed_user : User,
bypass_rate_limit : bool = False
) -> None:
"""
:param follower_user : User : The user that is following
:param followed_user : User : The user that is being followed
:param bypass_rate_limit : bool : Bypass the rate limit check
:return None
:raises FollowingExceptions.AlreadyFollowing : The user is already following the other user
:raises FollowingExceptions.CannotFollowSelf : The user cannot follow themselves
:raises FollowingExceptions.UserNotFound : The user is not found
:raises FollowingExceptions.RedisLockAcquireError : The Redis lock cannot be acquired
:raises FollowingExceptions.UserRateLimited : The user is rate limited
:raises FollowingExceptions.FollowingIsDisabled : Following is disabled
"""
assert isinstance(follower_user, User), f"Expected follower_user to be of type User, got {follower_user.__class__.__name__}"
assert isinstance(followed_user, User), f"Expected followed_user to be of type User, got {followed_user.__class__}"
if follower_user.id == followed_user.id:
raise FollowingExceptions.CannotFollowSelf
if GetWebsiteFeature("FollowingUsers") is False:
raise FollowingExceptions.FollowingIsDisabled
try:
with redis_lock.Lock( redis_client = redis_controller, name = f"services:followings:follow_user:{follower_user.id}", expire = 10 ):
if not bypass_rate_limit:
if redis_controller.get(f"rate_limit:followings:follow_user_action:{follower_user.id}") is not None:
raise FollowingExceptions.UserRateLimited
redis_controller.set(f"rate_limit:followings:follow_user_action:{follower_user.id}", "1", ex = 5)
if FollowRelationship.query.filter_by(followerUserId = follower_user.id, followeeUserId = followed_user.id).first() is not None:
raise FollowingExceptions.AlreadyFollowing
follow_relationship = FollowRelationship(
followerUserId = follower_user.id,
followeeUserId = followed_user.id
)
db.session.add(follow_relationship)
db.session.commit()
return None
except AssertionError:
raise FollowingExceptions.RedisLockAcquireError
def unfollow_user(
current_follower : User,
followed_user : User,
bypass_rate_limit : bool = False
) -> None:
"""
:param current_follower : User : The user that is unfollowing
:param followed_user : User : The user that is being unfollowed
:param bypass_rate_limit : bool : Bypass the rate limit check
:return None
:raises FollowingExceptions.UserNotFollowing : The user is not following the other user
:raises FollowingExceptions.RedisLockAcquireError : The Redis lock cannot be acquired
:raises FollowingExceptions.UserRateLimited : The user is rate limited
"""
assert isinstance(current_follower, User), f"Expected current_follower to be of type User, got {current_follower.__class__.__name__}"
assert isinstance(followed_user, User), f"Expected followed_user to be of type User, got {followed_user.__class__}"
try:
with redis_lock.Lock( redis_client = redis_controller, name = f"services:followings:unfollow_user:{current_follower.id}", expire = 10 ):
if not bypass_rate_limit:
if redis_controller.get(f"rate_limit:followings:unfollow_user_action:{current_follower.id}") is not None:
raise FollowingExceptions.UserRateLimited
redis_controller.set(f"rate_limit:followings:unfollow_user_action:{current_follower.id}", "1", ex = 1)
follow_relationship = FollowRelationship.query.filter_by(followerUserId = current_follower.id, followeeUserId = followed_user.id).first()
if follow_relationship is None:
raise FollowingExceptions.UserNotFollowing
db.session.delete(follow_relationship)
db.session.commit()
return None
except AssertionError:
raise FollowingExceptions.RedisLockAcquireError
def is_following(
follower_user : User,
followed_user : User
) -> bool:
"""
:param follower_user : User : The user that is following
:param followed_user : User : The user that is being followed
:return bool : Whether the user is following the other user
"""
assert isinstance(follower_user, User), f"Expected follower_user to be of type User, got {follower_user.__class__.__name__}"
assert isinstance(followed_user, User), f"Expected followed_user to be of type User, got {followed_user.__class__}"
return FollowRelationship.query.filter_by(followerUserId = follower_user.id, followeeUserId = followed_user.id).first() is not None
def get_followers(
requested_user : User,
return_as_query : bool = False
) -> list[User]:
"""
:param requested_user : User : The user that is being requested
:param return_as_query : bool : Whether to return the result as a query or a list
:return list[User] : The list of users that are following the requested user
"""
assert isinstance(requested_user, User), f"Expected requested_user to be of type User, got {requested_user.__class__.__name__}"
FollowersQuery = User.query.join(FollowRelationship, FollowRelationship.followerUserId == User.id).filter(FollowRelationship.followeeUserId == requested_user.id)
if return_as_query:
return FollowersQuery
return FollowersQuery.all()
def get_follower_count(
requested_user : User,
skip_cache : bool = False
) -> int:
"""
:param requested_user : User : The user that is being requested
:param skip_cache : bool : Whether to skip the cache
:return int : The number of users that are following the requested user
"""
assert isinstance(requested_user, User), f"Expected requested_user to be of type User, got {requested_user.__class__}"
if not skip_cache:
CachedFollowerCount = redis_controller.get(f"services:followings:follower_count:{requested_user.id}")
if CachedFollowerCount is not None:
return int(CachedFollowerCount)
FollowerCount : int = get_followers(requested_user, return_as_query = True).count()
redis_controller.set(f"services:followings:follower_count:{requested_user.id}", FollowerCount, ex = 10)
return FollowerCount
def get_following(
requested_user : User,
return_as_query : bool = False
) -> list[User]:
"""
:param requested_user : User : The user that is being requested
:param return_as_query : bool : Whether to return the result as a query or a list
:return list[User] : The list of users that the requested user is following
"""
assert isinstance(requested_user, User), f"Expected requested_user to be of type User, got {requested_user.__class__}"
FollowingQuery = User.query.join(FollowRelationship, FollowRelationship.followeeUserId == User.id).filter(FollowRelationship.followerUserId == requested_user.id)
if return_as_query:
return FollowingQuery
return FollowingQuery.all()
def get_following_count(
requested_user : User,
skip_cache : bool = False
) -> int:
"""
:param requested_user : User : The user that is being requested
:param skip_cache : bool : Whether to skip the cache
:return int : The number of users that the requested user is following
"""
assert isinstance(requested_user, User), f"Expected requested_user to be of type User, got {requested_user.__class__}"
if not skip_cache:
CachedFollowingCount = redis_controller.get(f"services:followings:following_count:{requested_user.id}")
if CachedFollowingCount is not None:
return int(CachedFollowingCount)
FollowingCount : int = get_following(requested_user, return_as_query = True).count()
redis_controller.set(f"services:followings:following_count:{requested_user.id}", FollowingCount, ex = 10)
return FollowingCount
+200
View File
@@ -0,0 +1,200 @@
from app.models.friend_relationship import FriendRelationship
from app.models.friend_request import FriendRequest
from app.models.user import User
from app.extensions import db, redis_controller
import redis_lock
class FriendExceptions():
class AlreadyFriends(Exception):
pass
class CannotFriendSelf(Exception):
pass
class RedisLockAcquireError(Exception):
pass
class UserRateLimited(Exception):
pass
class UserNotFriends(Exception):
pass
class FriendIsDisabled(Exception):
pass
class RecipientHasTooManyFriends(Exception):
pass
class SenderHasTooManyFriends(Exception):
pass
def get_friend_count(
user : User
) -> int:
"""
:param user : User : The user
:return int : The amount of friends the user has
"""
assert isinstance(user, User), f"Expected user to be of type User, got {user.__class__}"
return FriendRelationship.query.filter(
(FriendRelationship.user_id == user.id) | (FriendRelationship.friend_id == user.id)
).count()
def get_friend_relationship(
user1 : User,
user2 : User
) -> FriendRelationship | None:
"""
:param user1 : User : The first user
:param user2 : User : The second
:return FriendRelationship | None : The friend relationship or None
"""
assert isinstance(user1, User), f"Expected user1 to be of type User, got {user1.__class__.__name__}"
assert isinstance(user2, User), f"Expected user2 to be of type User, got {user2.__class__}"
return FriendRelationship.query.filter(
(FriendRelationship.user_id == user1.id and FriendRelationship.friend_id == user2.id) |
(FriendRelationship.user_id == user2.id and FriendRelationship.friend_id == user1.id)
).first()
def create_friend_relationship(
user1 : User,
user2 : User,
) -> FriendRelationship:
"""
:param user1 : User : The first user
:param user2 : User : The second
:return FriendRelationship : The friend relationship
"""
assert isinstance(user1, User), f"Expected user1 to be of type User, got {user1.__class__.__name__}"
assert isinstance(user2, User), f"Expected user2 to be of type User, got {user2.__class__}"
FirstUser = user1 if user1.id < user2.id else user2
SecondUser = user2 if user1.id < user2.id else user1
try:
with redis_lock.Lock( redis_client = redis_controller, name = f"services:friends:create_friend_relationship:{FirstUser.id}:{SecondUser.id}", expire = 10 ):
if get_friend_relationship(user1, user2) is not None:
raise FriendExceptions.AlreadyFriends
friendRelationship = FriendRelationship(
user_id = user1.id,
friend_id = user2.id
)
db.session.add(friendRelationship)
db.session.commit()
return friendRelationship
except AssertionError:
raise FriendExceptions.RedisLockAcquireError
def remove_friend_relationship(
user1 : User,
user2 : User
) -> None:
"""
:param user1 : User : The first user
:param user2 : User : The second
:return None
"""
assert isinstance(user1, User), f"Expected user1 to be of type User, got {user1.__class__}"
assert isinstance(user2, User), f"Expected user2 to be of type User, got {user2.__class__}"
FirstUser = user1 if user1.id < user2.id else user2
SecondUser = user2 if user1.id < user2.id else user1
try:
with redis_lock.Lock( redis_client = redis_controller, name = f"services:friends:remove_friend_relationship:{FirstUser.id}:{SecondUser.id}", expire = 10 ):
friendRelationship = get_friend_relationship(user1, user2)
if friendRelationship is None:
raise FriendExceptions.UserNotFriends
db.session.delete(friendRelationship)
db.session.commit()
return None
except AssertionError:
raise FriendExceptions.RedisLockAcquireError
def send_friend_request(
sender_user : User,
recipient_user : User
) -> FriendRequest | FriendRelationship:
"""
:param sender_user : User : The user sending the friend request
:param recipient_user : User : The user receiving the friend request
:return FriendRequest | FriendRelationship : The friend request or friend relationship if there is already an existing friend request from the recipient user
"""
assert isinstance(sender_user, User), f"Expected user1 to be of type User, got {sender_user.__class__}"
assert isinstance(recipient_user, User), f"Expected user2 to be of type User, got {recipient_user.__class__}"
FirstUser = sender_user if sender_user.id < recipient_user.id else recipient_user
SecondUser = recipient_user if sender_user.id < recipient_user.id else sender_user
try:
with redis_lock.Lock( redis_client = redis_controller, name = f"services:friends:send_friend_request:{FirstUser.id}:{SecondUser.id}", expire = 10 ):
if get_friend_relationship(sender_user, recipient_user) is not None:
raise FriendExceptions.AlreadyFriends
if sender_user.id == recipient_user.id:
raise FriendExceptions.CannotFriendSelf
if get_friend_count(recipient_user) >= 200:
raise FriendExceptions.RecipientHasTooManyFriends
if get_friend_count(sender_user) >= 200:
raise FriendExceptions.SenderHasTooManyFriends
if redis_controller.get(f"rate_limit:friends:send_friend_request:{sender_user.id}") is not None:
raise FriendExceptions.UserRateLimited
redis_controller.set(f"rate_limit:friends:send_friend_request:{sender_user.id}", "1", ex = 3)
otherFriendRequest = FriendRequest.query.filter_by(requester_id = recipient_user.id, requestee_id = sender_user.id).first()
if otherFriendRequest is not None:
db.session.delete(otherFriendRequest)
db.session.commit()
return create_friend_relationship( user1 = sender_user, user2 = recipient_user)
friendRequest = FriendRequest.query.filter_by(requester_id = sender_user.id, requestee_id = recipient_user.id).first()
if friendRequest is not None:
return friendRequest
friendRequest = FriendRequest(
requester_id = sender_user.id,
requestee_id = recipient_user.id
)
db.session.add(friendRequest)
db.session.commit()
return friendRequest
except AssertionError:
raise FriendExceptions.RedisLockAcquireError
def decline_friend_request(
sender_user : User,
recipient_user : User
) -> None:
"""
:param sender_user : User : The user sending the friend request
:param recipient_user : User : The user receiving the friend request
:return None
"""
assert isinstance(sender_user, User), f"Expected user1 to be of type User, got {sender_user.__class__}"
assert isinstance(recipient_user, User), f"Expected user2 to be of type User, got {recipient_user.__class__}"
friendRequest = FriendRequest.query.filter_by(requester_id = sender_user.id, requestee_id = recipient_user.id).first()
if friendRequest is not None:
db.session.delete(friendRequest)
db.session.commit()
return None