syntaxwebsite/app/routes/gamejoin.py

714 lines
34 KiB
Python

from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, after_this_request, Response
from app.util import auth, websiteFeatures
from app.extensions import db, redis_controller, csrf, get_remote_address
import uuid
import requests
import time
from config import Config
import json
from datetime import datetime, timedelta
import random
import string
import logging
import base64
import hashlib
from app.models.user import User
from app.models.gameservers import GameServer
from app.models.placeservers import PlaceServer
from app.models.placeserver_players import PlaceServerPlayer
from app.models.place import Place
from app.models.asset import Asset
from app.models.login_records import LoginRecord
from app.models.user_hwid_log import UserHWIDLog
from app.models.universe import Universe
from app.models.asset_version import AssetVersion
from app.enums.AssetType import AssetType
from app.enums.MembershipType import MembershipType
from app.enums.PlaceYear import PlaceYear
from app.services.gameserver_comm import perform_post
from app.util.membership import GetUserMembership
from app.util.signscript import signUTF8
from app.util.assetversion import GetLatestAssetVersion
from app.routes.jobreporthandler import EvictPlayer
from app.routes.asset import GenerateTempAuthToken
config = Config()
class PlaceServerCooldownStart( Exception ):
pass
class NoAvailableGameServers( Exception ):
pass
class MissingData( Exception ):
pass
class UnsupportedPlaceYear( Exception ):
pass
class UnexpectedStatusCode( Exception ):
pass
class BadResponseData( Exception ):
pass
def CreateNewPlaceServer( placeId : int, reserved_server_access_code : str = None ) -> PlaceServer:
"""
Starts a new PlaceServer for the given placeId, raises appropriate exceptions if it fails
:param placeId: The placeId to start a new PlaceServer for
:reserved_server_access_code: The reserved server access code to use, if None it will be a public server
:return: PlaceServer object if successful
"""
CooldownKeyName : str = f"create_new_place_server:{placeId}:{reserved_server_access_code}"
if redis_controller.get(CooldownKeyName) is not None:
logging.debug(f"CreateNewPlaceServer -> Place {placeId} recently requested to create a new place server, skipping")
raise PlaceServerCooldownStart("")
redis_controller.setex(CooldownKeyName, 40, "1")
SelectedGameServerObj : GameServer = GameServer.query.filter_by(
allowGameServerHost = True
).filter(
GameServer.lastHeartbeat > datetime.utcnow() - timedelta(seconds=30)
).order_by(
GameServer.RCCmemoryUsage.asc()
).first()
if SelectedGameServerObj is None:
logging.error(f"CreateNewPlaceServer -> Failed to find a available Gameserver to host Place {placeId}")
raise NoAvailableGameServers("")
PlaceObj : Place = Place.query.filter_by( placeid = placeId ).first()
AssetObj : Asset = Asset.query.filter_by( id = placeId ).first()
if PlaceObj is None or AssetObj is None:
logging.error(f"CreateNewPlaceServer -> Failed to find Place / Asset {placeId} in database")
raise MissingData("")
UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
if UniverseObj is None:
logging.error(f"CreateNewPlaceServer -> Failed to find Universe {PlaceObj.parent_universe_id} in database for Place {placeId}")
raise MissingData("")
logging.info(f"CreateNewPlaceServer -> Attempting to start new place server for Place {PlaceObj.placeid}, Universe {UniverseObj.id} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), reserved_server_access_code: {reserved_server_access_code}")
RequestSession = requests.Session()
RequestSession.headers.update({
"Authorization": SelectedGameServerObj.accessKey
})
JSONOpenPayload = {}
GameOpenRoute = "Game"
JobId : str = str( uuid.uuid4() )
CommApiKey : str = str( uuid.uuid4() )
LatestPlaceVersion : AssetVersion = GetLatestAssetVersion( AssetObj )
if UniverseObj.place_year == PlaceYear.Sixteen:
TempPlaceAuthorizationToken : str = GenerateTempAuthToken( placeId, Expiration = 200, CreatorIP = None )
JSONOpenPayload = {
"placeid": placeId,
"creatorId": AssetObj.creator_id,
"creatorType": AssetObj.creator_type,
"SpecialAccessToken": TempPlaceAuthorizationToken,
"useNewLoadFile": False,
"loadfile_location": f"{config.BaseURL}/game/gameserver2016.lua",
"universeid": UniverseObj.id,
"place_version": LatestPlaceVersion.version
}
elif UniverseObj.place_year == PlaceYear.Eighteen:
GameOpenRoute = "Game2018"
JSONOpenPayload = {
"placeid": placeId,
"creatorId": AssetObj.creator_id,
"creatorType": "User" if AssetObj.creator_type == 0 else "Group",
"jobid": JobId,
"apikey": CommApiKey,
"maxplayers": PlaceObj.maxplayers,
"address": SelectedGameServerObj.serverIP,
"universeid": UniverseObj.id,
"place_version": LatestPlaceVersion.version
}
elif UniverseObj.place_year == PlaceYear.Twenty:
GameOpenRoute = "Game2020"
JSONOpenPayload = {
"placeid": placeId,
"creatorId": AssetObj.creator_id,
"creatorType": "User" if AssetObj.creator_type == 0 else "Group",
"jobid": JobId,
"apikey": CommApiKey,
"maxplayers": PlaceObj.maxplayers,
"address": SelectedGameServerObj.serverIP,
"universeid": UniverseObj.id,
"place_version": LatestPlaceVersion.version
}
elif UniverseObj.place_year == PlaceYear.Fourteen:
GameOpenRoute = "Game2014"
JSONOpenPayload = {
"placeid": placeId,
"creatorId": AssetObj.creator_id,
"creatorType": AssetObj.creator_type,
"universeid": UniverseObj.id,
"place_version": LatestPlaceVersion.version
}
elif UniverseObj.place_year == PlaceYear.TwentyOne:
GameOpenRoute = "Game2021"
JSONOpenPayload = {
"placeid": placeId,
"creatorId": AssetObj.creator_id,
"creatorType": "User" if AssetObj.creator_type == 0 else "Group",
"jobid": JobId,
"apikey": CommApiKey,
"maxplayers": PlaceObj.maxplayers,
"address": SelectedGameServerObj.serverIP,
"universeid": UniverseObj.id,
"place_version": LatestPlaceVersion.version
}
else:
logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId}, unsupported place year got {UniverseObj.place_year.name}")
raise UnsupportedPlaceYear(f"Year {UniverseObj.place_year.name} is not supported")
if UniverseObj.place_year in [ PlaceYear.Eighteen, PlaceYear.Twenty, PlaceYear.TwentyOne ]:
redis_controller.set(f"GameServerAccessKey:{CommApiKey}:{JobId}", "1", ex=60*60*24*2)
if UniverseObj.place_year in [ PlaceYear.Fourteen ]:
redis_controller.set(f"gameserver2014lua:{PlaceObj.placeid}:{JobId}", "1", ex=120)
try:
OpenJobReq = perform_post(
TargetGameserver = SelectedGameServerObj,
Endpoint = GameOpenRoute,
JSONData = JSONOpenPayload,
RequestTimeout = 35
)
except requests.exceptions.Timeout:
logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), open request timed out")
raise NoAvailableGameServers(f"Request timed out")
if OpenJobReq.status_code != 200:
logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), response: {OpenJobReq.content}")
raise UnexpectedStatusCode(f"Got status code {OpenJobReq.status_code} from Gameserver")
OpenJobReqJSON = OpenJobReq.json()
if "jobid" not in OpenJobReqJSON or "port" not in OpenJobReqJSON:
logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), missing 'port' or 'jobid' in JSON,response: {OpenJobReqJSON}")
raise BadResponseData(f"Missing 'port' or 'jobid' in JSON response")
redis_controller.set(f"place:{OpenJobReqJSON['jobid']}:origin", str(SelectedGameServerObj.serverId), ex=60*60*24*2)
PlaceServerObject = PlaceServer(
serveruuid = OpenJobReqJSON["jobid"],
originServerId = SelectedGameServerObj.serverId,
serverIP = SelectedGameServerObj.serverIP,
serverPort = OpenJobReqJSON["port"],
serverPlaceId = placeId,
maxPlayerCount = PlaceObj.maxplayers,
reservedServerAccessCode = reserved_server_access_code
)
db.session.add(PlaceServerObject)
db.session.commit()
logging.info(f"CreateNewPlaceServer -> Started new place server for Place {placeId}, Universe {UniverseObj.id} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), is_reserved: {reserved_server_access_code is not None}")
return PlaceServerObject
def GetSuitablePlaceServer( placeId : int ) -> PlaceServer | bool:
"""
Returns a suitable PlaceServer for the given placeId
:param placeId: The placeId to find a suitable PlaceServer for
:return: PlaceServer object if successful, returns False if no PlaceServer is found
"""
PlaceObj : Place = Place.query.filter_by( placeid = placeId ).first()
if PlaceObj is None:
logging.error(f"GetSuitablePlaceServer -> Failed to find Place {placeId} in database")
raise MissingData("")
HasFoundSuitablePlaceServer : bool = False
PlaceServers = PlaceServer.query.filter_by( serverPlaceId = placeId, reservedServerAccessCode = None ).all()
if PlaceServers is None or ( type(PlaceServers) == list and len(PlaceServers) == 0 ):
HasFoundSuitablePlaceServer = False
if not HasFoundSuitablePlaceServer:
for PlaceServerObject in PlaceServers:
PlaceServerObject : PlaceServer
if PlaceServerObject.maxPlayerCount <= PlaceServerObject.playerCount:
continue
if PlaceServerObject.serverRunningTime == 0:
continue
return PlaceServerObject
if HasFoundSuitablePlaceServer is False:
logging.info(f"GetSuitablePlaceServer -> Place {placeId} has no PlaceServers, attempting to start one")
try:
NewPlaceServerObj = CreateNewPlaceServer( placeId = placeId, reserved_server_access_code = None )
except:
return False
return False
GameJoinRoute = Blueprint('gamejoin', __name__, url_prefix='/')
@GameJoinRoute.route('/universes/validate-place-join', methods=['GET'])
def validateplacejoin():
return "true"
@GameJoinRoute.route('/Game/Join2012.ashx', methods=['GET'])
def Join2012():
SignedFirstTicketRaw : str = signUTF8("print('hello')", formatAutomatically=True, addNewLine=True, twelveclient=True)
Resposne = make_response(SignedFirstTicketRaw)
return Resposne
@GameJoinRoute.route('/game/validate-machine', methods=['POST'])
@csrf.exempt
def validate_machine():
try:
if request.cookies.get("t") is None:
raise Exception("Request has no 't' cookie")
macAddressesList = request.form.getlist('macAddresses')
if len(macAddressesList) > 0:
combinedAddress = ""
for macAddress in macAddressesList:
combinedAddress += macAddress
UserHWIDHash = hashlib.sha256(combinedAddress.encode("utf-8")).hexdigest()
tracking_cookie = request.cookies.get("t")
redis_controller.setex(f"hwid:{str(tracking_cookie)}", 60, UserHWIDHash)
except Exception as e:
logging.error(f"Failed during /game/validate-machine: {e}")
return jsonify({
"success": True,
"message": ""
})
@GameJoinRoute.route('/Game/MachineConfiguration.ashx', methods=['POST', 'GET'])
@csrf.exempt
def machine_configuration():
return ""
def ReturnPlaceLauncher( message : str, status : int, authenticated_userid : int = None) -> Response:
response = make_response(
jsonify({
"jobId": None,
"status": status,
"joinScriptUrl": None,
"authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
"authenticationTicket": None,
"message": message,
"rand": random.randint(0, 100000000000)
})
)
if authenticated_userid is not None and request.cookies.get(".ROBLOSECURITY") is None:
response.set_cookie(
key = ".ROBLOSECURITY",
value = auth.CreateToken( userid = authenticated_userid, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
expires = datetime.utcnow() + timedelta(days=3),
domain = f".{config.BaseDomain}"
)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
@GameJoinRoute.route('/game/PlaceLauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/Game/PlaceLauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/game/placelauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/Game/placelauncher.ashx', methods=['GET', 'POST'])
@csrf.exempt
def placelauncher():
if not websiteFeatures.GetWebsiteFeature("GameJoinAPI"):
return ReturnPlaceLauncher("GameJoinAPI is disabled", 12)
AuthenticatdUser = None
placeid = request.args.get( key = 'placeId', default = None, type = int) or request.args.get( key = 'placeid', default = None, type = int)
ticket = request.args.get( key = 't', default = None, type = str)
if ticket is None:
AuthenticatdUser : User | None = auth.GetCurrentUser()
if AuthenticatdUser is None:
return ReturnPlaceLauncher("Invalid request", 12)
if placeid is None:
return ReturnPlaceLauncher("Invalid request", 12)
requestedJobId = request.args.get( key = 'jobId', default = None, type = str) or request.args.get( key = 'jobid', default = None, type = str)
isTeleport = request.args.get( key = 'isTeleport', default = None, type = str ) == "true"
requestType = request.args.get( key = 'request', default = "RequestGame", type = str )
if request.user_agent != "Roblox/WinInet":
isTeleport = False
authticketInfo = redis_controller.get(f"authticket:{ticket}")
if authticketInfo is None and AuthenticatdUser is None:
return ReturnPlaceLauncher("Invalid authentication ticket", 12)
#UserIPHash = hashlib.md5(get_remote_address().encode("utf-8")).hexdigest()
#LoginRecords : list[LoginRecord] = LoginRecord.query.filter(LoginRecord.ip == UserIPHash).distinct(LoginRecord.userid).all()
#for record in LoginRecords:
# if record.User.accountstatus != 1:
# return ReturnPlaceLauncher("Invalid authentication ticket", 12)
userId = int(authticketInfo) if authticketInfo is not None else AuthenticatdUser.id
if PlaceServerPlayer.query.filter_by(userid=userId).first() is not None and not isTeleport:
CurrentPlaceServerPlayerObj : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(userid=userId).first()
CurrentPlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=CurrentPlaceServerPlayerObj.serveruuid).first()
try:
EvictPlayer(CurrentPlaceServerObj, userId)
except:
return ReturnPlaceLauncher("Invalid request", 12)
AssetObj : Asset = Asset.query.filter_by(id=placeid).first()
if AssetObj is None:
return ReturnPlaceLauncher("Invalid place", 14)
if AssetObj.asset_type != AssetType.Place or AssetObj.moderation_status == 2:
return ReturnPlaceLauncher("Invalid place", 14)
PlaceObj : Place = Place.query.filter_by(placeid=placeid).first()
if PlaceObj is None:
return ReturnPlaceLauncher("Invalid place", 14)
UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
if UniverseObj.is_public == False and userId != 1:
return ReturnPlaceLauncher("Invalid place", 14)
UserObj : User = User.query.filter_by(id=userId).first()
if request.cookies.get("t") is not None:
Tracking_Cookie = request.cookies.get("t")
UserHWIDHash = redis_controller.get(f"hwid:{str(Tracking_Cookie)}")
if UserHWIDHash is not None:
UserHWIDLogObject = UserHWIDLog(
user_id = UserObj.id,
hwid = UserHWIDHash
)
db.session.add(UserHWIDLogObject)
db.session.commit()
redis_controller.delete(f"hwid:{str(Tracking_Cookie)}")
UserMembershipStatus : MembershipType = GetUserMembership(UserObj)
if UniverseObj.bc_required and UserMembershipStatus == MembershipType.NonBuildersClub:
return ReturnPlaceLauncher("Builders Club required", 12)
if UniverseObj.minimum_account_age > (datetime.utcnow() - UserObj.created).days:
return ReturnPlaceLauncher("Account is too new to join this place", 12)
if requestedJobId is not None:
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by( serveruuid = requestedJobId, serverPlaceId = placeid, reservedServerAccessCode = None ).first()
if PlaceServerObj is None:
return ReturnPlaceLauncher("Invalid request", 12)
if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
return ReturnPlaceLauncher("Server is full", 6, authenticated_userid=userId)
elif requestType == "RequestPrivateGame":
server_accessCode = request.args.get( key = 'accessCode', default = None, type = str)
if server_accessCode is None:
return ReturnPlaceLauncher("Invalid request", 12)
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serverPlaceId = placeid, reservedServerAccessCode = server_accessCode ).first()
if PlaceServerObj is None:
return ReturnPlaceLauncher("Invalid request", 12)
if redis_controller.exists(f"reservedserveraccesscode:{str(PlaceServerObj.serveruuid)}:{UserObj.id}") is False:
return ReturnPlaceLauncher("Invalid request", 12)
if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
return ReturnPlaceLauncher("Server is full", 6, authenticated_userid=userId)
redis_controller.delete(f"reservedserveraccesscode:{str(PlaceServerObj.serveruuid)}:{UserObj.id}")
else:
PlaceServerObj : PlaceServer | bool = GetSuitablePlaceServer( placeId = placeid )
if PlaceServerObj is False:
logging.info(f"Placelauncher.ashx : {str(placeid)} : {UserObj.username} [{UserObj.id}] : No available place servers found yet")
return ReturnPlaceLauncher(None, 1, authenticated_userid=userId)
redis_controller.delete(f"authticket:{ticket}")
authenticatedTicketUUID = str(uuid.uuid4())
redis_controller.setex(f"place:{placeid}:ticket:{authenticatedTicketUUID}", 60, json.dumps({"id": userId, "jobid": str(PlaceServerObj.serveruuid)}))
authticket = ''.join(random.choices(string.ascii_uppercase + string.digits, k=256))
redis_controller.set(f"authticket:{authticket}", userId, 60*10)
resp = make_response(jsonify({
"jobId": PlaceServerObj.serveruuid,
"status": 2,
"joinScriptUrl": config.BaseURL + "/Game/Join.ashx?placeId=" + str(placeid) + "&jobId=" + str(PlaceServerObj.serveruuid) + "&ticket=" + authenticatedTicketUUID,
"authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
"authenticationTicket": authticket,
"message": None,
"rand": random.randint(0, 100000000000)
}))
resp.set_cookie("ticket", authenticatedTicketUUID)
if request.cookies.get(".ROBLOSECURITY") is None:
resp.set_cookie(
key = ".ROBLOSECURITY",
value = auth.CreateToken( userid = userId, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
expires = datetime.utcnow() + timedelta(days=3),
domain = f".{config.BaseDomain}"
)
return resp
@GameJoinRoute.route("/v1/join-game", methods=["POST"]) # Meant for 2020 Android
@csrf.exempt
@auth.authenticated_required_api
def gamejoin_api_v1():
if not websiteFeatures.GetWebsiteFeature("GameJoinAPI"):
return ReturnPlaceLauncher("GameJoinAPI is disabled", 12)
if not request.is_json:
return ReturnPlaceLauncher("Invalid request", 12)
if "placeId" not in request.json:
return ReturnPlaceLauncher("Invalid request", 12)
try:
requestedPlaceId : int = int(request.json["placeId"])
isTeleport : bool = request.json["isTeleport"] if "isTeleport" in request.json else False
except:
return ReturnPlaceLauncher("Invalid request", 12)
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None: # Shouldnt happen but just in case
return ReturnPlaceLauncher("Invalid request", 12)
userId = AuthenticatedUser.id
if PlaceServerPlayer.query.filter_by(userid=userId).first() is not None and not isTeleport:
CurrentPlaceServerPlayerObj : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(userid=userId).first()
CurrentPlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=CurrentPlaceServerPlayerObj.serveruuid).first()
try:
EvictPlayer(CurrentPlaceServerObj, userId)
except:
logging.error(f"/v1/join-game - Failed to evict player {userId} from place {CurrentPlaceServerObj.serverPlaceId}")
return ReturnPlaceLauncher("Invalid request", 12)
AssetObj : Asset = Asset.query.filter_by(id=requestedPlaceId).first()
if AssetObj is None:
return ReturnPlaceLauncher("Invalid place", 14)
if AssetObj.asset_type != AssetType.Place or AssetObj.moderation_status == 2:
return ReturnPlaceLauncher("Invalid place", 14)
PlaceObj : Place = Place.query.filter_by(placeid=requestedPlaceId).first()
if PlaceObj is None:
return ReturnPlaceLauncher("Invalid place", 14)
UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
if UniverseObj.is_public == False and userId != 1:
return ReturnPlaceLauncher("Invalid place", 14)
UserMembershipStatus : MembershipType = GetUserMembership(AuthenticatedUser)
if UniverseObj.bc_required and UserMembershipStatus == MembershipType.NonBuildersClub:
return ReturnPlaceLauncher("Builders Club required", 12)
if UniverseObj.minimum_account_age > (datetime.utcnow() - AuthenticatedUser.created).days:
return ReturnPlaceLauncher("Account is too new to join this place", 12)
PlaceServerObj : PlaceServer | bool = GetSuitablePlaceServer( placeId = requestedPlaceId )
if PlaceServerObj is False:
logging.info(f"/v1/join-game : {str(requestedPlaceId)} : {AuthenticatedUser.username} [{AuthenticatedUser.id}] : No available place servers found yet")
return ReturnPlaceLauncher(None, 1, authenticated_userid=userId)
ClientTicket = GenerateClientTicket(AuthenticatedUser, PlaceServerObj.serveruuid, TicketVersion = 4, PlaceId = requestedPlaceId)
SessionId : str = f"{str(uuid.uuid4())}|{str(PlaceServerObj.serveruuid)}|0|{str(PlaceServerObj.serverIP)}|8|{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')}|0|null|AAAAA"
resp = make_response(jsonify({
"jobId": PlaceServerObj.serveruuid,
"status": 2,
"authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
"authenticationTicket": "",
"message": None,
"rand": random.randint(0, 100000000000),
"joinScript": {
"ClientPort" : 0,
"MachineAddress" : PlaceServerObj.serverIP,
"ServerConnections": [
{
"Port": PlaceServerObj.serverPort,
"Address": PlaceServerObj.serverIP
}
],
"ServerPort" : PlaceServerObj.serverPort,
"PingUrl": "",
"PingInterval": 120,
"UserName": AuthenticatedUser.username,
"DisplayName": AuthenticatedUser.username,
"SeleniumTestMode": False,
"UserId": AuthenticatedUser.id,
"ClientTicket": ClientTicket,
"SuperSafeChat": False,
"PlaceId": PlaceServerObj.serverPlaceId,
"MeasurementUrl": "",
"WaitingForCharacterGuid": str(uuid.uuid4()),
"BaseUrl": config.BaseURL,
"ChatStyle": PlaceObj.chat_style.name,
"VendorId": 0,
"ScreenShotInfo": "",
"VideoInfo": "",
"CreatorId": AssetObj.creator_id,
"CreatorTypeEnum": "User" if AssetObj.creator_type == 0 else "Group",
"MembershipType": GetUserMembership(AuthenticatedUser, changeToString=True),
"AccountAge": (datetime.utcnow() - AuthenticatedUser.created).days,
"CookieStoreFirstTimePlayKey": "rbx_evt_ftp",
"CookieStoreFiveMinutePlayKey": "rbx_evt_fmp",
"CookieStoreEnabled": True,
"IsRobloxPlace": False,
"UniverseId": PlaceObj.parent_universe_id,
"GenerateTeleportJoin": False,
"IsUnknownOrUnder13": False,
"SessionId": SessionId,
"DataCenterId": 0,
"FollowUserId": 0,
"BrowserTrackerId": 0,
"UsePortraitMode": False,
"CharacterAppearance": f"http://www.syntax.eco/v1/avatar-fetch?userId={str(AuthenticatedUser.id)}&placeId={str(requestedPlaceId)}",
"GameId": PlaceServerObj.serverPlaceId,
"RobloxLocale": "en_us",
"GameLocale": "en_us",
"characterAppearanceId": AuthenticatedUser.id
}
}))
return resp
def GenerateClientTicket( UserObj : User, JobId : str, CharacterURL : str = None, CustomTimestamp : str = "", TicketVersion : int = 1, PlaceId : int = 1) -> str:
"""
Generates a client ticket so that RCC can verify the user is authenticated
If CharacterURL is not None, it will be used as the character URL instead of the default
If CustomTimestamp is not 0, it will be used as the timestamp instead of the current time
"""
if CustomTimestamp == "":
CustomTimestamp = datetime.utcnow().strftime("%m/%d/%Y %I:%M:%S %p")
if CharacterURL is None:
if TicketVersion == 2:
CharacterURL = str(UserObj.id)
elif TicketVersion == 1:
CharacterURL = Config.BaseURL + "/Asset/CharacterFetch.ashx?userId=" + str(UserObj.id) # f"http://www.syntax.eco/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(PlaceId)}"
elif TicketVersion == 4:
CharacterURL = f"http://www.syntax.eco/v1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(PlaceId)}"
FirstTicketUnsigned = f"{str(UserObj.id)}\n{UserObj.username}\n{CharacterURL}\n{JobId}\n{str(CustomTimestamp)}"
SignedFirstTicketRaw : bytes = signUTF8(FirstTicketUnsigned, formatAutomatically=False, addNewLine=False, useNewKey=(TicketVersion > 1))
SignedFirstTicket = base64.b64encode(SignedFirstTicketRaw).decode("utf-8")
AccountAge = (datetime.utcnow() - UserObj.created).days
UserMembershipType = GetUserMembership(UserObj, changeToString=True)
if TicketVersion <= 3:
SecondTicketUnsigned = f"{str(UserObj.id)}\n{str(JobId)}\n{str(CustomTimestamp)}"
elif TicketVersion == 4:
SecondTicketUnsigned = f"{CustomTimestamp}\n{JobId}\n{UserObj.id}\n{UserObj.id}\n0\n{AccountAge}\nf\n{len(UserObj.username)}\n{UserObj.username}\n{len(UserMembershipType)}\n{UserMembershipType}\n0\n\n0\n\n{len(UserObj.username)}\n{UserObj.username}"
SignedSecondTicketRaw : bytes = signUTF8(SecondTicketUnsigned, formatAutomatically=False, addNewLine=False, useNewKey=(TicketVersion > 1))
SignedSecondTicket = base64.b64encode(SignedSecondTicketRaw).decode("utf-8")
return f"{str(CustomTimestamp)};{SignedFirstTicket};{SignedSecondTicket}{f';{TicketVersion}' if TicketVersion > 1 else ''}"
@GameJoinRoute.route('/Game/Join.ashx', methods=['GET', 'POST'])
@csrf.exempt
def join():
placeid = request.args.get('placeId', default = None, type = int)
jobid = request.args.get('jobId', default = None, type = str)
ticket = request.args.get('ticket', default = None, type = str)
if placeid is None or jobid is None or ticket is None:
return 'Invalid request ( 0 )',400
ticketInfo = redis_controller.get(f"place:{placeid}:ticket:{ticket}")
if ticketInfo is None:
return 'Invalid request ( 1 )',400
ticketInfo = json.loads(ticketInfo)
if ticketInfo['jobid'] != jobid:
return 'Invalid request ( 2 )',400
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=jobid).first()
if PlaceServerObj is None:
return 'Invalid request ( 3 )',400
if PlaceServerObj.serverPlaceId != placeid:
return 'Invalid request ( 4 )',400
if PlaceServerObj.serverRunningTime == 0:
return 'Invalid request ( 5 )',400
if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
return 'Invalid request ( 6 )',400
UserObj : User = User.query.filter_by(id=ticketInfo['id']).first()
if UserObj is None:
return 'Invalid request ( 7 )',400
PlaceObj : Place = Place.query.filter_by(placeid=placeid).first()
AssetObj : Asset = Asset.query.filter_by(id=placeid).first()
UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
ClientTicket = GenerateClientTicket(UserObj, jobid, TicketVersion = 1 if UniverseObj.place_year in [PlaceYear.Sixteen, PlaceYear.Fourteen] else ( 2 if UniverseObj.place_year == PlaceYear.Eighteen else 4), PlaceId = placeid)
AuthenticationTicket = auth.CreateToken(UserObj.id, get_remote_address() , (60*60*24) )
SessionId : str = f"{str(uuid.uuid4())}|{str(PlaceServerObj.serveruuid)}|0|{str(PlaceServerObj.serverIP)}|8|{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')}|0|null|{AuthenticationTicket}"
UserMembershipStatus : str = GetUserMembership(UserObj, changeToString=True)
JoinData : str = json.dumps({
"ClientPort" : 0,
"MachineAddress" : PlaceServerObj.serverIP,
"ServerConnections": [
{
"Port": PlaceServerObj.serverPort,
"Address": PlaceServerObj.serverIP
}
],
"ServerPort" : PlaceServerObj.serverPort,
"PingUrl": "",
"PingInterval": 120,
"UserName": UserObj.username,
"DisplayName": UserObj.username,
"SeleniumTestMode": False,
"UserId": UserObj.id,
"ClientTicket": ClientTicket,
"SuperSafeChat": False,
"PlaceId": PlaceServerObj.serverPlaceId,
"MeasurementUrl": "",
"WaitingForCharacterGuid": str(uuid.uuid4()),
"BaseUrl": config.BaseURL,
"ChatStyle": PlaceObj.chat_style.name,
"VendorId": 0,
"ScreenShotInfo": "",
"VideoInfo": "",
"CreatorId": AssetObj.creator_id,
"CreatorTypeEnum": "User" if AssetObj.creator_type == 0 else "Group",
"MembershipType": UserMembershipStatus,
"AccountAge": (datetime.utcnow() - UserObj.created).days,
"CookieStoreFirstTimePlayKey": "rbx_evt_ftp",
"CookieStoreFiveMinutePlayKey": "rbx_evt_fmp",
"CookieStoreEnabled": True,
"IsRobloxPlace": False,
"UniverseId": PlaceObj.parent_universe_id,
"GenerateTeleportJoin": False,
"IsUnknownOrUnder13": False,
"SessionId": SessionId,
"DataCenterId": 0,
"FollowUserId": 0,
"BrowserTrackerId": 0,
"UsePortraitMode": False,
"CharacterAppearance": config.BaseURL + "/Asset/CharacterFetch.ashx?userId=" + str(UserObj.id) if PlaceObj.placeyear in [PlaceYear.Fourteen, PlaceYear.Sixteen] else ( f"http://www.syntax.eco/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(placeid)}" if PlaceObj.placeyear in [PlaceYear.Eighteen] else f"http://www.syntax.eco/v1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(placeid)}" ),
"GameId": PlaceServerObj.serverPlaceId,
"RobloxLocale": "en_us",
"GameLocale": "en_us",
"characterAppearanceId": UserObj.id
})
if UniverseObj.place_year == PlaceYear.Sixteen:
SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False)
elif UniverseObj.place_year == PlaceYear.Eighteen:
SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
elif UniverseObj.place_year == PlaceYear.Twenty:
SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
elif UniverseObj.place_year == PlaceYear.Fourteen:
JoinData = json.loads(JoinData)
VerificationTicket = str(uuid.uuid4())
JoinData["CharacterAppearance"] = f"{config.BaseURL}/Asset/CharacterFetch.ashx?userId={str(UserObj.id)}&t={VerificationTicket}&legacy=1"
redis_controller.set(
f"joinashx-auth:{str(jobid)}:{str(UserObj.id)}:{placeid}:{VerificationTicket}",
json.dumps({
"CharacterAppearance": JoinData["CharacterAppearance"],
"Username": JoinData["UserName"]
}),
ex = 60
)
JoinData = json.dumps(JoinData)
SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False)
elif UniverseObj.place_year == PlaceYear.TwentyOne:
SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
else:
return 'Invalid request ( 8 )',400
if UniverseObj.place_year in [ PlaceYear.Fourteen, PlaceYear.Sixteen ]:
redis_controller.set(f"allow_join:{str(UserObj.id)}:{str(placeid)}:{str(jobid)}", "1", ex=120)
joinResposne = make_response(SignedJoinData)
joinResposne.headers['Content-Type'] = 'text/plain'
joinResposne.set_cookie(
key = "Syntax-Session-Id",
value = SessionId,
expires = datetime.utcnow() + timedelta(days=1),
domain = f".{config.BaseDomain}"
)
if request.cookies.get(".ROBLOSECURITY") is None: # 2018 and 2020 doesen't want to authenticate properly with negotiate.ashx :(, so this is my hack till i can figure out why
joinResposne.set_cookie(
key = ".ROBLOSECURITY",
value = auth.CreateToken( userid = UserObj.id, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
expires = datetime.utcnow() + timedelta(days=3),
domain = f".{config.BaseDomain}"
)
return joinResposne