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
+70
View File
@@ -0,0 +1,70 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify, make_response
from app.util import auth
from app.extensions import db, csrf, limiter
from flask_wtf.csrf import CSRFError, generate_csrf
from app.models.user_email import UserEmail
from app.models.user import User
AccountSettingsAPIRoute = Blueprint('accountsettingsapi', __name__, url_prefix='/')
csrf.exempt(AccountSettingsAPIRoute)
@AccountSettingsAPIRoute.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@AccountSettingsAPIRoute.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@AccountSettingsAPIRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
@AccountSettingsAPIRoute.route("/v1/email", methods=["GET"])
@auth.authenticated_required_api
@limiter.limit("60/minute")
def get_email_status():
AuthenticatedUser : User = auth.GetCurrentUser()
UserEmailObject : UserEmail = UserEmail.query.filter_by(user_id=AuthenticatedUser.id).first()
HiddenEmail = None
if UserEmailObject:
emailParts = UserEmailObject.email.split("@")
FirstPart = emailParts[0][0] + "*" * (len(emailParts[0])-1)
SecondPart = emailParts[1]
HiddenEmail = FirstPart + "@" + SecondPart
return jsonify({
"emailAddress": HiddenEmail,
"verified": UserEmailObject.verified if UserEmailObject else False,
"canBypassPasswordForEmailUpdate": True
})
@AccountSettingsAPIRoute.route("/v1/themes/<consumerType>/<int:consumerId>", methods=["GET"])
@auth.authenticated_required_api
@limiter.limit("60/minute")
def get_consumer_theme( consumerType : str, consumerId : int ):
return jsonify({
"themeType": "Dark"
})
+1304
View File
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, after_this_request
from config import Config
from app.models.user import User
from app.extensions import redis_controller, get_remote_address, csrf, limiter
from app.util import auth, websiteFeatures
import string
from datetime import datetime, timedelta
import secrets
import logging
from sqlalchemy import func
from app.pages.login.login import CreateLoginRecord
config = Config()
AuthenticationRoute = Blueprint('authentication', __name__, url_prefix='/')
@AuthenticationRoute.route('/Login/Negotiate.ashx', methods=['GET'])
def loginNegotiate():
AuthenticationTicket = request.args.get(
key = 'suggest',
default = None,
type = str
)
if AuthenticationTicket is None:
return 'Invalid request',400
authticketInfo = redis_controller.get(f"authticket:{AuthenticationTicket}")
if authticketInfo is None:
return 'Invalid request',400
redis_controller.delete(f"authticket:{AuthenticationTicket}")
userId = int(authticketInfo)
newAuthToken = auth.CreateToken(userId, get_remote_address())
resp = make_response(newAuthToken, 200)
resp.headers["Content-Type"] = "text/plain"
resp.set_cookie(".ROBLOSECURITY", newAuthToken, expires=datetime.utcnow() + timedelta(days=365), domain=f".{config.BaseDomain}")
return resp
@AuthenticationRoute.route('/v2/twostepverification/verify', methods=['POST'])
@limiter.limit("12/minute")
@csrf.exempt
def verifyTwoStep():
if not request.is_json:
return jsonify( { "errors": [ { "code": 1, "message": "Invalid request." } ] } ), 400
try:
assert "username" in request.json, "Missing parameter: username"
assert "code" in request.json, "Missing parameter: code"
assert "ticket" in request.json, "Missing parameter: ticket"
assert isinstance(request.json["username"], str), "Invalid parameter type: username, expected string"
assert isinstance(request.json["code"], int), "Invalid parameter type: code, expected integer"
assert isinstance(request.json["ticket"], str), "Invalid parameter type: ticket, expected string"
except Exception as e:
return jsonify( { "errors": [ { "code": 1, "message": f"Validation failed, {str(e)}" } ] } ), 400
GivenUsername : str = request.json["username"]
GivenTwoStepCode : int = request.json["code"]
GivenTicket : str = request.json["ticket"]
if redis_controller.exists(f"twofactorticket:{GivenTicket}") == 0:
return jsonify( { "errors": [ { "code": 5, "message": "Invalid two step verification ticket." } ] } ), 400
TwoFactorSessionTicketInfo = redis_controller.get(f"twofactorticket:{GivenTicket}")
if TwoFactorSessionTicketInfo is None:
return jsonify( { "errors": [ { "code": 5, "message": "Invalid two step verification ticket." } ] } ), 400
redis_controller.delete(f"twofactorticket:{GivenTicket}")
UserId = int(TwoFactorSessionTicketInfo)
Validated2FA = auth.Validate2FACode(UserId, GivenTwoStepCode)
if not Validated2FA:
return jsonify( { "errors": [ { "code": 6, "message": "The code is invalid." } ] } ), 400
if Validated2FA:
Response = make_response(jsonify({}))
sessionToken = auth.CreateToken(userid=UserId, ip=get_remote_address())
Response.set_cookie(".ROBLOSECURITY", sessionToken, expires=datetime.utcnow() + timedelta(days=365), domain=f".{config.BaseDomain}")
return Response
@AuthenticationRoute.route('/Login/NewAuthTicket', methods=['POST'])
@csrf.exempt
@auth.authenticated_required_api
def loginNewAuthTicket():
userId = auth.GetCurrentUser().id
authticket = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(256))
redis_controller.set(f"authticket:{authticket}", userId, 60*10)
return authticket,200
@AuthenticationRoute.route('/game/GetCurrentUser.ashx', methods=['GET'])
def gameGetCurrentUser():
AuthenticatedUser = auth.GetCurrentUser()
if AuthenticatedUser is None:
return "Bad Request", 200
return str(AuthenticatedUser.id), 200
@AuthenticationRoute.route('/login/RequestAuth.ashx', methods=['GET'])
@limiter.limit("6/minute")
def loginRequestAuth():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return "User is not authorized.", 401
NewAuthTicket = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(256))
redis_controller.set(f"authticket:{NewAuthTicket}", AuthenticatedUser.id, 60*10)
resp = make_response(
f"{config.BaseURL}/Login/Negotiate.ashx?suggest={NewAuthTicket}",
200
)
resp.headers["Content-Type"] = "text/plain"
return resp
@AuthenticationRoute.route('/game/logout.aspx')
@auth.authenticated_required
def gameLogout():
auth.invalidateToken(request.cookies.get(".ROBLOSECURITY"))
resp = make_response(redirect("/login"))
resp.set_cookie(".ROBLOSECURITY", "", expires=0)
return resp
@AuthenticationRoute.route('/v2/login', methods=['POST'])
@limiter.limit("12/minute")
@csrf.exempt
def LoginRoute():
loginEnabled = websiteFeatures.GetWebsiteFeature("WebLogin")
if not loginEnabled:
return jsonify( { "errors": [ { "code": 11, "message": "Service unavailable. Please try again." } ] } ), 503
if not request.is_json:
return abort(400)
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser and request.user_agent.string != "RobloxStudio/WinInet RobloxApp/0.450.0.411923 (GlobalDist; RobloxDirectDownload)":
return redirect("/home")
if AuthenticatedUser and request.user_agent.string == "RobloxStudio/WinInet RobloxApp/0.450.0.411923 (GlobalDist; RobloxDirectDownload)":
Response = make_response(jsonify({
"username": AuthenticatedUser.username,
"isUnder13": False,
"userId": AuthenticatedUser.id,
"countryCode": "US",
"membershipType": 4,
"displayName": AuthenticatedUser.username
}))
return Response
if ( "username" not in request.json and "cvalue" not in request.json ) or "password" not in request.json:
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
if "username" in request.json:
Username = str(request.json["username"])
else:
Username = str(request.json["cvalue"])
Password = str(request.json["password"])
UserObject : User = User.query.filter(func.lower(User.username) == func.lower(Username)).first()
if not UserObject:
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
if UserObject.accountstatus != 1:
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
ActualPassword = Password
if UserObject.TOTPEnabled:
if request.user_agent.string == "RobloxStudio/WinInet RobloxApp/0.450.0.411923 (GlobalDist; RobloxDirectDownload)":
if not auth.VerifyPassword(UserObject, ActualPassword):
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
twofactorticket = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(60))
redis_controller.set(f"twofactorticket:{twofactorticket}", UserObject.id, 60*10)
return jsonify({"user": {
"id": UserObject.id,
"name": UserObject.username,
"displayName": UserObject.username
}, "twoStepVerificationData": {
"mediaType": "Email",
"ticket": twofactorticket
}, "identityVerificationLoginTicket": twofactorticket })
if ";" not in Password:
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
ActualPassword = Password.split(";")[0]
TOTPCode = Password.split(";")[1]
if not auth.Validate2FACode(UserObject.id, TOTPCode):
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
if not auth.VerifyPassword(UserObject, ActualPassword):
return jsonify( { "errors": [ { "code": 1, "message": "Incorrect username or password. Please try again." } ] } ), 403
sessionToken = auth.CreateToken(userid=UserObject.id, ip=get_remote_address())
Response = make_response(jsonify({
"username": UserObject.username,
"isUnder13": False,
"userId": UserObject.id,
"countryCode": "US",
"membershipType": 4,
"displayName": UserObject.username
}))
Response.set_cookie(".ROBLOSECURITY", sessionToken, expires=datetime.utcnow() + timedelta(days=365), domain=f".{config.BaseDomain}")
CreateLoginRecord( UserObject.id )
#logging.info(f"/v2/login - {UserObject.username} ({UserObject.id}) [{request.user_agent}] logged in successfully")
return Response
@AuthenticationRoute.route("/v1/logout", methods=["POST"])
@AuthenticationRoute.route("/v2/logout", methods=["POST"])
@auth.authenticated_required_api
@csrf.exempt
def LogoutRoute():
auth.invalidateToken(request.cookies.get(".ROBLOSECURITY"))
resp = make_response(jsonify({}))
resp.set_cookie(".ROBLOSECURITY", "", expires=0)
return resp
@AuthenticationRoute.route("/v2/passwords/current-status", methods=["GET"])
@auth.authenticated_required_api
def PasswordsCurrentStatusRoute():
return jsonify({"valid": True})
File diff suppressed because one or more lines are too long
+343
View File
@@ -0,0 +1,343 @@
#badges.roblox.com
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify, make_response, after_this_request, Response
from app.util import auth
from app.extensions import db, csrf, limiter, redis_controller
from flask_wtf.csrf import CSRFError, generate_csrf
from app.models.user import User
from app.models.groups import Group
from app.models.place import Place
from app.models.userassets import UserAsset
from app.models.place_badge import PlaceBadge, UserBadge
from app.models.asset import Asset
from app.models.game_session_log import GameSessionLog
from app.models.placeserver_players import PlaceServerPlayer
from app.models.placeservers import PlaceServer
from app.models.universe import Universe
from datetime import datetime, timedelta
from sqlalchemy import and_
BadgesAPIRoute = Blueprint( 'BadgesAPIRoute', __name__, url_prefix='/')
def CalculateBadgeRarity( badge_id : int, bypass_cache : bool = False ) -> float:
"""
Badges have a rarity value that is influenced by the ratio of the number of users in the past 24 hours
to achieve the badge to the total number of players that have joined the experience within the same timespan;
for example, if 99,900 of 100,000 daily visitors receive a badge, its difficulty will likely be Freebie (99.9%),
while if only 100 players get a badge in the same experience, its difficulty will appear as Impossible (0.1%).
https://roblox.fandom.com/wiki/Player_badge
"""
def _calculate_rarity() -> float:
BadgeObj : PlaceBadge = PlaceBadge.query.filter_by( id = badge_id ).first()
if BadgeObj is None:
return 0.0
PlaceObj : Place = Place.query.filter_by( placeid = BadgeObj.associated_place_id ).first()
UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
AwardedRecentlyCount : int = UserBadge.query.filter_by( badge_id = badge_id ).filter( UserBadge.awarded_at > datetime.utcnow() - timedelta( hours = 24 ) ).count()
TotalPlayedRecentlyCount : int = GameSessionLog.query.filter_by( place_id = UniverseObj.root_place_id ).filter( GameSessionLog.joined_at > datetime.utcnow() - timedelta( hours = 24 ) ).distinct( GameSessionLog.user_id ).count()
if TotalPlayedRecentlyCount == 0 or AwardedRecentlyCount == 0:
return 0.0
return round( AwardedRecentlyCount / TotalPlayedRecentlyCount, 3 )
CacheRedisKey = f"badge_rarity_{badge_id}"
if not bypass_cache:
CachedValue = redis_controller.get( CacheRedisKey )
if CachedValue is not None:
return float( CachedValue )
RarityValue = _calculate_rarity()
redis_controller.set( CacheRedisKey, str(RarityValue), ex = 60 )
return RarityValue
def GetBadgeAwardedPastDay( badge_id ) -> int:
timenow = datetime.utcnow()
start_of_yesterday = datetime(timenow.year, timenow.month, timenow.day) - timedelta(days=1)
end_of_yesterday = datetime(timenow.year, timenow.month, timenow.day) - timedelta(seconds=1)
return UserBadge.query.filter_by( badge_id = badge_id ).filter( and_( UserBadge.awarded_at > start_of_yesterday, UserBadge.awarded_at < end_of_yesterday ) ).count()
class UserAlreadyHasBadgeException( Exception ):
pass
def AwardBadgeToUser( badge_id : int, user_obj : User ) -> UserBadge:
""" Award a badge to a user. """
BadgeObj : PlaceBadge | None = PlaceBadge.query.filter_by( id = badge_id ).first()
if BadgeObj is None:
raise ValueError("Badge does not exist.")
if UserBadge.query.filter_by( badge_id = badge_id, user_id = user_obj.id ).first() is not None:
raise UserAlreadyHasBadgeException("User already has badge.")
BadgeAwarded = UserBadge( badge_id = badge_id, user_id = user_obj.id )
db.session.add( BadgeAwarded )
try:
if BadgeObj.asset_reward is not None:
UserAssetObj : UserAsset | None = UserAsset.query.filter_by( userid = user_obj.id, assetid = BadgeObj.asset_reward ).first()
if UserAssetObj is None:
AssetObj : Asset | None = Asset.query.filter_by( id = BadgeObj.asset_reward ).first()
if AssetObj is None:
raise ValueError(f"Asset [{BadgeObj.asset_reward}] does not exist for badge {BadgeObj.id}")
UserAssetObj = UserAsset( userid = user_obj.id, assetid = BadgeObj.asset_reward )
db.session.add( UserAssetObj )
except Exception as e:
logging.error(f"Error attempting to give user [{user_obj.id}] badge award [{badge_id}]: {e}")
db.session.commit()
return BadgeAwarded
def GetAssetCreator( asset_obj : Asset ) -> User | Group | None:
if asset_obj.creator_type == 0:
return User.query.filter_by( id = asset_obj.creator_id ).first()
elif asset_obj.creator_type == 1:
return Group.query.filter_by( id = asset_obj.creator_id ).first()
return None
csrf.exempt(BadgesAPIRoute)
@BadgesAPIRoute.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@BadgesAPIRoute.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({"errors": [{"code": 9,"message": "The flood limit has been exceeded."}]}), 429
@BadgesAPIRoute.errorhandler(405)
def handle_method_not_allowed(e):
return jsonify({"errors": [{"code": 0,"message": "MethodNotAllowed"}]}), 405
@BadgesAPIRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
@BadgesAPIRoute.route('/v1/badges/<int:badge_id>', methods=['GET'])
@limiter.limit("60/minute")
def get_badge_info( badge_id : int ):
BadgeObj : PlaceBadge = PlaceBadge.query.filter_by( id = badge_id ).first()
if BadgeObj is None:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
UniverseObj : Universe = Universe.query.filter_by( id = BadgeObj.universe_id ).first()
BadgeAwardedCount : int = UserBadge.query.filter_by( badge_id = badge_id ).count()
PastAwardedCount : int = UserBadge.query.filter_by( badge_id = badge_id ).filter( UserBadge.awarded_at > datetime.utcnow() - timedelta( hours = 24 ) ).count()
AssociatedPlaceAsset : Asset = Asset.query.filter_by( id = UniverseObj.root_place_id ).first()
BadgeWinRatePercentage : float = CalculateBadgeRarity( badge_id = badge_id )
return jsonify({
"id": BadgeObj.id,
"name": BadgeObj.name,
"description": BadgeObj.description,
"displayName": BadgeObj.name,
"displayDescription": BadgeObj.description,
"enabled": BadgeObj.enabled,
"iconImageId": BadgeObj.icon_image_id,
"displayIconImageId": BadgeObj.icon_image_id,
"created": BadgeObj.created_at.isoformat(),
"updated": BadgeObj.updated_at.isoformat(),
"statistics": {
"pastAwardCount": PastAwardedCount,
"awardCount": BadgeAwardedCount,
"winRatePercentage": BadgeWinRatePercentage
},
"awardingUniverse": {
"id": UniverseObj.id,
"name": AssociatedPlaceAsset.name,
"rootPlaceId": UniverseObj.root_place_id
}
})
# This endpoint should only be used by 2020 games, which should always have "AccessKey" in the request headers unlike 2014
@BadgesAPIRoute.route('/v1/users/<int:user_id>/badges/<int:badge_id>/award-badge', methods=["POST"])
@auth.gameserver_accesskey_required
def server_award_badge_route( user_id : int, badge_id : int ):
BadgeObj : PlaceBadge = PlaceBadge.query.filter_by( id = badge_id ).first()
if BadgeObj is None:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
UserObj : User | None = User.query.filter_by( id = user_id ).first()
if UserObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is invalid or does not exist."}]}), 404
PlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by( userid = user_id ).first()
if PlaceServerPlayerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by( serveruuid = PlaceServerPlayerObj.serveruuid ).first()
# This should never happen, but just in case
if PlaceServerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
ServerPlaceObj : Place = Place.query.filter_by( placeid = PlaceServerObj.serverPlaceId ).first()
if ServerPlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
RequestPlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int )
if RequestPlaceId is None:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
PlaceObj : Place = Place.query.filter_by( placeid = RequestPlaceId ).first()
if PlaceObj is None:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
if PlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
try:
AwardBadgeToUser( badge_id = badge_id, user_obj = UserObj )
except UserAlreadyHasBadgeException:
return jsonify({"errors": [{"code": 1,"message": "User already has badge."}]}), 400
except ValueError:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
except Exception as e:
logging.error(f"Error awarding badge to user: {e}")
return jsonify({"errors": [{"code": 1,"message": "Internal Server error."}]}), 500
return jsonify({ "success": True })
# 2018 RCC
@BadgesAPIRoute.route('/assets/award-badge', methods=["POST"])
@auth.gameserver_accesskey_required
def server_award_badge_route_legacy():
reqUserId = request.args.get("userId", type=int, default=None)
reqBadgeId = request.args.get("badgeId", type=int, default=None)
reqPlaceId = request.args.get("placeId", type=int, default=None)
if reqUserId is None or reqBadgeId is None or reqPlaceId is None:
return jsonify({"errors": [{"code": 1,"message": "Invalid request."}]}), 400
BadgeObj : PlaceBadge = PlaceBadge.query.filter_by( id = reqBadgeId ).first()
if BadgeObj is None:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
UserObj : User | None = User.query.filter_by( id = reqUserId ).first()
if UserObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is invalid or does not exist."}]}), 404
PlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by( userid = reqUserId ).first()
if PlaceServerPlayerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by( serveruuid = PlaceServerPlayerObj.serveruuid ).first()
# This should never happen, but just in case
if PlaceServerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
ServerPlaceObj : Place = Place.query.filter_by( placeid = PlaceServerObj.serverPlaceId ).first()
if ServerPlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
RequestPlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int )
if RequestPlaceId is None:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
if RequestPlaceId != reqPlaceId:
return jsonify({"errors": [{"code": 1,"message": f"Roblox-Place-Id [{RequestPlaceId}] Header does not match placeId argument [{reqPlaceId}]"}]}), 400
PlaceObj : Place = Place.query.filter_by( placeid = RequestPlaceId ).first()
if PlaceObj is None:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
if PlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
try:
AwardBadgeToUser( badge_id = reqBadgeId, user_obj = UserObj )
except UserAlreadyHasBadgeException:
return jsonify({"errors": [{"code": 1,"message": "User already has badge."}]}), 400
except ValueError:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
except Exception as e:
logging.error(f"Error awarding badge to user: {e}")
return jsonify({"errors": [{"code": 1,"message": "Internal Server error."}]}), 500
PlaceAssetObj : Asset = Asset.query.filter_by( id = PlaceObj.placeid ).first()
PlaceCreator : User | Group = GetAssetCreator( PlaceAssetObj )
return f"{UserObj.username} won {PlaceCreator.username if PlaceAssetObj.creator_type == 0 else PlaceCreator.name}'s \"{BadgeObj.name}\" award!" # This message is sent to client to be shown as a badge awarded notification
@BadgesAPIRoute.route("/Game/Badge/HasBadge.ashx", methods=["GET"])
@auth.gameserver_authenticated_required # Only IP address is checked
def query_user_has_badge():
reqUserId = request.args.get("UserID", type=int, default=None)
reqBadgeId = request.args.get("BadgeID", type=int, default=None)
if reqUserId is None or reqBadgeId is None:
return "0"
BadgeObj : PlaceBadge | None = PlaceBadge.query.filter_by( id = reqBadgeId ).first()
if BadgeObj is None:
return "0"
UserObj : User | None = User.query.filter_by( id = reqUserId ).first()
if UserObj is None:
return "0"
if UserBadge.query.filter_by( badge_id = reqBadgeId, user_id = reqUserId ).first() is None:
return "0"
return "1"
@BadgesAPIRoute.route("/Game/Badge/AwardBadge.ashx", methods=["POST"])
@auth.gameserver_authenticated_required
def award_badge_to_user():
reqUserId = request.args.get("UserID", type=int, default=None)
reqBadgeId = request.args.get("BadgeID", type=int, default=None)
reqPlaceId = request.args.get("PlaceID", type=int, default=None)
if reqUserId is None or reqBadgeId is None or reqPlaceId is None:
return jsonify({"errors": [{"code": 1,"message": "Invalid request."}]}), 400
BadgeObj : PlaceBadge = PlaceBadge.query.filter_by( id = reqBadgeId ).first()
if BadgeObj is None:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
UserObj : User | None = User.query.filter_by( id = reqUserId ).first()
if UserObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is invalid or does not exist."}]}), 404
PlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by( userid = reqUserId ).first()
if PlaceServerPlayerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by( serveruuid = PlaceServerPlayerObj.serveruuid ).first()
# This should never happen, but just in case
if PlaceServerObj is None:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
ServerPlaceObj : Place = Place.query.filter_by( placeid = PlaceServerObj.serverPlaceId ).first()
if ServerPlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "User is not in the game."}]}), 400
PlaceObj : Place = Place.query.filter_by( placeid = reqPlaceId ).first()
if PlaceObj is None:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
if PlaceObj.parent_universe_id != BadgeObj.universe_id:
return jsonify({"errors": [{"code": 1,"message": "Place ID is invalid or does not exist."}]}), 404
try:
AwardBadgeToUser( badge_id = reqBadgeId, user_obj = UserObj )
except UserAlreadyHasBadgeException:
return jsonify({"errors": [{"code": 1,"message": "User already has badge."}]}), 400
except ValueError:
return jsonify({"errors": [{"code": 1,"message": "Badge is invalid or does not exist."}]}), 404
except Exception as e:
logging.error(f"Error awarding badge to user: {e}")
return jsonify({"errors": [{"code": 1,"message": "Internal Server error."}]}), 500
PlaceAssetObj : Asset = Asset.query.filter_by( id = PlaceObj.placeid ).first()
PlaceCreator : User | Group = GetAssetCreator( PlaceAssetObj )
return f"{UserObj.username} won {PlaceCreator.username if PlaceAssetObj.creator_type == 0 else PlaceCreator.name}'s \"{BadgeObj.name}\" award!" # This message is sent to client to be shown as a badge awarded notification
+7
View File
@@ -0,0 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
BootstrapperRoute = Blueprint('BootstrapperRoute', __name__, template_folder='pages')
@BootstrapperRoute.route("/install/GetInstallerCdns.ashx", methods=["GET"])
def getinstallercdns():
return "https://setup.vortexi.cc/"
File diff suppressed because one or more lines are too long
+383
View File
@@ -0,0 +1,383 @@
import base64
import hashlib
import requests
import json
import string
import secrets
import logging
import redis_lock
from flask import request, Blueprint, jsonify, make_response, abort, redirect, render_template
from functools import wraps
from datetime import datetime, timedelta
from flask_wtf.csrf import CSRFError
from config import Config
from app.models.cryptomus_invoice import CryptomusInvoice
from app.models.user import User
from app.models.giftcard_key import GiftcardKey
from app.enums.GiftcardType import GiftcardType
from app.enums.CryptomusPaymentStatus import CryptomusPaymentStatus
from app.util import auth
from app.extensions import get_remote_address, db, csrf, user_limiter, redis_controller, limiter
config = Config()
CryptomusHandler = Blueprint('CryptomusHandler', __name__, url_prefix='/cryptomus_service')
StatusStringToEnum = {
"paid": CryptomusPaymentStatus.Paid,
"paid_over": CryptomusPaymentStatus.PaidOver,
"wrong_amount": CryptomusPaymentStatus.WrongAmount,
"process": CryptomusPaymentStatus.Process,
"confirm_check": CryptomusPaymentStatus.ConfirmCheck,
"wrong_amount_waiting": CryptomusPaymentStatus.WrongAmountWaiting,
"check": CryptomusPaymentStatus.Check,
"fail": CryptomusPaymentStatus.Fail,
"cancel": CryptomusPaymentStatus.Cancel,
"system_fail": CryptomusPaymentStatus.SystemFail,
"refund_process": CryptomusPaymentStatus.RefundProcess,
"refund_fail": CryptomusPaymentStatus.RefundFail,
"refund_paid": CryptomusPaymentStatus.RefundPaid,
"locked": CryptomusPaymentStatus.Locked
}
def GenerateSignature( PayloadData : str = "" ) -> str:
"""
Generates a signature for the given payload data
:param PayloadData: The payload data to sign
:returns: str
"""
assert isinstance( PayloadData, str ), "PayloadData must be a string"
return hashlib.md5(
(
base64.b64encode( PayloadData.encode( "utf-8" ) ).decode( "utf-8" ) + config.CRYPTOMUS_API_KEY
).encode( "utf-8" )
).hexdigest()
def VerifySignature( PayloadData : str, Signature : str ) -> bool:
"""
Verifies the signature of the given payload data
:param PayloadData: The payload data to verify
:param Signature: The signature to verify
:returns: bool
"""
assert isinstance( PayloadData, str ), "PayloadData must be a string"
assert isinstance( Signature, str ), "Signature must be a string"
return GenerateSignature( PayloadData ) == Signature
def PerformRequest( Endpoint : str = "/", RequestMethod : str = "GET", PayloadData : dict | list | None = None, RequestTimeout : int = 20 ) -> requests.Response:
"""
Performs a request to the Cryptomus API
:param Endpoint: The endpoint to send the request to
:param RequestMethod: The request method to use
:param PayloadData: The payload data to send with the request on POST requests
:param RequestTimeout: The amount of time before the request times out
:returns: requests.Response
"""
assert isinstance( Endpoint, str ), "Endpoint must be a string"
assert isinstance( RequestMethod, str ), "RequestMethod must be a string"
assert RequestMethod in ["GET", "POST"], "RequestMethod must be either GET or POST"
assert isinstance( PayloadData, (dict, list, type(None)) ), "PayloadData must be a dictionary, list or None"
assert isinstance( RequestTimeout, int ), "RequestTimeout must be an integer"
headers = {
"Content-Type": "application/json",
"merchant": config.CRYPTOMUS_MERCHANT_ID,
}
if RequestMethod == "GET":
headers.update({
"sign": GenerateSignature()
})
return requests.get(
url = f"{config.CRYPTOMUS_API_BASEURL}{Endpoint}",
headers = headers,
timeout = RequestTimeout
)
else:
headers.update({
"sign": GenerateSignature( json.dumps( PayloadData ) )
})
return requests.post(
url = f"{config.CRYPTOMUS_API_BASEURL}{Endpoint}",
headers = headers,
data = json.dumps( PayloadData ),
timeout = RequestTimeout
)
def CryptomusSignatureRequired(f):
"""
Decorator to require a valid Cryptomus signature for a request
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if get_remote_address() != "91.227.144.54":
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Invalid remote address: {get_remote_address()}")
abort(404)
if not request.is_json:
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Request is not JSON")
abort(400)
PayloadData = request.get_json()
if "sign" not in PayloadData:
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > sign not found in payload")
abort(400)
# PayloadSignature = PayloadData["sign"]
# del PayloadData["sign"]
# if not VerifySignature( json.dumps( PayloadData ), PayloadSignature ):
# logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Invalid signature")
# abort(400)
return f(*args, **kwargs)
return decorated_function
def GenerateInvoiceID():
"""
Generates a new invoice ID
:returns: str
"""
NewInvoiceID = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(64))
if CryptomusInvoice.query.filter_by( id = NewInvoiceID ).first() is not None:
# This will never happen, but we are handling money here so we need to be sure
return GenerateInvoiceID()
return NewInvoiceID
def ConvertStringStatusToEnum( Status : str ) -> CryptomusPaymentStatus:
"""
Converts a string status to a CryptomusPaymentStatus enum
:param Status: The status to convert
:returns: CryptomusPaymentStatus
"""
assert Status in StatusStringToEnum, "Invalid status"
return StatusStringToEnum[Status]
@CryptomusHandler.errorhandler( 429 )
def RateLimitExceeded( e ):
"""
Rate limit exceeded error handler
"""
return jsonify({ "status": "error", "message": "Rate limit exceeded" }), 429
@CryptomusHandler.errorhandler( CSRFError )
def CSRFError( e ):
"""
CSRF error handler
"""
return jsonify({ "status": "error", "message": "Invalid CSRF Token" }), 400
@CryptomusHandler.route("/invoice_status_callback/<invoice_id>", methods=["POST"])
@CryptomusSignatureRequired
@csrf.exempt
def InvoiceStatusCallback( invoice_id : str ):
"""
Callback for invoice status updates
:param invoice_id: The invoice ID to update the status for
"""
CryptomusInvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if CryptomusInvoiceObj is None:
logging.warning(f"cryptomus_handler > InvoiceStatusCallback > Invoice not found: {invoice_id}")
return jsonify({ "status": "error", "message": "Invoice not found" }), 404
PayloadData = request.get_json()
try:
assert isinstance( PayloadData, dict ), "PayloadData must be a dictionary"
assert "status" in PayloadData, "Status not found in payload"
assert "payment_amount_usd" in PayloadData, "payment_amount_usd not found in payload"
assert "is_final" in PayloadData, "is_final not found in payload"
assert isinstance( PayloadData["payment_amount_usd"], str ), "payment_amount_usd must be a string"
assert isinstance( PayloadData["is_final"], bool ), "is_final must be a boolean"
assert PayloadData["status"] in StatusStringToEnum, "Invalid status"
except Exception as e:
logging.error(f"cryptomus_handler > InvoiceStatusCallback > Validation failed: {e}")
return jsonify({ "status": "error", "message": f"Validation failed: {e}" }), 500
CryptomusInvoiceObj.status = StatusStringToEnum[PayloadData["status"]]
CryptomusInvoiceObj.paid_amount_usd = float(PayloadData["payment_amount_usd"])
CryptomusInvoiceObj.is_final = PayloadData["is_final"]
CryptomusInvoiceObj.updated_at = datetime.utcnow()
db.session.commit()
def GenerateCode():
Code = ""
for i in range(0, 5):
Chunk = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(5))
Code += Chunk
if i != 4:
Code += "-"
return Code
if CryptomusInvoiceObj.status in [ CryptomusPaymentStatus.Paid, CryptomusPaymentStatus.PaidOver ] and CryptomusInvoiceObj.assigned_key is None and CryptomusInvoiceObj.extra_data == "membership":
NewGiftcardCode : str = GenerateCode()
NewGiftcardObj : GiftcardKey = GiftcardKey(
key = NewGiftcardCode,
type = GiftcardType.Outrageous_BuildersClub,
value = 1
)
db.session.add(NewGiftcardObj)
db.session.commit()
CryptomusInvoiceObj.assigned_key = NewGiftcardObj.key
db.session.commit()
return jsonify({ "status": "success" }), 200
@CryptomusHandler.route("/payment_cancelled/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def PaymentCancelled( invoice_id : str ):
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect(f"/cryptomus_service/view_payment/{invoice_id}")
@CryptomusHandler.route("/payment_success/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def PaymentSuccess( invoice_id : str ):
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect(f"/cryptomus_service/view_payment/{invoice_id}")
@CryptomusHandler.route("/create_payment/membership", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
@user_limiter.limit("5/minute")
def user_create_payment():
AuthenticatedUser : User = auth.GetCurrentUser()
try:
with redis_lock.Lock(
redis_client = redis_controller,
name = f"cryptomus_handler:user_create_payment:{AuthenticatedUser.id}",
expire = 30,
auto_renewal = True
) as lock:
ActiveUserInvoices : int = CryptomusInvoice.query.filter_by( initiator_id = AuthenticatedUser.id ).filter(
CryptomusInvoice.created_at > datetime.now() - timedelta( minutes = 30 )
).count(
)
if ActiveUserInvoices >= 10:
return jsonify({ "status": "error", "message": "You have created too many payments recently" }), 403
InvoiceOrderID = GenerateInvoiceID()
try:
InvoiceCreateRequest = PerformRequest(
Endpoint = "/v1/payment",
RequestMethod = "POST",
PayloadData = {
"amount": "5",
"currency": "USD",
"order_id": InvoiceOrderID,
"url_return": f"{config.BaseURL}/cryptomus_service/payment_cancelled/{InvoiceOrderID}",
"url_callback": f"{config.BaseURL}/cryptomus_service/invoice_status_callback/{InvoiceOrderID}",
"url_success": f"{config.BaseURL}/cryptomus_service/payment_success/{InvoiceOrderID}",
"is_payment_multiple": True,
"lifetime": 60 * 60 * 12, # 12 hours
"additional_data": "membership"
}
)
except requests.exceptions.RequestException as e:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {e}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
except Exception as e:
logging.error(f"cryptomus_handler > user_create_payment > Unknown error: {e}")
return jsonify({ "status": "error", "message": "Unknown error" }), 500
if InvoiceCreateRequest.status_code != 200:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {InvoiceCreateRequest.status_code}, {InvoiceCreateRequest.text}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
InvoiceCreateResponse = InvoiceCreateRequest.json()
try:
assert "state" in InvoiceCreateResponse, "state not found in response"
assert isinstance( InvoiceCreateResponse["state"], int ), "state must be a integer"
assert "result" in InvoiceCreateResponse, "result not found in response"
assert isinstance( InvoiceCreateResponse["result"], dict ), "result must be a dictionary"
except AssertionError as e:
logging.error(f"cryptomus_handler > user_create_payment > Validation failed: {e}")
return jsonify({ "status": "error", "message": f"Request failed" }), 500
if InvoiceCreateResponse["state"] != 0:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {InvoiceCreateResponse['result']}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
InvoiceResult : dict = InvoiceCreateResponse["result"]
NewCryptomusInvoice = CryptomusInvoice(
id = InvoiceOrderID,
cryptomus_invoice_id = InvoiceResult["uuid"],
initiator_id = AuthenticatedUser.id,
required_amount = 5,
currency = "USD",
status = ConvertStringStatusToEnum( InvoiceResult["status"] ),
expires_at = datetime.utcnow() + timedelta( hours = 12 ),
extra_data = "membership"
)
db.session.add(NewCryptomusInvoice)
db.session.commit()
return jsonify({ "status": "success", "invoice_id": InvoiceOrderID, "cryptomus_invoice_id": InvoiceResult["uuid"], "payment_url": InvoiceResult["url"] }), 200
except AssertionError:
abort( 429 )
@CryptomusHandler.route("/pay_invoice/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def pay_invoice( invoice_id : str ):
AuthenticatedUser : User = auth.GetCurrentUser()
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect( f"https://pay.cryptomus.com/pay/{InvoiceObj.cryptomus_invoice_id}" )
@CryptomusHandler.route("/view_payment/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def view_payment( invoice_id : str ):
AuthenticatedUser : User = auth.GetCurrentUser()
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return render_template("cryptomus/view_payment.html", InvoiceObj = InvoiceObj)
@CryptomusHandler.route("/dashboard", methods=["GET"])
@auth.authenticated_required
def dashboard():
AuthenticatedUser : User = auth.GetCurrentUser()
PageNumber = max(request.args.get('page', default=1, type=int) , 1)
UserInvoices = CryptomusInvoice.query.filter_by( initiator_id = AuthenticatedUser.id ).order_by( CryptomusInvoice.created_at.desc() ).paginate(
page=PageNumber, per_page=15, error_out=False
)
return render_template("cryptomus/dashboard.html", UserInvoices = UserInvoices)
+277
View File
@@ -0,0 +1,277 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify
from app.models.gameservers import GameServer
from app.models.place_ordered_datastore import PlaceOrderedDatastore
from app.models.place_datastore import PlaceDatastore
from app.models.asset import Asset
from app.models.universe import Universe
from app.models.place import Place
from app.enums.AssetType import AssetType
from app.extensions import get_remote_address, db, redis_controller, csrf
from functools import wraps
DataStoreRoute = Blueprint('datastore', __name__)
def GameServerRequired(f):
@wraps(f)
def decorated_function(*args, **kwargs):
RequestIP = get_remote_address()
if RequestIP is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
server : GameServer = GameServer.query.filter_by(serverIP=RequestIP).first()
if server is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
ServerAccessKey = request.headers.get("AccessKey", default = None, type = str)
if ServerAccessKey is None or ServerAccessKey != server.accessKey:
return jsonify({"success": False, "message": "Unauthorized"}),401
return f(*args, **kwargs)
return decorated_function
@DataStoreRoute.route("/persistence/getSortedValues", methods=["POST"])
@csrf.exempt
@GameServerRequired
def getSortedValues():
placeId : int = request.args.get("placeId", default=None, type=int)
dataType : str = request.args.get("type", default=None, type=str)
scope : str = request.args.get("scope", default="global", type=str)
pageSize : int = request.args.get("pageSize", default=50, type=int)
exclusiveStartKey : str = request.args.get("exclusiveStartKey", default=None, type=int) # I have no idea how Roblox uses this, but I am going to use it as a page number with pagination
key : str = request.args.get("key", default=None, type=str)
ascending : bool = request.args.get("ascending", default="False", type=str) == "True"
inclusiveMinValue : int = request.args.get("inclusiveMinValue", default=None, type=int)
exclusiveMaxValue : int = request.args.get("inclusiveMaxValue", default=None, type=int)
PlaceObj : Place = Place.query.filter_by(placeid = placeId).first()
if PlaceObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
UniverseObj : Universe = Universe.query.filter_by(id = PlaceObj.parent_universe_id).first()
if UniverseObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
if pageSize == 0 or pageSize > 100:
return jsonify({"data":[], "message": "Page size is too large"}), 200
if dataType != "sorted":
return jsonify({"data":[], "message": "Invalid data type"}), 200
if exclusiveStartKey is None:
exclusiveStartKey = 1
else:
if exclusiveStartKey < 1:
return jsonify({"data":[], "message": "Invalid exclusive start key"}), 200
DataStoreObj = PlaceOrderedDatastore.query.filter_by(
universe_id = UniverseObj.id,
scope = scope,
key = key
)
if inclusiveMinValue is not None:
DataStoreObj = DataStoreObj.filter(PlaceOrderedDatastore.value >= inclusiveMinValue)
if exclusiveMaxValue is not None:
DataStoreObj = DataStoreObj.filter(PlaceOrderedDatastore.value < exclusiveMaxValue)
DataStoreObj : list[PlaceOrderedDatastore] = DataStoreObj.order_by(PlaceOrderedDatastore.value.asc() if ascending else PlaceOrderedDatastore.value.desc()).paginate( page=exclusiveStartKey, per_page=pageSize, error_out=False )
if DataStoreObj is None:
return jsonify({"Entries": [], "ExclusiveStartKey": None}), 200
AllEntries = []
for entry in DataStoreObj.items:
AllEntries.append({
"Target": entry.name,
"Value": entry.value
})
return jsonify({"data":{
"Entries": AllEntries,
"ExclusiveStartKey": str(DataStoreObj.next_num) if DataStoreObj.has_next else None
}}) # TODO: Implement
@DataStoreRoute.route("/persistence/set", methods=["POST"])
@csrf.exempt
@GameServerRequired
def setKey():
placeId : int = request.args.get("placeId", default=None, type=int)
dataType : str = request.args.get("type", default=None, type=str)
scope : str = request.args.get("scope", default="global", type=str)
key : str = request.args.get("key", default=None, type=str)
target : str = request.args.get("target", default=None, type=str)
valueLength : int = request.args.get("valueLength", default=None, type=int)
value : str = request.form.get("value", default=None, type=str)
if valueLength is not None and not valueLength < 1024 * 1024 * 1: # 1MB
return jsonify({"success": False, "message": "Value too large"}),400
PlaceObj : Place = Place.query.filter_by(placeid = placeId).first()
if PlaceObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
UniverseObj : Universe = Universe.query.filter_by(id = PlaceObj.parent_universe_id).first()
if UniverseObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
if dataType == "standard":
DataStoreObj : PlaceDatastore = PlaceDatastore.query.filter_by(
universe_id = UniverseObj.id,
scope = scope,
key = key,
name = target
).order_by(PlaceDatastore.updated_at.desc()).first()
if DataStoreObj is None:
DataStoreObj : PlaceDatastore = PlaceDatastore(
placeid = placeId,
universe_id = UniverseObj.id,
scope=scope,
key=key,
name=target,
value=value
)
db.session.add(DataStoreObj)
else:
DataStoreObj.value = value
db.session.commit()
elif dataType == "sorted":
try:
value : int = int(value)
except:
return jsonify({"success": False, "message": "Value is not an integer"}), 400
DataStoreObj : PlaceOrderedDatastore = PlaceOrderedDatastore.query.filter_by(
universe_id = UniverseObj.id,
scope=scope,
key=key,
name=target
).order_by(PlaceOrderedDatastore.updated_at.desc()).first()
if DataStoreObj is None:
DataStoreObj : PlaceOrderedDatastore = PlaceOrderedDatastore(
placeid = placeId,
universe_id = UniverseObj.id,
scope=scope,
key=key,
name=target,
value=value
)
db.session.add(DataStoreObj)
else:
DataStoreObj.value = value
db.session.commit()
return jsonify({"data":value}) # TODO: Implement
@DataStoreRoute.route("/persistence/getV2", methods=["POST"])
@DataStoreRoute.route("/persistence/getv2", methods=["POST"])
@csrf.exempt
@GameServerRequired
def getv2():
placeId : int = request.args.get("placeId", default=None, type=int)
dataType : str = request.args.get("type", default=None, type=str)
scope : str = request.args.get("scope", default="global", type=str)
PlaceObj : Place = Place.query.filter_by(placeid = placeId).first()
if PlaceObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
UniverseObj : Universe = Universe.query.filter_by(id = PlaceObj.parent_universe_id).first()
if UniverseObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
dataBeingRequested = []
StartingCount = 0
while True:
ReqScope : str = request.form.get(f"qkeys[{str(StartingCount)}].scope", default=None, type=str)
if ReqScope is None:
break
Target : str = request.form.get(f"qkeys[{str(StartingCount)}].target", default=None, type=str)
DataStoreName : str = request.form.get(f"qkeys[{str(StartingCount)}].key", default=None, type=str)
if DataStoreName is None or Target is None or ReqScope is None:
break
dataBeingRequested.append({"Scope": ReqScope, "Target": Target, "Key": DataStoreName})
StartingCount += 1
if len(dataBeingRequested) == 0:
return jsonify({"data":[], "message": "No data being requested"}), 200
ReturnData = []
if dataType == 'standard':
for targetReq in dataBeingRequested:
DataStoreObj : PlaceDatastore = PlaceDatastore.query.filter_by(universe_id = UniverseObj.id, scope=targetReq["Scope"], key=targetReq["Key"], name=targetReq["Target"]).order_by(PlaceDatastore.updated_at.desc()).first()
if DataStoreObj is not None:
ReturnData.append({
"Value": DataStoreObj.value,
"Scope": DataStoreObj.scope,
"Key": DataStoreObj.key,
"Target": DataStoreObj.name
})
elif dataType == 'sorted':
for targetReq in dataBeingRequested:
DataStoreObj : PlaceOrderedDatastore = PlaceOrderedDatastore.query.filter_by(universe_id = UniverseObj.id, scope=targetReq["Scope"], key=targetReq["Key"], name=targetReq["Target"]).order_by(PlaceOrderedDatastore.updated_at.desc()).first()
if DataStoreObj is not None:
ReturnData.append({
"Value": str(DataStoreObj.value),
"Scope": DataStoreObj.scope,
"Key": DataStoreObj.key,
"Target": DataStoreObj.name
})
return jsonify({"data":ReturnData})
@DataStoreRoute.route("/persistence/increment", methods=["POST"])
@csrf.exempt
@GameServerRequired
def increment():
placeId : int = request.args.get("placeId", default=None, type=int)
key : str = request.args.get("key", default=None, type=str)
dataType : str = request.args.get("type", default=None, type=str)
scope : str = request.args.get("scope", default="global", type=str)
target : str = request.args.get("target", default=None, type=str)
value : int = request.args.get("value", default=None, type=int)
PlaceObj : Place = Place.query.filter_by(placeid = placeId).first()
if PlaceObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
UniverseObj : Universe = Universe.query.filter_by(id = PlaceObj.parent_universe_id).first()
if UniverseObj is None:
return jsonify({"data":[], "message": "Place does not exist"}), 200
if dataType == "standard":
DataStoreObj : PlaceDatastore = PlaceDatastore.query.filter_by(
universe_id=UniverseObj.id,
scope=scope,
key=key,
name=target
).order_by(PlaceDatastore.updated_at.desc()).first()
if DataStoreObj is None:
DataStoreObj : PlaceDatastore = PlaceDatastore(
placeid = placeId,
universe_id=UniverseObj.id,
scope=scope,
key=key,
name=target,
value=str(value)
)
db.session.add(DataStoreObj)
else:
try:
DataStoreObj.value = str(int(DataStoreObj.value) + value)
except:
return jsonify({"data":[], "message": "Value is not an integer"}), 200
db.session.commit()
elif dataType == "sorted":
DataStoreObj : PlaceOrderedDatastore = PlaceOrderedDatastore.query.filter_by(
universe_id=UniverseObj.id,
scope=scope,
key=key,
name=target
).order_by(PlaceOrderedDatastore.updated_at.desc()).first()
if DataStoreObj is None:
DataStoreObj : PlaceOrderedDatastore = PlaceOrderedDatastore(
placeid = placeId,
universe_id=UniverseObj.id,
scope=scope,
key=key,
name=target,
value=value
)
db.session.add(DataStoreObj)
else:
DataStoreObj.value += value # PlaceOrderedDatastore values should always be integers
return jsonify({"data":value})
+294
View File
@@ -0,0 +1,294 @@
"""
All routes inside here is used to communicate internally with our Discord Bot.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort
from app.extensions import csrf, redis_controller, get_remote_address
from sqlalchemy import func
from config import Config
from app.models.user import User
from app.models.asset import Asset
from app.models.linked_discord import LinkedDiscord
from app.models.asset_rap import AssetRap
from app.models.placeserver_players import PlaceServerPlayer
from app.enums.MembershipType import MembershipType
from app.services.economy import GetAssetRap
from app.util.membership import GetUserMembership, GiveUserMembership
import datetime
import time
import logging
DiscordInternal = Blueprint('discord_internal', __name__, url_prefix='/internal/discord_bot')
@DiscordInternal.before_request
def before_request():
AuthorizationToken = request.headers.get("InternalAuthorizationKey", default=None, type=str)
if AuthorizationToken != Config.DISCORD_BOT_AUTHTOKEN:
logging.warning(f"Discord Bot Internal API: Unauthorized request from {get_remote_address()}, invalid auth token")
return abort(404)
UserAgent = request.headers.get("User-Agent", default=None, type=str)
if UserAgent != "SyntaxBot/1.0":
logging.warning(f"Discord Bot Internal API: Unauthorized request from {get_remote_address()}, invalid user agent")
return abort(404)
RequesterAddress = get_remote_address()
if RequesterAddress not in Config.DISCORD_BOT_AUTHORISED_IPS:
logging.warning(f"Discord Bot Internal API: Unauthorized request from {RequesterAddress}, not in authorised IP list")
return abort(404)
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 Exception("User does not exist.")
return TargetUser
def ReturnUserObject( UserObj : User ) -> dict:
return {
"id": UserObj.id,
"username": UserObj.username,
"last_online": time.mktime(UserObj.lastonline.timetuple()),
"created_at": time.mktime(UserObj.created.timetuple()),
"description": UserObj.description,
"membership": GetUserMembership(UserObj, changeToString=True)
}
def ReturnItemObject( AssetObj : Asset ) -> dict:
return {
"id": AssetObj.id,
"name": AssetObj.name,
"description": AssetObj.description,
"asset_type": AssetObj.asset_type.name,
"creator": ReturnUserObject(GetUserFromId(AssetObj.creator_id)) if AssetObj.creator_type == 0 else None,
"creator_type": AssetObj.creator_type,
"created_at": time.mktime(AssetObj.created_at.timetuple()),
"updated_at": time.mktime(AssetObj.updated_at.timetuple()),
"is_limited": AssetObj.is_limited,
"is_limited_unique": AssetObj.is_limited_unique,
"is_for_sale": AssetObj.is_for_sale,
"asset_rap": GetAssetRap(AssetObj.id) if AssetObj.is_limited and not AssetObj.is_for_sale else None,
"price_robux": AssetObj.price_robux,
"price_tickets": AssetObj.price_tix,
"sales": AssetObj.sale_count
}
@DiscordInternal.route("/UsernameLookup", methods=['GET'])
def UsernameLookup():
Username = request.args.get("username", default=None, type=str)
if Username is None:
return jsonify({
"success": False,
"message": "Invalid username"
})
UserObject : User = User.query.filter(func.lower(User.username) == func.lower(Username)).first()
if UserObject is None:
return jsonify({
"success": False,
"message": "User not found"
})
if UserObject.accountstatus in [3,4]:
return jsonify({
"success": False,
"message": "User not found"
})
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObject)
})
@DiscordInternal.route("/UseridLookup", methods=['GET'])
def UseridLookup():
Userid = request.args.get("userid", default=None, type=int)
if Userid is None:
return jsonify({
"success": False,
"message": "Invalid userid"
})
UserObject : User = User.query.filter_by(id=Userid).first()
if UserObject is None:
return jsonify({
"success": False,
"message": "User not found"
})
if UserObject.accountstatus in [3,4]:
return jsonify({
"success": False,
"message": "User not found"
})
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObject)
})
@DiscordInternal.route("/ItemLookup", methods=['GET'])
def ItemLookup():
AssetId = request.args.get("itemid", default=None, type=int)
if AssetId is None:
return jsonify({
"success": False,
"message": "Invalid itemid"
})
AssetObject : Asset = Asset.query.filter_by(id=AssetId).first()
if AssetObject is None:
return jsonify({
"success": False,
"message": "Item not found"
})
if AssetObject.asset_type.value not in [2, 8, 11, 12, 17, 18, 19, 27, 28, 29, 30, 31, 32, 41, 42, 43, 44, 45, 46, 47]:
return jsonify({
"success": False,
"message": "Item not found"
})
return jsonify({
"success": True,
"message": "",
"data": ReturnItemObject(AssetObject)
})
@DiscordInternal.route("/UserLookupByDiscordId", methods=['GET'])
def UserLookupByDiscordId():
DiscordId = request.args.get("discordid", default=None, type=int)
if DiscordId is None:
return jsonify({
"success": False,
"message": "Invalid discordid"
})
LinkedDiscordObject : LinkedDiscord = LinkedDiscord.query.filter_by(discord_id=DiscordId).first()
if LinkedDiscordObject is None:
return jsonify({
"success": False,
"message": "User does not have an account linked to their discord account"
})
UserObject : User = User.query.filter_by(id=LinkedDiscordObject.user_id).first()
if UserObject is None:
return jsonify({
"success": False,
"message": "User not found"
})
if UserObject.accountstatus in [3,4]:
return jsonify({
"success": False,
"message": "User not found"
})
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObject)
})
@DiscordInternal.route("/AwardUserTurbo", methods=['POST'])
@csrf.exempt
def AwardUserTurbo():
DiscordId = request.args.get("discordid", default=None, type=int)
if DiscordId is None:
return jsonify({
"success": False,
"message": "Invalid discordid"
})
LinkedDiscordObject : LinkedDiscord = LinkedDiscord.query.filter_by(discord_id=DiscordId).first()
if LinkedDiscordObject is None:
return jsonify({
"success": False,
"message": "User does not have an account linked to their discord account"
})
UserObject : User = User.query.filter_by(id=LinkedDiscordObject.user_id).first()
if UserObject is None:
return jsonify({
"success": False,
"message": "User not found"
})
if UserObject.accountstatus in [3,4]:
return jsonify({
"success": False,
"message": "User is currently moderated"
})
UserMembershipStatus : MembershipType = GetUserMembership(UserObject)
if UserMembershipStatus == MembershipType.TurboBuildersClub:
return jsonify({
"success": False,
"message": "User already has Turbo Builders Club"
})
if UserMembershipStatus == MembershipType.OutrageousBuildersClub:
return jsonify({
"success": False,
"message": "User has Outrageous Builders Club"
})
if redis_controller.get(f"discord_bot_award_turbo_{UserObject.id}") is not None:
return jsonify({
"success": False,
"message": "Discord user has recenetly been awarded Turbo Builders Club"
})
GiveUserMembership(UserObject, MembershipType.TurboBuildersClub, expiration=datetime.timedelta(days=14))
redis_controller.set(f"discord_bot_award_turbo_{UserObject.id}", "1", ex=60*60*24*14)
return jsonify({
"success": True,
"message": ""
})
@DiscordInternal.route("/Ping", methods=['GET'])
def Ping():
return jsonify({
"success": True,
"message": "Pong!"
})
@DiscordInternal.route("/VerifyOBCUsers", methods=["POST"])
@csrf.exempt
def VerifyOBCUsers():
JSONPayload = request.get_json()
if JSONPayload is None:
return jsonify({
"success": False,
"message": "Invalid JSON payload"
})
if "users" not in JSONPayload:
return jsonify({
"success": False,
"message": "Invalid JSON payload"
})
BadUsersIds : list[int] = []
for UserId in JSONPayload["users"]:
LinkedDiscordObj : LinkedDiscord = LinkedDiscord.query.filter_by(discord_id=UserId).first()
if LinkedDiscordObj is None:
BadUsersIds.append(UserId)
continue
UserObj : User = User.query.filter_by(id=LinkedDiscordObj.user_id).first()
if UserObj is None:
BadUsersIds.append(UserId)
continue
UserMembershipType : MembershipType = GetUserMembership(UserObj)
if UserMembershipType != MembershipType.OutrageousBuildersClub:
BadUsersIds.append(UserId)
continue
return jsonify({
"success": True,
"message": "",
"bad_users": BadUsersIds
})
@DiscordInternal.route("/GetSiteStats", methods=["GET"])
def SiteStats():
UsersOnline = User.query.filter(User.lastonline > (datetime.datetime.utcnow() - datetime.timedelta(minutes=1))).count()
UsersIngame = PlaceServerPlayer.query.count()
return jsonify({
"success": True,
"message": "",
"data": {
"users_online": UsersOnline,
"users_ingame": UsersIngame
}
})
+96
View File
@@ -0,0 +1,96 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify, session, abort
from config import Config
from app.extensions import db
from app.util import auth
from app.models.user import User
from app.models.user_email import UserEmail
import hashlib
import hmac
import base64
import urllib.parse
import time
import logging
config = Config()
discourse_sso = Blueprint("discourse_sso", __name__, url_prefix="/discourse")
@discourse_sso.before_request
def VerifyUserAgent():
if not config.DISCOURSE_SSO_ENABLED:
return abort(404)
def VerifyPayloadSignature( Payload : str, Signature : str ) -> bool:
"""
Verifies the payload signature
"""
return hmac.compare_digest(
Signature,
hmac.new(
key = config.DISCOURSE_SECRET_KEY.encode("utf-8"),
msg = Payload.encode("utf-8"),
digestmod = hashlib.sha256
).hexdigest()
)
def SignPayload( Payload : str ) -> str:
"""
Signs the payload and returns the signature
"""
return hmac.new(
key = config.DISCOURSE_SECRET_KEY.encode("utf-8"),
msg = Payload.encode("utf-8"),
digestmod = hashlib.sha256
).hexdigest()
@discourse_sso.route("/", methods=["GET"])
@auth.authenticated_required
def DiscourseSSOIndex():
return render_template("discourse/leaving-syntax.html", baseurl=config.DISCOURSE_FORUM_BASEURL)
@discourse_sso.route("/sso", methods=["GET", "POST"])
@auth.authenticated_required
def DiscourseSSO():
SSOPayload : str = request.args.get("sso", default=None, type=str)
PayloadSignature : str = request.args.get("sig", default=None, type=str)
if SSOPayload is None or PayloadSignature is None:
return abort(404)
isValidSignature = VerifyPayloadSignature(SSOPayload, PayloadSignature)
if not isValidSignature:
return abort(404)
Payload = urllib.parse.parse_qs(base64.b64decode(SSOPayload).decode("utf-8"))
if "nonce" not in Payload:
return abort(404)
nonce = Payload["nonce"][0]
if nonce is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
UserEmailObj : UserEmail = UserEmail.query.filter_by(user_id=AuthenticatedUser.id, verified=True).first()
if UserEmailObj is None:
flash("You must have a verified email address to access the forums", "error")
return redirect(url_for("settings.settings_update_email"))
if request.method == "GET":
return render_template("discourse/sso-confirm.html", baseurl=config.DISCOURSE_FORUM_BASEURL)
elif request.method == "POST":
payload = {
"nonce": nonce,
"email": f"SyntaxUser{AuthenticatedUser.id}@forum.vortexi.cc",
"external_id": AuthenticatedUser.id,
"username": AuthenticatedUser.username,
"name": AuthenticatedUser.username,
"avatar_url": f"{config.BaseURL}/Thumbs/Head.ashx?x=420&y=420&userId={AuthenticatedUser.id}&rnd={time.time()}",
"avatar_force_update": "true",
"require_activation": "false",
"suppress_welcome_message": "true"
}
payload = urllib.parse.urlencode(payload)
payload = base64.b64encode(payload.encode("utf-8")).decode("utf-8")
payloadSignature = SignPayload(payload)
return redirect(f"{config.DISCOURSE_FORUM_BASEURL}/session/sso_login?sso={payload}&sig={payloadSignature}")
else:
return abort(404)
+147
View File
@@ -0,0 +1,147 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response
from app.models.fflag_group import FflagGroup
from app.models.fflag_value import FflagValue
from app.models.gameservers import GameServer
from app.extensions import redis_controller, get_remote_address
import json
import base64
import os
import requests
FFlagRoute = Blueprint("fflag", __name__, url_prefix="/")
def ClearCache( GroupId : int ):
redis_controller.delete("fflags_" + str(GroupId))
GenerateFFlags(GroupId, True)
def GenerateFFlags( GroupId : int, BypassCache : bool = False ) -> dict:
if not BypassCache:
CachedFFlags = redis_controller.get("fflags_" + str(GroupId))
if CachedFFlags is not None:
return json.loads(CachedFFlags)
FFlagValues = FflagValue.query.filter_by(group_id=GroupId).all()
if FFlagValues is None:
return jsonify({}),200
FinalData = {}
for FFlagValue in FFlagValues:
FinalData[FFlagValue.name] = str(base64.b64decode(FFlagValue.flag_value).decode('utf-8'))
redis_controller.set("fflags_" + str(GroupId), json.dumps(FinalData), ex = 60 * 60)
return FinalData
@FFlagRoute.route("/Setting/QuietGet/<group>", methods=["GET"])
@FFlagRoute.route("/Setting/QuietGet/<group>/", methods=["GET"])
@FFlagRoute.route("/Setting/Get/<group>/", methods=["GET"])
def get_fflag(group):
api_key = request.headers.get('apiKey') or request.args.get('apiKey')
json_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'files')
if api_key == "D6925E56-BFB9-4908-AAA2-A5B1EC4B2D78":
json_file_path = os.path.join(json_file_path, 'Client18Setting.json')
return get_json_response(json_file_path)
if group in ["RCCServiceaRWyf4zz9jy", "ClientAppSettings", "RCCServiceS2kvmA2lVpz4g9qlbDw1"]:
json_file_path = os.path.join(json_file_path, f'{group}.json')
return get_json_response(json_file_path)
elif group in ["Client2014Setting", "Client2014SharedConf"]:
json_file_path = os.path.join(json_file_path, 'Client2014SharedConf.json')
return get_json_response(json_file_path)
else:
return 'Invalid request', 400
def get_json_response(file_path):
try:
with open(file_path, 'r') as file:
json_data = json.load(file)
return jsonify(json_data), 200
except FileNotFoundError:
return 'File not found', 404
except json.JSONDecodeError:
return 'Invalid JSON file', 500
@FFlagRoute.route("/v1/settings/application", methods=["GET"])
def fflag_application():
application_name = request.args.get("applicationName")
if not application_name:
return jsonify({"error": "Missing 'applicationName' query parameter"}), 400
base_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'files')
file_mapping = {
"PCDesktopClient": os.path.join(base_path, 'PCDesktopClient.json'),
"GD5Z5gO1n0gYX1P": os.path.join(base_path, 'PCDesktopClient.json'),
"6sxp8X2Y02aRWyf4zz9jy": os.path.join(base_path, '2020RCCService.json'),
"6sxp8X2Y02SecureR": os.path.join(base_path, '2020RCCService.json'),
"S2kvmA2lVpz4g9qlbDw1": os.path.join(base_path, '2020RCCService.json'),
"RCCServiceaRWyf4zz9jy": os.path.join(base_path, '2020RCCService.json')
}
# 2020 rcc fix
request_host = request.headers.get('Host', '')
if request_host.startswith("clientsettingzcdn."):
json_file_path = os.path.join(base_path, 'classicrcc.json')
else:
json_file_path = file_mapping.get(application_name)
#json_file_path = file_mapping.get(application_name)
if not json_file_path:
#os.path.join(base_path, '2020RCCService.json'),
return jsonify({"error": f"No configuration found forr '{application_name}'"}), 404
if not os.path.exists(json_file_path):
return jsonify({"error": "Configuration file not found"}), 404
try:
with open(json_file_path, 'r') as json_file:
json_data = json.load(json_file)
return jsonify(json_data)
except (json.JSONDecodeError, IOError) as e:
return jsonify({"error": "Failed to read or parse JSON file", "details": str(e)}), 500
@FFlagRoute.route("/v2/settings/application/<application_name>", methods=["GET"])
def fflag_application_v2(application_name):
base_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'files')
if application_name == "PCDesktopClient":
json_file = "2022DesktopClient.json"
elif application_name == "PCStudioApp":
json_file = "2022StudioApp.json"
else:
return jsonify({"error": "Unsupported application"}), 400
json_file_path = os.path.join(base_path, json_file)
if not os.path.exists(json_file_path):
return jsonify({"error": "Configuration file not found"}), 404
try:
with open(json_file_path, 'r') as f:
fflags_data = json.load(f)
return jsonify(fflags_data)
except (json.JSONDecodeError, IOError) as e:
return jsonify({
"error": "Failed to read configuration",
"details": str(e)
}), 500
"""@FFlagRoute.route("/v1/settings/application", methods=["GET"])
def fflag_application():
application_name = request.args.get("applicationName")
if not application_name:
return jsonify({"error": "Missing 'applicationName' query parameter"}), 400
external_url = f"https://www.solario.ws/v1/settings/application?applicationName={application_name}"
try:
external_response = requests.get(external_url, timeout=5)
if external_response.status_code == 200:
return jsonify(external_response.json()), 200
else:
return jsonify({"error": f"External service error: {external_response.status_code}"}), external_response.status_code
except requests.RequestException as e:
return jsonify({"error": f"Failed to fetch data from external service: {str(e)}"}), 500 """
+311
View File
@@ -0,0 +1,311 @@
# friends.roblox.com
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, after_this_request
from app.util import auth, websiteFeatures, friends
from app.models.user import User
from app.models.friend_relationship import FriendRelationship
from app.models.friend_request import FriendRequest
from app.models.follow_relationship import FollowRelationship
from app.extensions import limiter, db, redis_controller, get_remote_address, csrf, user_limiter
from flask_wtf.csrf import CSRFError, generate_csrf
from sqlalchemy import or_
from datetime import datetime, timedelta
from app.services.user_relationships import followings
FriendsAPIRoute = Blueprint('friendsapi', __name__, url_prefix="/")
csrf.exempt(FriendsAPIRoute)
@FriendsAPIRoute.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@FriendsAPIRoute.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@FriendsAPIRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
@FriendsAPIRoute.route('/v1/my/friends/count', methods=['GET'])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def GetFriendCount():
AuthenticatedUser : User = auth.GetCurrentUser()
return jsonify( { "count": friends.GetFriendCount( AuthenticatedUser.id ) } ), 200
@FriendsAPIRoute.route('/v1/users/<int:userId>/friends/count', methods=['GET'])
@limiter.limit("60/minute")
def GetFriendCountByUserId( userId : int ):
UserObj : User = User.query.filter_by( id = userId ).first()
if UserObj is None:
return jsonify( { "errors": [ { "code": 1, "message": "User not found" } ] } ), 404
return jsonify( { "count": friends.GetFriendCount( userId ) } ), 200
@FriendsAPIRoute.route("/v1/user/friend-requests/count", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def GetMyFriendRequestsCount():
AuthenticatedUser : User = auth.GetCurrentUser()
FriendRequestCount : int = max(FriendRequest.query.filter_by( requestee_id = AuthenticatedUser.id ).count(), 500)
return jsonify( { "count": FriendRequestCount } ), 200
@FriendsAPIRoute.route("/v1/users/<int:userid>/friends", methods=["GET"])
@limiter.limit("60/minute")
def get_user_friends( userid : int ):
UserObj : User = User.query.filter_by(id=userid).first()
if UserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
FriendRelationshipList : list[FriendRelationship] = FriendRelationship.query.filter(or_(FriendRelationship.user_id == userid, FriendRelationship.friend_id == userid)).all()
FriendList = []
for FriendRelationshipObj in FriendRelationshipList:
FriendObj : User = User.query.filter_by(id=FriendRelationshipObj.user_id if FriendRelationshipObj.user_id != userid else FriendRelationshipObj.friend_id).first()
if FriendObj is None:
continue
FriendList.append({
"isOnline": FriendObj.lastonline > datetime.utcnow() - timedelta(minutes=1),
"isDeleted": False,
"friendFrequentScore": 0,
"friendFrequentRank": 1,
"hasVerifiedBadge": False,
"description": FriendObj.description,
"created": FriendObj.created.isoformat(),
"isBanned": False,
"externalAppDisplayName": None,
"id": FriendObj.id,
"name": FriendObj.username,
"displayName": FriendObj.username
})
return jsonify({
"data": FriendList
})
@FriendsAPIRoute.route("/v1/user/friend-requests/decline-all", methods=["POST"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def decline_all_friend_requests():
AuthenticatedUser : User = auth.GetCurrentUser()
FriendRequestList : list[FriendRequest] = FriendRequest.query.filter_by( requestee_id = AuthenticatedUser.id ).all()
for FriendRequestObj in FriendRequestList:
db.session.delete( FriendRequestObj )
db.session.commit()
return jsonify({}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/unfriend", methods=["POST"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
@user_limiter.limit("60/minute")
def unfriend_user( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship( AuthenticatedUser.id, UserObj.id )
if FriendRelationshipObj is not None:
db.session.delete( FriendRelationshipObj )
db.session.commit()
return jsonify({}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/request-friendship", methods=["POST"])
@limiter.limit("15/minute")
@auth.authenticated_required_api
@user_limiter.limit("15/minute")
def request_friendship( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
if userId == AuthenticatedUser.id:
return jsonify({ "errors": [{ "code": 7, "message": "The user cannot be friends with itself."}] }), 400
if friends.GetFriendCount( AuthenticatedUser.id ) >= 200:
return jsonify({ "errors": [{ "code": 31, "message": "User with max friends sent friend request."}] }), 400
if friends.IsFriends( AuthenticatedUser.id, UserObj.id ):
return jsonify({ "errors": [{ "code": 5, "message": "The target user is already a friend."}] }), 400
OtherFriendRequestObj : FriendRequest = FriendRequest.query.filter_by( requestee_id = AuthenticatedUser.id, requester_id = UserObj.id ).first()
if OtherFriendRequestObj is not None:
if friends.GetFriendCount( UserObj.id ) >= 200:
return jsonify({ "errors": [{ "code": 12, "message": "The target users friends limit has been exceeded."}] }), 400
db.session.delete( OtherFriendRequestObj )
NewFriendRelationshipObj : FriendRelationship = FriendRelationship( user_id = AuthenticatedUser.id, friend_id = UserObj.id )
db.session.add( NewFriendRelationshipObj )
db.session.commit()
return jsonify({ "success": True }), 200
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by( requestee_id = UserObj.id, requester_id = AuthenticatedUser.id ).first()
if FriendRequestObj is None:
FriendRequestObj = FriendRequest( requestee_id = UserObj.id, requester_id = AuthenticatedUser.id )
db.session.add( FriendRequestObj )
db.session.commit()
return jsonify({ "success": True }), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/accept-friend-request", methods=["POST"])
@limiter.limit("30/minute")
@auth.authenticated_required_api
@user_limiter.limit("30/minute")
def accept_friend_request( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by( requestee_id = AuthenticatedUser.id, requester_id = UserObj.id ).first()
if FriendRequestObj is None:
return jsonify({ "errors": [{ "code": 10, "message": "The friend request does not exist."}] }), 400
if friends.GetFriendCount( AuthenticatedUser.id ) >= 200:
return jsonify({ "errors": [{ "code": 11, "message": "The current users friends limit has been exceeded."}] }), 400
if friends.GetFriendCount( UserObj.id ) >= 200:
return jsonify({ "errors": [{ "code": 12, "message": "The target users friends limit has been exceeded."}] }), 400
db.session.delete( FriendRequestObj )
NewFriendRelationshipObj : FriendRelationship = FriendRelationship( user_id = AuthenticatedUser.id, friend_id = UserObj.id )
db.session.add( NewFriendRelationshipObj )
db.session.commit()
return jsonify({}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/decline-friend-request", methods=["POST"])
@limiter.limit("30/minute")
@auth.authenticated_required_api
@user_limiter.limit("30/minute")
def decline_friend_request( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by( requestee_id = AuthenticatedUser.id, requester_id = UserObj.id ).first()
if FriendRequestObj is None:
return jsonify({ "errors": [{ "code": 10, "message": "The friend request does not exist."}] }), 400
db.session.delete( FriendRequestObj )
db.session.commit()
return jsonify({}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/followers/count", methods=["GET"])
@limiter.limit("60/minute")
def get_user_followers_count( userId : int ):
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
return jsonify( { "count": followings.get_follower_count( requested_user = UserObj ) } ), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/followings/count", methods=["GET"])
@limiter.limit("60/minute")
def get_user_followings_count( userId : int ):
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
return jsonify( { "count": followings.get_following_count( requested_user = UserObj ) } ), 200
@FriendsAPIRoute.route("/v1/user/following-exists", methods=["POST"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def following_exists():
AuthenticatedUser : User = auth.GetCurrentUser()
if not request.is_json:
return jsonify({ "errors": [{ "code": 0, "message": "An invalid userId was passed in."}] }), 400
if "targetUserIds" not in request.json:
return jsonify({ "errors": [{ "code": 0, "message": "An invalid userId was passed in."}] }), 400
TargetUserIds : list[int] = request.json["targetUserIds"]
ResponseList = []
if len(TargetUserIds) > 100:
return jsonify({ "errors": [{ "code": 0, "message": "An invalid userId was passed in."}] }), 400
for TargetUserId in TargetUserIds:
if TargetUserId < 1:
return jsonify({ "errors": [{ "code": 0, "message": "An invalid userId was passed in."}] }), 400
TargetUser : User = User.query.filter_by( id = TargetUserId ).first()
if TargetUser is None:
return jsonify({ "errors": [{ "code": 0, "message": "An invalid userId was passed in."}] }), 400
ResponseList.append({
"isFollowing": followings.is_following( follower_user = AuthenticatedUser, followed_user = TargetUser ),
"isFollowed": followings.is_following( follower_user = TargetUser, followed_user = AuthenticatedUser ),
"userId": TargetUserId
})
return jsonify({
"followings": ResponseList
}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/unfollow", methods=["POST"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
@user_limiter.limit("60/minute")
def unfollow_user( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
try:
followings.unfollow_user(
current_follower = AuthenticatedUser,
followed_user = UserObj
)
except followings.FollowingExceptions.UserNotFollowing:
return jsonify({}), 200
except followings.FollowingExceptions.UserRateLimited:
return jsonify({ "errors": [{ "code": 9, "message": "The flood limit has been exceeded."}] }), 429
except followings.FollowingExceptions.FollowingIsDisabled:
return jsonify({ "errors": [{ "code": 0, "message": "Following is disabled."}] }), 500
return jsonify({}), 200
@FriendsAPIRoute.route("/v1/users/<int:userId>/follow", methods=["POST"])
@limiter.limit("10/minute")
@auth.authenticated_required_api
@user_limiter.limit("10/minute")
def follow_user( userId : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({ "errors": [{ "code": 1, "message": "The target user is invalid or does not exist"}] }), 400
if userId == AuthenticatedUser.id:
return jsonify({ "errors": [{ "code": 8, "message": "The user cannot follow itself."}] }), 400
try:
followings.follow_user(
follow_user = AuthenticatedUser,
followed_user = UserObj
)
except followings.FollowingExceptions.AlreadyFollowing:
return jsonify({ "success": True }), 200
except followings.FollowingExceptions.UserRateLimited:
return jsonify({ "errors": [{ "code": 9, "message": "The flood limit has been exceeded."}] }), 429
except followings.FollowingExceptions.FollowingIsDisabled:
return jsonify({ "errors": [{ "code": 0, "message": "Following is disabled."}] }), 500
return jsonify({ "success": True }), 200
+725
View File
@@ -0,0 +1,725 @@
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 secrets
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 ""
@GameJoinRoute.route('/v1/heartbeat', methods=['POST', 'GET'])
@csrf.exempt
def heartbeat():
response = make_response(
jsonify({
"success": "true",
})
)
return response
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(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(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": "",
"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.vortexi.cc/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.vortexi.cc/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(PlaceId)}"
elif TicketVersion == 4:
CharacterURL = f"http://www.vortexi.cc/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": "",
"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.vortexi.cc/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(placeid)}" if PlaceObj.placeyear in [PlaceYear.Eighteen] else f"http://www.vortexi.cc/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
+504
View File
@@ -0,0 +1,504 @@
# games.roblox.com
import math
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, after_this_request
from app.extensions import redis_controller, get_remote_address, csrf, limiter, db
from app.util import auth, websiteFeatures, placeinfo
from app.models.place import Place
from app.models.asset import Asset
from app.models.placeservers import PlaceServer
from app.models.user import User
from app.models.groups import Group
from app.models.asset_thumbnail import AssetThumbnail
from app.models.universe import Universe
from app.enums.PlaceYear import PlaceYear
from app.routes.rate import GetAssetLikesAndDislikes, GetAssetFavoriteCount, GetUserFavoriteStatus
from flask_wtf.csrf import CSRFError, generate_csrf
from sqlalchemy import func, and_
from flask_sqlalchemy import pagination
GamesAPIRoute = Blueprint('gamesapi', __name__, url_prefix='/')
csrf.exempt(GamesAPIRoute)
@GamesAPIRoute.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@GamesAPIRoute.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@GamesAPIRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
@GamesAPIRoute.route('/v1/games/sorts', methods=["GET"])
@auth.authenticated_required_api
def games_sorts():
return jsonify({
"sorts" : [
{
"token": "MostPopular",
"name": "most_popular",
"displayName": "Popular",
"gameSetTypeId": 1,
"gameSetTargetId": 90,
"timeOptionsAvailable": False,
"genreOptionsAvailable": False,
"numberOfRows": 2,
"numberOfGames": 0,
"isDefaultSort": True,
"contextUniverseId": None,
"contextCountryRegionId": 1,
"tokenExpiryInSeconds": 3600
},
{
"token": "Featured",
"name": "featured",
"displayName": "Featured",
"gameSetTypeId": 2,
"gameSetTargetId": 91,
"timeOptionsAvailable": False,
"genreOptionsAvailable": False,
"numberOfRows": 1,
"numberOfGames": 0,
"isDefaultSort": True,
"contextUniverseId": None,
"contextCountryRegionId": 1,
"tokenExpiryInSeconds": 3600
},
{
"token": "RecentlyUpdated",
"name": "recently_updated",
"displayName": "Recently Updated",
"gameSetTypeId": 3,
"gameSetTargetId": 93,
"timeOptionsAvailable": False,
"genreOptionsAvailable": False,
"numberOfRows": 1,
"numberOfGames": 0,
"isDefaultSort": True,
"contextUniverseId": None,
"contextCountryRegionId": 1,
"tokenExpiryInSeconds": 3600
},
],
"timeFilters": [
{
"token": "Now",
"name": "Now",
"tokenExpiryInSeconds": 3600
},
{
"token": "PastDay",
"name": "PastDay",
"tokenExpiryInSeconds": 3600
},
{
"token": "PastWeek",
"name": "PastWeek",
"tokenExpiryInSeconds": 3600
},
{
"token": "PastMonth",
"name": "PastMonth",
"tokenExpiryInSeconds": 3600
},
{
"token": "AllTime",
"name": "AllTime",
"tokenExpiryInSeconds": 3600
}
],
"genreFilters": [
{
"token": "T638364961735517991_1_89de",
"name": "All",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518009_19_3d2",
"name": "Building",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518045_11_3de6",
"name": "Horror",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518062_7_558c",
"name": "Town and City",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518076_17_c371",
"name": "Military",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518094_15_2056",
"name": "Comedy",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518107_8_6d4f",
"name": "Medieval",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518120_13_c168",
"name": "Adventure",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518134_9_e6aa",
"name": "Sci-Fi",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518156_12_13fb",
"name": "Naval",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518169_20_46a",
"name": "FPS",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518183_21_4bbf",
"name": "RPG",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518192_14_efc6",
"name": "Sports",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518205_10_fa83",
"name": "Fighting",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518223_16_5d38",
"name": "Western",
"tokenExpiryInSeconds": 3600
}
],
"gameFilters": [
{
"token": "T638364961735518263_Any_56d2",
"name": "Any",
"tokenExpiryInSeconds": 3600
},
{
"token": "T638364961735518277_Classic_a1f4",
"name": "Classic",
"tokenExpiryInSeconds": 3600
}
],
"pageContext": {
"pageId": "f5b1510e-3810-42ab-8135-8ffa5ef221ba",
"isSeeAllPage": None
},
"gameSortStyle": None
})
@GamesAPIRoute.route("/v1/games/list", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def games_list():
sortToken = request.args.get("sortToken", default = "MostPopular", type = str)
startRows = request.args.get("startRows", default = 0, type = int)
maxRows = request.args.get("maxRows", default = 40, type = int)
if sortToken not in ["MostPopular", "Featured", "RecentlyUpdated"]:
return jsonify( { "errors": [ { "code": 0, "message": "Invalid sort token." } ] } ), 400
if startRows < 0:
return jsonify( { "errors": [ { "code": 0, "message": "Invalid start rows." } ] } ), 400
if maxRows > 40 or maxRows < 0:
return jsonify( { "errors": [ { "code": 0, "message": "Max rows must be between 0-40" } ] } ), 400
pageNumber = math.floor(startRows / maxRows) + 1
if sortToken == "MostPopular":
PopularGames = Universe.query.filter( and_( Universe.is_public == True, Universe.place_year == PlaceYear.Twenty ) ).outerjoin( Place, Place.parent_universe_id == Universe.id ).outerjoin( PlaceServer, PlaceServer.serverPlaceId == Place.placeid ).group_by( Universe.id )
PopularGames = PopularGames.order_by( func.coalesce( func.sum( PlaceServer.playerCount ), 0 ).desc() ).order_by( func.coalesce( func.sum( Place.visitcount ), 0 ).desc() )
UniverseObjsList = PopularGames.paginate(
page = pageNumber,
per_page = maxRows,
error_out = False
)
elif sortToken == "Featured":
FeaturedGames = Universe.query.filter( and_( Universe.is_public == True, Universe.place_year == PlaceYear.Twenty, Universe.is_featured == True ) ).outerjoin( Place, Place.parent_universe_id == Universe.id ).outerjoin( PlaceServer, PlaceServer.serverPlaceId == Place.placeid ).group_by( Universe.id )
FeaturedGames = FeaturedGames.order_by( func.coalesce( func.sum( PlaceServer.playerCount ), 0 ).desc() ).order_by( func.coalesce( func.sum( Place.visitcount ), 0 ).desc() )
UniverseObjsList = FeaturedGames.paginate(
page = pageNumber,
per_page = maxRows,
error_out = False
)
else: # Fallback to RecentlyUpdated #sortToken == "RecentlyUpdated":
RecentlyUpdatedGames = Universe.query.filter( and_( Universe.is_public == True, Universe.place_year == PlaceYear.Twenty )).order_by( Universe.updated_at.desc() )
UniverseObjsList = RecentlyUpdatedGames.paginate(
page = pageNumber,
per_page = maxRows,
error_out = False
)
UniverseObjsList : pagination.Pagination = UniverseObjsList
GameList = []
for UniverseObj in UniverseObjsList.items:
UniverseObj : Universe
PlaceObj : Place = Place.query.filter_by( placeid = UniverseObj.root_place_id ).first()
Upvotes, Downvotes = GetAssetLikesAndDislikes(PlaceObj.placeid)
PlaceAssetObj : Asset = PlaceObj.assetObj
CreatorObj : User | Group = User.query.filter_by(id=UniverseObj.creator_id).first() if UniverseObj.creator_type == 0 else Group.query.filter_by(id=UniverseObj.creator_id).first()
GameList.append({
"creatorId": UniverseObj.creator_id,
"creatorName": CreatorObj.username if UniverseObj.creator_type == 0 else CreatorObj.name,
"creatorType": "User" if UniverseObj.creator_type == 0 else "Group",
"creatorHasVerifiedBadge": False,
"totalUpVotes": Upvotes,
"totalDownVotes": Downvotes,
"universeId": UniverseObj.id,
"name": PlaceAssetObj.name,
"placeId": PlaceObj.placeid,
"playerCount": placeinfo.GetUniversePlayingCount(UniverseObj),
"imageToken": "",
"isSponsored": False,
"nativeAdData": "",
"isShowSponsoredLabel": False,
"price": 0,
"analyticsIdentifier": "",
"gameDescription": PlaceAssetObj.description,
"genre": "All",
"minimumAge": 0
})
return jsonify({
"games": GameList,
"suggestedKeyword": "",
"correctedKeyword": "",
"filteredKeyword": "",
"hasMoreRows": UniverseObjsList.has_next,
"nextPageExclusiveStartId": 0,
"featuredSearchUniverseId": 0,
"emphasis": False,
"cutOffIndex": 0,
"algorithm": "",
"algorithmQueryType": "",
"suggestionAlgorithm": "",
"relatedGames": []
})
@GamesAPIRoute.route("/v1/games/<int:placeId>/votes", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_place_votes( placeId : int ):
PlaceObj : Place = Place.query.filter_by(placeid=placeId).first()
if not PlaceObj:
return jsonify( { "errors": [ { "code": 0, "message": "Invalid placeId." } ] } ), 400
Upvotes, Downvotes = GetAssetLikesAndDislikes(PlaceObj.placeid)
return jsonify({
"id": PlaceObj.placeid,
"upVotes": Upvotes,
"downVotes": Downvotes
})
@GamesAPIRoute.route("/v1/games/<int:placeId>/votes/user", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_place_user_votes( placeId : int ):
PlaceObj : Place = Place.query.filter_by(placeid=placeId).first()
if not PlaceObj:
return jsonify( { "errors": [ { "code": 0, "message": "Invalid placeId." } ] } ), 400
return jsonify({
"canVote": False,
"userVote": False,
"reasonForNotVoteable": "Voting is disabled"
})
@GamesAPIRoute.route("/v1/games/multiget-playability-status", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_playability_status():
universeIdsList = request.args.get("universeIds", default = "", type = str)
if universeIdsList == "":
return jsonify( { "errors": [ { "code": 8, "message": "No universe IDs were specified." } ] } ), 400
try:
universeIdsList = universeIdsList.split(",")
universeIdsList = [int(x) for x in universeIdsList]
universeIdsList = list(set(universeIdsList))
except:
return jsonify( { "errors": [ { "code": 8, "message": "Invalid universe IDs were specified." } ] } ), 400
if len(universeIdsList) > 100:
return jsonify( { "errors": [ { "code": 9, "message": "Too many universe IDs were specified." } ] } ), 400
RequestedData = []
for universeId in universeIdsList:
RequestedData.append({
"playabilityStatus": 0,
"isPlayable": True,
"universeId": universeId
})
return jsonify(RequestedData), 200
@GamesAPIRoute.route("/v2/games/<int:universeId>/media", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_game_media( universeId : int ):
UniverseObj : Universe = Universe.query.filter_by(id = universeId).first()
if not UniverseObj:
return jsonify( { "errors": [ { "code": 2, "message": "The requested universe does not exist." } ] } ), 404
return jsonify({"data": []}), 200
@GamesAPIRoute.route("/v1/games", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def multi_get_game_info():
universeIdsList = request.args.get("universeIds", default = "", type = str)
if universeIdsList == "":
return jsonify( { "errors": [ { "code": 8, "message": "No universe IDs were specified." } ] } ), 400
try:
universeIdsList = universeIdsList.split(",")
universeIdsList = [int(x) for x in universeIdsList]
universeIdsList = list(set(universeIdsList))
except:
return jsonify( { "errors": [ { "code": 8, "message": "Invalid universe IDs were specified." } ] } ), 400
if len(universeIdsList) > 100:
return jsonify( { "errors": [ { "code": 9, "message": "Too many universe IDs were specified." } ] } ), 400
RequestedData = []
for universeId in universeIdsList:
UniverseObj : Universe = Universe.query.filter_by(id=universeId).first()
if UniverseObj is None:
continue
PlaceObj : Place = Place.query.filter_by( placeid = UniverseObj.root_place_id ).first()
if PlaceObj is None:
continue
PlaceAssetObj : Asset = PlaceObj.assetObj
CreatorObj : User | Group = User.query.filter_by(id=UniverseObj.creator_id).first() if UniverseObj.creator_type == 0 else Group.query.filter_by(id=UniverseObj.creator_id).first()
RequestedData.append({
"id": UniverseObj.id,
"rootPlaceId": UniverseObj.root_place_id,
"name": PlaceAssetObj.name,
"description": PlaceAssetObj.description,
"sourceName": PlaceAssetObj.name,
"sourceDescription": PlaceAssetObj.description,
"creator": {
"id": CreatorObj.id,
"type": "User" if UniverseObj.creator_type == 0 else "Group",
"name": CreatorObj.username if UniverseObj.creator_type == 0 else CreatorObj.name,
"hasVerifiedBadge": False,
"isRNVAccount": False
},
"price": 0,
"allowedGearGenres": [],
"allowedGearCategories": [],
"isGenreEnforced": True,
"copyingAllowed": False,
"playing": placeinfo.GetUniversePlayingCount(UniverseObj),
"visits": PlaceObj.visitcount,
"maxPlayers": PlaceObj.maxplayers,
"created": UniverseObj.created_at.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"updated": UniverseObj.updated_at.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"studioAccessToApisAllowed": False,
"createVipServersAllowed": False,
"universeAvatarType": 1,
"genre": "All",
"isAllGenre": True,
"isFavoritedByUser": False,
"favoritedCount": GetAssetFavoriteCount(PlaceObj.placeid)
})
return jsonify({
"data": RequestedData
}), 200
@GamesAPIRoute.route("/v1/games/<int:universeId>/favorites", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_game_favorites( universeId : int ):
UniverseObj : Universe = Universe.query.filter_by(id = universeId).first()
if not UniverseObj:
return jsonify( { "errors": [ { "code": 2, "message": "The requested universe does not exist." } ] } ), 404
AuthenticatedUser : User = auth.GetCurrentUser()
return jsonify({
"isFavorited": GetUserFavoriteStatus( universeId, AuthenticatedUser.id)
}), 200
@GamesAPIRoute.route("/v1/games/<int:universeId>/social-links/list", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_universe_social_links_list( universeId : int ):
UniverseObj : Universe = Universe.query.filter_by(id = universeId).first()
if not UniverseObj:
return jsonify( { "errors": [ { "code": 2, "message": "The requested universe does not exist." } ] } ), 404
return jsonify({
"data": []
}), 200
@GamesAPIRoute.route("/v1/games/<int:universeId>/game-passes", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_universe_game_passes( universeId : int ):
UniverseObj : Universe = Universe.query.filter_by(id = universeId).first()
if not UniverseObj:
return jsonify( { "errors": [ { "code": 2, "message": "The requested universe does not exist." } ] } ), 404
return jsonify({
"previousPageCursor": None,
"nextPageCursor": None,
"data": []
}), 200
@GamesAPIRoute.route("/v1/games/recommendations/game/<int:universeId>", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_recommended_games_from_universeid( universeId : int ):
UniverseObj : Universe = Universe.query.filter_by(id = universeId).first()
if not UniverseObj:
return jsonify( { "errors": [ { "code": 2, "message": "The requested universe does not exist." } ] } ), 404
return jsonify({
"games": [],
"nextPaginationKey": None
}), 200
+89
View File
@@ -0,0 +1,89 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify, make_response
from app.util import auth, friends, websiteFeatures
import logging
from app.services import economy
from app.extensions import db, limiter, csrf, get_remote_address
from app.models.gameservers import GameServer
from app.models.place_developer_product import DeveloperProduct
from app.models.product_receipt import ProductReceipt
GameTransactionsRoute = Blueprint("gametransactions", __name__, url_prefix="/")
@GameTransactionsRoute.before_request
def VerifyUserAgent():
RequestAddress = get_remote_address()
TargetServerObj : GameServer = GameServer.query.filter_by( serverIP = RequestAddress ).first()
if TargetServerObj is None:
abort(404)
AccessKey = request.headers.get( key = "AccessKey", default = None , type = str )
if AccessKey is None:
abort(403)
if AccessKey != TargetServerObj.accessKey:
abort(403)
@GameTransactionsRoute.route("/gametransactions/getpendingtransactions/", methods=["GET"])
def GetPendingTransactions():
PlayerId = request.args.get( key = "PlayerId", default = None, type = int )
PlaceId = request.args.get( key = "PlaceId", default = None, type = int )
if PlayerId is None or PlaceId is None:
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
PendingProductReceipts : list[ProductReceipt] = ProductReceipt.query.filter_by(
user_id = PlayerId, is_processed = False
).outerjoin(
DeveloperProduct, DeveloperProduct.productid == ProductReceipt.product_id
).filter( DeveloperProduct.placeid == PlaceId ).all()
PendingProductReceiptsDict = []
for PendingProductReceipt in PendingProductReceipts:
PendingProductReceiptsDict.append({
"playerId": PendingProductReceipt.user_id,
"placeId": PlaceId,
"receipt": PendingProductReceipt.receipt_id,
"actionArgs": [
{
"Key": "productId",
"Value": PendingProductReceipt.product_id
},
{
"Key": "currencyTypeId",
"Value": 1
},
{
"Key": "unitPrice",
"Value": PendingProductReceipt.robux_amount
}
]
})
return jsonify(PendingProductReceiptsDict)
@GameTransactionsRoute.route("/gametransactions/settransactionstatuscomplete", methods=["POST"])
@csrf.exempt
def SetTransactionStatusComplete():
receiptId : int = request.form.get( key = "receipt", default = None, type = int )
if receiptId is None:
logging.error("Invalid request")
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
ReceiptObj : ProductReceipt = ProductReceipt.query.filter_by( receipt_id = receiptId ).first()
if ReceiptObj is None:
logging.error("Receipt not found")
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
ReceiptObj.is_processed = True
db.session.commit()
return jsonify({
"success": True,
})
+652
View File
@@ -0,0 +1,652 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, Response
import hashlib
import gzip
import json
from PIL import Image
from app.models.groups import GroupIcon
from app.models.user_thumbnail import UserThumbnail
from app.models.asset_thumbnail import AssetThumbnail
from app.models.place_icon import PlaceIcon
from app.models.user import User
from app.models.asset import Asset
from app.routes.thumbnailer import TakeThumbnail
from app.util import s3helper
from app.extensions import redis_controller, csrf
from config import Config
from io import BytesIO
config = Config()
ImageRoute = Blueprint('image', __name__)
def HandleResolutionCheck(
WidthParametersName : list[ str ] = [ 'width', 'x' ],
HeightParametersName : list[ str ] = [ 'height', 'y' ],
AllowedWidths : list[ int ] = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
AllowedHeights : list[ int ] = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
MustBeSquare : bool = True,
CanRoundToNearest : bool = True
) -> ( int, int ):
"""
Handles resolution checking for images. Aborts the request if the resolution is invalid.
Must be called in a flask request context.
:param WidthParametersName: The names of valid parameters for width.
:param HeightParametersName: The names of valid parameters for height.
:param AllowedWidths: The allowed widths.
:param AllowedHeights: The allowed heights.
:param MustBeSquare: Whether or not the image must be square.
:param CanRoundToNearest: Whether or not the image can round to the nearest resolution.
:return: ( width, height )
"""
Width : int = None
Height : int = None
for WidthParameterName in WidthParametersName:
if WidthParameterName in request.args:
Width = request.args.get( key=WidthParameterName, default=None, type=int )
break
for HeightParameterName in HeightParametersName:
if HeightParameterName in request.args:
Height = request.args.get( key=HeightParameterName, default=None, type=int )
break
if Width is None or Height is None:
abort( 400 )
if ( Width not in AllowedWidths or Height not in AllowedHeights ) and not CanRoundToNearest:
abort( 400 )
if MustBeSquare and Width != Height:
abort( 400 )
if CanRoundToNearest:
if Width not in AllowedWidths:
Width = min( AllowedWidths, key=lambda x:abs(x-Width) )
if Height not in AllowedHeights:
Height = min( AllowedHeights, key=lambda x:abs(x-Height) )
return ( Width, Height )
def HandleImageResize(
ImageContentHash : str,
TargetWidth : int,
TargetHeight : int,
CroppedHash : str,
CacheControl : str = "max-age=120",
SkipCacheCroppedImage : bool = False,
ReturnAsJSON : bool = False
) -> Response:
"""
Handles image resizing
:param ImageContentHash: The content hash of the image.
:param TargetWidth: The target width.
:param TargetHeight: The target height.
:param CroppedHash: The hash of the cropped image.
:return: Flask Response
"""
if s3helper.DoesKeyExist(CroppedHash) and not SkipCacheCroppedImage:
if ReturnAsJSON:
return jsonify({
"Final": True,
"Url": f"{config.CDN_URL}/{CroppedHash}"
})
ImageResponse = make_response(redirect(f"{config.CDN_URL}/{CroppedHash}"))
ImageResponse.headers['Cache-Control'] = CacheControl
return ImageResponse
if not s3helper.DoesKeyExist(ImageContentHash):
if ReturnAsJSON:
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
return redirect("/static/img/placeholder.png")
ImageContent = BytesIO( s3helper.GetFileFromS3(ImageContentHash) )
ImageObj = Image.open(ImageContent)
ImageObj = ImageObj.resize((int(TargetWidth),int(TargetHeight))).convert('RGBA')
VirtualFile = BytesIO()
ImageObj.save(VirtualFile, "PNG")
VirtualFile.seek(0)
s3helper.UploadBytesToS3(VirtualFile.getvalue(), CroppedHash, contentType="image/png")
if ReturnAsJSON:
return jsonify({
"Final": True,
"Url": f"{config.CDN_URL}/{CroppedHash}"
})
ImageResponse = make_response(redirect(f"{config.CDN_URL}/{CroppedHash}"))
ImageResponse.headers['Cache-Control'] = CacheControl
return ImageResponse
@ImageRoute.route("/avatar-thumbnail/image", methods=["GET"])
@ImageRoute.route('/Thumbs/Avatar.ashx', methods=['GET'])
@ImageRoute.route('/thumbs/avatar.ashx', methods=['GET'])
def avatar():
userId = request.args.get('userId', default = None, type = int)
username = request.args.get('username', default = None, type = str)
if (userId is None and username is None):
return redirect("/static/img/placeholder.png")
TargetX, TargetY = HandleResolutionCheck(
WidthParametersName = [ 'x', 'width' ],
HeightParametersName = [ 'y', 'height' ],
AllowedWidths = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
AllowedHeights = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
MustBeSquare = True,
CanRoundToNearest = True
)
if username is not None and userId is None:
UserObj : User = User.query.filter_by(username=username).first()
if UserObj is None:
return redirect("/static/img/placeholder.png")
userId = UserObj.id
ThumbnailObj : UserThumbnail = UserThumbnail.query.filter_by(userid=userId).first()
if ThumbnailObj is None:
return redirect("/static/img/placeholder.png")
ContentHash = ThumbnailObj.full_contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetX}-{TargetY}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetX,
TargetHeight = TargetY,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
@ImageRoute.route('/avatar-thumbnail/json', methods=['GET'])
def avatar_json():
userId = request.args.get('userId', None, type=int)
if userId is None:
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
TargetWidth, TargetHeight = HandleResolutionCheck(
WidthParametersName = [ 'width', 'x' ],
HeightParametersName = [ 'height', 'y' ],
AllowedWidths = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
AllowedHeights = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
MustBeSquare = True,
CanRoundToNearest = True
)
thumbnail : UserThumbnail = UserThumbnail.query.filter_by(userid=userId).first()
if thumbnail is None:
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
ContentHash = thumbnail.full_contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetWidth,
TargetHeight = TargetHeight,
CroppedHash = CroppedHash,
CacheControl = "max-age=120",
ReturnAsJSON = True
)
@ImageRoute.route('/headshot-thumbnail/image', methods=['GET'])
@ImageRoute.route('/Thumbs/Head.ashx', methods=['GET'])
def head():
userId = request.args.get('userId', default = None, type = int)
if userId is None:
return redirect("/static/img/placeholder.png")
TargetX, TargetY = HandleResolutionCheck(
WidthParametersName = [ 'x', 'width' ],
HeightParametersName = [ 'y', 'height' ],
AllowedWidths = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
AllowedHeights = [ 48, 180, 420, 60, 100, 150, 352, 200, 500 ],
MustBeSquare = True,
CanRoundToNearest = True
)
ThumbnailObj : UserThumbnail = UserThumbnail.query.filter_by(userid=userId).first()
if ThumbnailObj is None:
return redirect("/static/img/placeholder.png")
ContentHash = ThumbnailObj.headshot_contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetX}-{TargetY}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetX,
TargetHeight = TargetY,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
@ImageRoute.route('/asset-thumbnail/json', methods=['GET'])
def asset_json():
assetId = request.args.get( 'assetId', None, type=int )
if assetId is None:
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
TargetWidth, TargetHeight = HandleResolutionCheck(
WidthParametersName = [ 'width', 'x' ],
HeightParametersName = [ 'height', 'y' ],
AllowedWidths = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,360,1280,720],
AllowedHeights = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,360,1280,720],
MustBeSquare = False,
CanRoundToNearest = True
)
thumbnailObj : AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=assetId).order_by(AssetThumbnail.asset_version_id.desc()).first()
if thumbnailObj is None:
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
if thumbnailObj.moderation_status != 0 or thumbnailObj.asset.moderation_status != 0:
if thumbnailObj.moderation_status == 2 or thumbnailObj.asset.moderation_status == 2:
return jsonify({
"Final": True,
"Url": "/static/img/ContentDeleted.png"
})
return jsonify({
"Final": False,
"Url": "/static/img/placeholder.png"
})
ContentHash = thumbnailObj.content_hash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetWidth,
TargetHeight = TargetHeight,
CroppedHash = CroppedHash,
CacheControl = "max-age=120",
ReturnAsJSON = True
)
@ImageRoute.route('/asset-thumbnail/image', methods=['GET'])
@ImageRoute.route('/thumbs/asset.ashx', methods=['GET'])
@ImageRoute.route('/Thumbs/Asset.ashx', methods=['GET'])
def asset():
assetId = request.args.get('assetId', default = None, type = int) or request.args.get('assetid', default = None, type = int)
if assetId is None:
return redirect("/static/img/placeholder.png")
TargetX, TargetY = HandleResolutionCheck(
WidthParametersName = [ 'x', 'width' ],
HeightParametersName = [ 'y', 'height' ],
AllowedWidths = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,360,1280,720],
AllowedHeights = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,36,1280,720],
MustBeSquare = False,
CanRoundToNearest = True
)
ThumbnailObj : AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=assetId).order_by(AssetThumbnail.asset_version_id.desc()).first()
if ThumbnailObj is None:
return redirect("/static/img/placeholder.png")
if ThumbnailObj.moderation_status != 0 or ThumbnailObj.asset.moderation_status != 0:
if ThumbnailObj.moderation_status == 2 or ThumbnailObj.asset.moderation_status == 2:
return redirect("/static/img/ContentDeleted.png")
return redirect("/static/img/placeholder.png")
ContentHash = ThumbnailObj.content_hash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetX}-{TargetY}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetX,
TargetHeight = TargetY,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
@ImageRoute.route('/Thumbs/GroupIcon.ashx', methods=['GET'])
def groupicon():
groupid = request.args.get( key='groupid', default=None, type=int )
if groupid is None:
return redirect("/static/img/placeholder.png")
TargetX, TargetY = HandleResolutionCheck(
WidthParametersName = [ 'x', 'width' ],
HeightParametersName = [ 'y', 'height' ],
AllowedWidths = [48,180,420,60,100,150,352],
AllowedHeights = [48,180,420,60,100,150,352],
MustBeSquare = True,
CanRoundToNearest = True
)
ThumbnailObj : GroupIcon = GroupIcon.query.filter_by(group_id=groupid).first()
if ThumbnailObj is None:
return redirect("/static/img/placeholder.png")
if ThumbnailObj.moderation_status != 0:
if ThumbnailObj.moderation_status == 2:
return redirect("/static/img/ContentDeleted.png")
return redirect("/static/img/placeholder.png")
ContentHash = ThumbnailObj.content_hash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetX}-{TargetY}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetX,
TargetHeight = TargetY,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
@ImageRoute.route('/Thumbs/GameIcon.ashx', methods=['GET'])
@ImageRoute.route('/Thumbs/PlaceIcon.ashx', methods=['GET'])
def placeicon():
assetId = request.args.get('assetId', default = None, type = int) or request.args.get('assetid', default = None, type = int)
if assetId is None:
return redirect("/static/img/placeholder.png")
TargetX, TargetY = HandleResolutionCheck(
WidthParametersName = [ 'x', 'width' ],
HeightParametersName = [ 'y', 'height' ],
AllowedWidths = [48,180,420,60,100,150,352,324,576],
AllowedHeights = [48,180,420,60,100,150,352,324,576],
MustBeSquare = False,
CanRoundToNearest = True
)
PlaceIconObj : PlaceIcon = PlaceIcon.query.filter_by(placeid=assetId).first()
if PlaceIconObj is None:
return redirect("/static/img/placeholder.png")
if PlaceIconObj.moderation_status != 0 or PlaceIconObj.asset.moderation_status != 0:
if PlaceIconObj.moderation_status == 2 or PlaceIconObj.asset.moderation_status == 2:
return redirect("/static/img/ContentDeleted.png")
return redirect("/static/img/placeholder.png")
ContentHash = PlaceIconObj.contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetX}-{TargetY}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetX,
TargetHeight = TargetY,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
@ImageRoute.route('/Game/Tools/ThumbnailAsset.ashx', methods=['GET'])
def thumbnail_asset():
ExpectedFormat : str = request.args.get( key='fmt', default='png', type=str )
AssetId : int = request.args.get( key='aid', default=None, type=int )
if AssetId is None:
return redirect("/static/img/placeholder.png")
if ExpectedFormat.lower() != "png":
return redirect("/static/img/placeholder.png")
AssetObj : Asset = Asset.query.filter_by(id=AssetId).first()
if AssetObj is None:
return redirect("/static/img/placeholder.png")
TargetWidth, TargetHeight = HandleResolutionCheck(
WidthParametersName = [ 'wd', 'width' ],
HeightParametersName = [ 'ht', 'height' ],
AllowedWidths = [48,180,420,60,100,150,352,75],
AllowedHeights = [48,180,420,60,100,150,352,75],
MustBeSquare = True,
CanRoundToNearest = True
)
thumbnail : AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=AssetId).order_by(AssetThumbnail.asset_version_id.desc()).first()
if thumbnail is None:
TakeThumbnail(AssetId)
return redirect("/static/img/placeholder.png")
if thumbnail.moderation_status != 0:
if thumbnail.moderation_status == 2 or thumbnail.asset.moderation_status == 2:
return redirect("/static/img/ContentDeleted.png")
return redirect("/static/img/placeholder.png")
ContentHash = thumbnail.content_hash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
return HandleImageResize(
ImageContentHash = ContentHash,
TargetWidth = TargetWidth,
TargetHeight = TargetHeight,
CroppedHash = CroppedHash,
CacheControl = "max-age=120"
)
import urllib.parse
@ImageRoute.route("/v1/batch", methods=["POST"])
@csrf.exempt
def BatchImageRequest():
if request.headers.get("Content-Encoding") == "gzip":
try:
data = gzip.decompress(request.data)
except Exception as e:
return jsonify({"success": False, "message": "Invalid gzip data"}), 400
try:
JSONData = json.loads(data)
except Exception as e:
return jsonify({"success": False, "message": "Invalid JSON data"}), 400
else:
JSONData = request.json
if JSONData is None:
return jsonify({"success": False, "message": "Missing JSON data"}), 400
# [{'requestId': 'type=GameIcon&id=1&w=128&h=128&filters=', 'targetId': 1, 'type': 'GameIcon', 'size': '128x128', 'isCircular': False}]
if len(JSONData) > 15:
return jsonify({"success": False, "message": "Too many requests"}), 400
if len(JSONData) == 0:
return jsonify({"data":[]}), 200
ProcessedRequests = []
for RequestObj in JSONData:
if "requestId" not in RequestObj or "targetId" not in RequestObj or "type" not in RequestObj or "size" not in RequestObj:
continue
if RequestObj["type"] not in [ "Avatar", "AvatarHeadShot", "GameIcon", "GameThumbnail", "Asset", "GroupIcon"]:
continue
if "x" not in RequestObj["size"]:
continue
SplittedSize = RequestObj["size"].split("x")
if len(SplittedSize) != 2:
continue
try:
TargetWidth = int(SplittedSize[0])
TargetHeight = int(SplittedSize[1])
except:
continue
AllowedSizes = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,36,1280,720]
TargetWidth = min(AllowedSizes, key=lambda x:abs(x-TargetWidth))
TargetHeight = min(AllowedSizes, key=lambda x:abs(x-TargetHeight))
RequestType = RequestObj["type"]
if RequestType == "Avatar":
ThumbnailObj : UserThumbnail = UserThumbnail.query.filter_by(userid=RequestObj["targetId"]).first()
if ThumbnailObj is None:
continue
ContentHash = ThumbnailObj.full_contenthash
elif RequestType == "AvatarHeadShot":
ThumbnailObj : UserThumbnail = UserThumbnail.query.filter_by(userid=RequestObj["targetId"]).first()
if ThumbnailObj is None:
continue
ContentHash = ThumbnailObj.headshot_contenthash
elif RequestType == "GameIcon":
PlaceIconObj : PlaceIcon = PlaceIcon.query.filter_by(placeid=RequestObj["targetId"]).first()
if PlaceIconObj is None:
continue
ContentHash = PlaceIconObj.contenthash
elif RequestType == "GameThumbnail" or RequestType == "Asset":
thumbnailObj : AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=RequestObj["targetId"]).order_by(AssetThumbnail.asset_version_id.desc()).first()
if thumbnailObj is None:
continue
if thumbnailObj.moderation_status != 0:
continue
ContentHash = thumbnailObj.content_hash
elif RequestType == "GroupIcon":
ThumbnailObj : GroupIcon = GroupIcon.query.filter_by(group_id=RequestObj["targetId"]).first()
if ThumbnailObj is None:
continue
if ThumbnailObj.moderation_status != 0:
continue
ContentHash = ThumbnailObj.content_hash
else:
continue
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
if not s3helper.DoesKeyExist(CroppedHash):
if not s3helper.DoesKeyExist(ContentHash):
continue
ImageContent = BytesIO( s3helper.GetFileFromS3(ContentHash) )
ImageObj = Image.open(ImageContent)
ImageObj = ImageObj.resize((int(TargetWidth),int(TargetHeight))).convert('RGBA')
VirtualFile = BytesIO()
ImageObj.save(VirtualFile, "PNG")
VirtualFile.seek(0)
s3helper.UploadBytesToS3(VirtualFile.getvalue(), CroppedHash, contentType="image/png")
ProcessedRequests.append({
"requestId": RequestObj["requestId"],
"targetId": RequestObj["targetId"],
"state": "Completed",
"imageUrl": f"{config.CDN_URL}/{CroppedHash}",
"version": None
})
return jsonify({
"data": ProcessedRequests
})
@ImageRoute.route("/v1/users/avatar-headshot", methods=["GET"])
def multi_avatar_headshot():
userIdsCSV = request.args.get('userIds', default = None, type = str)
if userIdsCSV is None:
return jsonify( { "errors": [ { "code": 4, "message": "The requested Ids are invalid, of an invalid type or missing." } ] } ), 400
userIds = userIdsCSV.split(",")
if len(userIds) > 100:
return jsonify( { "errors": [ { "code": 1, "message": "There are too many requested Ids." } ] } ), 400
requestedSize = request.args.get('size', default = "48x48", type = str)
if "x" not in requestedSize:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
SplittedSize = requestedSize.split("x")
if len(SplittedSize) != 2:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
try:
TargetWidth = int(SplittedSize[0])
TargetHeight = int(SplittedSize[1])
AllowedSizes = [48,180,420,60,100,150,352,396,480,512,576,700,768,640,36,1280,720]
TargetWidth = min(AllowedSizes, key=lambda x:abs(x-TargetWidth))
TargetHeight = min(AllowedSizes, key=lambda x:abs(x-TargetHeight))
except:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
ProcessedRequests = []
for userId in userIds:
try:
userId = int(userId)
except:
continue
ThumbnailObj : UserThumbnail = UserThumbnail.query.filter_by(userid=userId).first()
if ThumbnailObj is None:
continue
ContentHash = ThumbnailObj.headshot_contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
if not s3helper.DoesKeyExist(CroppedHash):
if not s3helper.DoesKeyExist(ContentHash):
continue
ImageContent = BytesIO( s3helper.GetFileFromS3(ContentHash) )
ImageObj = Image.open(ImageContent)
ImageObj = ImageObj.resize((int(TargetWidth),int(TargetHeight))).convert('RGBA')
VirtualFile = BytesIO()
ImageObj.save(VirtualFile, "PNG")
VirtualFile.seek(0)
s3helper.UploadBytesToS3(VirtualFile.getvalue(), CroppedHash, contentType="image/png")
ProcessedRequests.append({
"targetId": userId,
"state": "Completed",
#"imageUrl": f"{config.CDN_URL}/{CroppedHash}", # 2020 Does not allow things like cdn.syntax.eco to be used directly in the "Texture" property so we have to redirect them to an allowed route on the www. domain
"imageUrl": f"{config.BaseURL}/headshot-thumbnail/image?userId={userId}&x={TargetWidth}&y={TargetHeight}",
"version": "1"
})
return jsonify({
"data": ProcessedRequests
}), 200
@ImageRoute.route("/v1/games/icons", methods=["GET"])
def get_game_icons():
universeIdsCSV = request.args.get('universeIds', default = None, type = str)
if universeIdsCSV is None:
return jsonify( { "errors": [ { "code": 4, "message": "The requested Ids are invalid, of an invalid type or missing." } ] } ), 400
universeIdsList = universeIdsCSV.split(",")
if len(universeIdsList) > 100:
return jsonify( { "errors": [ { "code": 1, "message": "There are too many requested Ids." } ] } ), 400
requestedSize = request.args.get('size', default = "50x50", type = str)
if "x" not in requestedSize:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
SplittedSize = requestedSize.split("x")
if len(SplittedSize) != 2:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
try:
TargetWidth = int(SplittedSize[0])
TargetHeight = int(SplittedSize[1])
AllowedSizes = [50, 128, 150, 256, 420, 512]
TargetWidth = min(AllowedSizes, key=lambda x:abs(x-TargetWidth))
TargetHeight = min(AllowedSizes, key=lambda x:abs(x-TargetHeight))
except:
return jsonify( { "errors": [ { "code": 3, "message": "The requested size is invalid. Please see documentation for valid thumbnail size parameter name and format." } ] } ), 400
ProcessedRequests = []
for universeId in universeIdsList:
try:
universeId = int(universeId)
except:
continue
PlaceIconObj : PlaceIcon = PlaceIcon.query.filter_by(placeid=universeId).first()
if PlaceIconObj is None:
continue
ContentHash = PlaceIconObj.contenthash
CroppedHash = hashlib.sha512(f"{ContentHash}-{TargetWidth}-{TargetHeight}-v3".encode('utf-8')).hexdigest()
if not s3helper.DoesKeyExist(CroppedHash):
if not s3helper.DoesKeyExist(ContentHash):
continue
ImageContent = BytesIO( s3helper.GetFileFromS3(ContentHash) )
ImageObj = Image.open(ImageContent)
ImageObj = ImageObj.resize((int(TargetWidth),int(TargetHeight))).convert('RGBA')
VirtualFile = BytesIO()
ImageObj.save(VirtualFile, "PNG")
VirtualFile.seek(0)
s3helper.UploadBytesToS3(VirtualFile.getvalue(), CroppedHash, contentType="image/png")
ProcessedRequests.append({
"targetId": universeId,
"state": "Completed",
#"imageUrl": f"{config.CDN_URL}/{CroppedHash}"
"imageUrl": f"{config.BaseURL}/Thumbs/GameIcon.ashx?assetId={str(universeId)}&x={str(TargetWidth)}&y={str(TargetHeight)}"
})
return jsonify({
"data": ProcessedRequests
}), 200
+181
View File
@@ -0,0 +1,181 @@
# inventory.roblox.com
from flask import Blueprint, jsonify, request, make_response
from flask_wtf.csrf import CSRFError, generate_csrf
from app.extensions import db, redis_controller, limiter, csrf
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.asset import Asset
from app.util import membership
from app.enums.AssetType import AssetType
from app.enums.MembershipType import MembershipType
InventoryAPI = Blueprint('InventoryAPI', __name__, url_prefix='/')
csrf.exempt(InventoryAPI)
@InventoryAPI.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@InventoryAPI.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@InventoryAPI.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
itemTypes = {
"asset": 0,
"gamepass": 1,
"badge": 2,
"bundle": 3
}
@InventoryAPI.route('/v1/users/<int:userId>/items/<itemType>/<int:itemTargetId>', methods=['GET'])
@limiter.limit("60/minute")
def get_user_item(userId : int , itemType : str , itemTargetId : int):
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify( { "errors": [ { "code": 1, "message": "The specified user does not exist!" } ] } ), 400
try:
itemType = int(itemType)
if itemType < 0 or itemType > 3:
return jsonify( { "errors": [ { "code": 6, "message": "The specified item type does not exist." } ] } ), 400
except ValueError:
if itemType.lower() not in itemTypes:
return jsonify( { "errors": [ { "code": 7, "message": "The specified Asset does not exist!" } ] } ), 400
itemType = itemTypes[itemType.lower()]
assetObj : Asset = Asset.query.filter_by(id=itemTargetId).first()
if assetObj is None:
return jsonify( { "errors": [ { "code": 3, "message": "The specified item does not exist!" } ] } ), 400
dataList = []
userAssetList : list[UserAsset] = UserAsset.query.filter_by(userid=userId, assetid=itemTargetId).all()
for user_asset_obj in userAssetList:
dataList.append({
"type": "Asset",
"id": user_asset_obj.assetid,
"name": user_asset_obj.asset.name,
"instanceId": user_asset_obj.id,
})
return jsonify({
"previousPageCursor": None,
"nextPageCursor": None,
"data": dataList
}), 200
@InventoryAPI.route('/v1/users/<int:userId>/items/<itemType>/<int:itemTargetId>/is-owned', methods=['GET'])
@limiter.limit("60/minute")
def lookup_user_ownership(userId : int , itemType , itemTargetId : int):
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify( { "errors": [ { "code": 1, "message": "The specified user does not exist!" } ] } ), 400
try:
itemType = int(itemType)
if itemType < 0 or itemType > 3:
return jsonify( { "errors": [ { "code": 6, "message": "The specified item type does not exist." } ] } ), 400
except ValueError:
if itemType.lower() not in itemTypes:
return jsonify( { "errors": [ { "code": 7, "message": "The specified Asset does not exist!" } ] } ), 400
itemType = itemTypes[itemType.lower()]
assetObj : Asset = Asset.query.filter_by(id=itemTargetId).first()
if assetObj is None:
return jsonify( { "errors": [ { "code": 3, "message": "The specified item does not exist!" } ] } ), 400
userAssetObj : UserAsset = UserAsset.query.filter_by(userid=userId, assetid=itemTargetId).first()
if userAssetObj is None:
return "false", 200
return "true", 200
#@InventoryAPI.route('/v2/users/<int:userId>/inventory', methods=['GET'])
@InventoryAPI.route('/v2/users/<int:userId>/inventory/<int:assetTypeId>/', methods=['GET'])
@InventoryAPI.route('/v2/users/<int:userId>/inventory/<int:assetTypeId>', methods=['GET'])
@limiter.limit("60/minute")
def get_user_inventory(userId : int, assetTypeId : int = None):
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify( { "errors": [ { "code": 1, "message": "Invalid user Id." } ] } ), 400
cursorPage : int = request.args.get("cursor", default = 1, type = int)
if cursorPage < 1:
return jsonify( { "errors": [ { "code": 4, "message": "The specified cursor is invalid!" } ] } ), 400
pageLimit : int = request.args.get("limit", default = 10, type = int)
if pageLimit not in [10, 25, 50, 100]:
return jsonify( { "errors": [ { "code": 5, "message": "The specified limit is invalid!" } ] } ), 400
sortOrder : str = request.args.get("sortOrder", default = "Asc", type = str)
if sortOrder.lower() not in ["asc", "desc"]:
return jsonify( { "errors": [ { "code": 6, "message": "The specified sort order is invalid!" } ] } ), 400
try:
AssetTypeEnum : AssetType = AssetType(assetTypeId)
except ValueError:
return jsonify( { "errors": [ { "code": 2, "message": "Invalid asset type Id." } ] } ), 400
UserAssetList : list[UserAsset] = UserAsset.query.filter_by(userid=userId).outerjoin(
Asset, UserAsset.assetid == Asset.id
).filter(
Asset.asset_type == AssetTypeEnum
).order_by(
UserAsset.updated.desc() if sortOrder.lower() == "desc" else UserAsset.updated.asc()
).paginate(
page = cursorPage,
per_page = pageLimit,
error_out = False
)
UserMembershipType : int = membership.GetUserMembership( userObject ).value
dataList = []
for user_asset_obj in UserAssetList.items:
user_asset_obj : UserAsset = user_asset_obj
dataList.append({
"userAssetId": user_asset_obj.id,
"assetId": user_asset_obj.assetid,
"assetName": user_asset_obj.asset.name,
"collectibleItemId": None,
"collectibleItemInstanceId": None,
"serialNumber": user_asset_obj.serial,
"owner": {
"userId": user_asset_obj.userid,
"username": userObject.username,
"buildersClubMembershipType": UserMembershipType
},
"created": user_asset_obj.created.isoformat(),
"updated": user_asset_obj.updated.isoformat(),
})
return jsonify({
"previousPageCursor": str(UserAssetList.prev_num) if UserAssetList.has_prev else None,
"nextPageCursor": str(UserAssetList.next_num) if UserAssetList.has_next else None,
"data": dataList
}), 200
+810
View File
@@ -0,0 +1,810 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort
from app.extensions import db, redis_controller, csrf, get_remote_address
import requests
import logging
import json
import gzip
import json
import math
from datetime import datetime, timedelta
from app.models.user import User
from app.models.asset import Asset
from app.models.usereconomy import UserEconomy
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.groups import Group
from app.models.game_session_log import GameSessionLog
from app.models.universe import Universe
from app.models.user_ban import UserBan
from app.services.economy import IncrementTargetBalance
from app.services.gameserver_comm import perform_post
from app.pages.home.home import InsertRecentlyPlayed
from app.enums.TransactionType import TransactionType
from app.enums.PlaceYear import PlaceYear
from app.enums.BanType import BanType
from app.util.placeinfo import ClearPlayingCountCache, GetPlayingCount
from app.util.transactions import CreateTransaction
from config import Config
config = Config()
JobReportHandler = Blueprint('jobreporthandler', __name__, url_prefix='/')
def isValidAuthorizationToken( authtoken : str) -> GameServer:
if authtoken is None:
return None
RequestAddress = get_remote_address()
GameServerObject = GameServer.query.filter_by( accessKey = authtoken, serverIP = RequestAddress ).first()
return GameServerObject
class InvalidGZIPData(Exception):
pass
class InvalidJSONData(Exception):
pass
def IncrementPlaceVisits( PlaceObj : Place ):
PlaceObj.visitcount += 1
UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
if UniverseObj is None:
return
UniverseObj.visit_count += 1
db.session.commit()
def ParsePayloadData(throwException : bool = True):
"""
Handles RCC Post Data and returns the JSON data
"""
if request.headers.get("Content-Encoding") == "gzip":
try:
data = gzip.decompress(request.data)
except Exception as e:
raise InvalidGZIPData("Invalid gzip data")
try:
JSONData = json.loads(data)
except Exception as e:
raise InvalidJSONData("Invalid JSON data")
else:
JSONData = request.json
if JSONData is None:
raise InvalidJSONData("Invalid JSON data")
return JSONData
def EvictPlayer( PlaceServerObject : PlaceServer, UserId : int ):
PlaceObj : Place = Place.query.filter_by( placeid = PlaceServerObject.serverPlaceId ).first()
MasterServer : GameServer | None = GameServer.query.filter_by(serverId=PlaceServerObject.originServerId).first()
try:
if PlaceObj.placeyear in [PlaceYear.Eighteen, PlaceYear.Twenty, PlaceYear.Sixteen]:
if PlaceObj.placeyear in [PlaceYear.Eighteen, PlaceYear.Twenty]:
ExecutionScript = f"""{{
"Mode": "EvictPlayer",
"MessageVersion": 1,
"Settings": {{
"PlayerId": {str(UserId)}
}}
}}"""
elif PlaceObj.placeyear in [PlaceYear.Sixteen]:
ExecutionScript = f"""for _, Player in pairs(game:GetService("Players"):GetPlayers()) do if Player.UserId == {UserId} then Player:Kick("Disconnected from game, possibly due to game joined from another device") end end"""
else:
raise Exception("PlaceYear is not compatible")
ExecuteScriptRequest = perform_post(
TargetGameserver = MasterServer,
Endpoint = "Execute",
JSONData = {
"jobid": str(PlaceServerObject.serveruuid),
"script": ExecutionScript,
"arguments": []
}
)
if ExecuteScriptRequest.status_code != 200:
raise Exception(f"Unexpected Status Code: {ExecuteScriptRequest.status_code}, {ExecuteScriptRequest.content}")
elif PlaceObj.placeyear in [PlaceYear.Fourteen]:
redis_controller.set(f"EvictPlayerRequest:{PlaceServerObject.serveruuid}:{UserId}", 1, ex=60)
StartTime = datetime.utcnow()
while redis_controller.exists(f"EvictPlayerRequest:{PlaceServerObject.serveruuid}:{UserId}"):
if (datetime.utcnow() - StartTime).total_seconds() > 50:
raise Exception("Failed to evict player")
else:
raise Exception("PlaceYear is not compatible")
except Exception as e:
logging.error(f"EvictPlayer failed to send request to kick player, {e}")
@JobReportHandler.before_request
def before_request():
requesterAddress = get_remote_address()
if requesterAddress is None:
logging.warning(f"Request rejected: ip not found")
return abort(404)
gameserverObj : GameServer = GameServer.query.filter_by(serverIP=requesterAddress).first()
if gameserverObj is None:
logging.warning(f"No gamesever found us stupid {requesterAddress}")
return abort(404)
if "UserRequest" in request.headers.get( key = "accesskey", default = "" ):
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
@JobReportHandler.route('/internal/gameserver/reportshutdown', methods=['POST'])
@csrf.exempt
def reportshutdown():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
if "AuthToken" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
PlaceServerOwner : GameServer | None = isValidAuthorizationToken(JSONData["AuthToken"])
if PlaceServerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}),400
placeId = JSONData["PlaceId"]
jobId = JSONData["JobId"]
PlaceServerObject : PlaceServer = PlaceServer.query.filter_by(serverPlaceId=placeId, serveruuid=jobId).first()
if PlaceServerObject is None:
return jsonify({"status": "error", "message": "Invalid place server"}),400
try:
logging.info(f"CloseJob : Closing {jobId} for place [{placeId}] because server reports shutdown")
perform_post(
TargetGameserver = PlaceServerOwner,
Endpoint = "CloseJob",
JSONData = {
"jobid": jobId,
}
)
except Exception as e:
logging.error(f"Failed to close job ( {jobId} ) for place ( {placeId} ), {e}")
PlaceServerOwner : GameServer = GameServer.query.filter_by(serverId=PlaceServerObject.originServerId).first()
PlaceServerPlayers = PlaceServerPlayer.query.filter_by(serveruuid=jobId).all()
for PlaceServerPlayerObject in PlaceServerPlayers:
db.session.delete(PlaceServerPlayerObject)
db.session.delete(PlaceServerObject)
db.session.commit()
PlaceObj : Place = Place.query.filter_by(placeid=placeId).first()
if PlaceObj is not None:
ClearPlayingCountCache(PlaceObj)
return jsonify({"status": "success"}),200
@JobReportHandler.route('/internal/gameserver/reportstats', methods=['POST'])
@csrf.exempt
def reportstats():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
if "AuthToken" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
PlaceServerOwner = isValidAuthorizationToken(JSONData["AuthToken"])
if PlaceServerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}),400
placeId = JSONData["PlaceId"]
jobId = JSONData["JobId"]
serverAliveTime = JSONData["ServerAliveTime"]
PlaceServerObject : PlaceServer = PlaceServer.query.filter_by(serverPlaceId=placeId, serveruuid=jobId).first()
if PlaceServerObject is None:
return jsonify({"status": "success"}),200
PlaceServerObject.lastping = datetime.utcnow()
PlaceServerObject.serverRunningTime = serverAliveTime
db.session.commit()
return jsonify({"status": "success"}),200
@JobReportHandler.route('/internal/gameserver/reportplacevalidation', methods=['POST'])
@csrf.exempt
def reportplacevalidation():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
if "AuthToken" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
PlaceServerOwner = isValidAuthorizationToken(JSONData["AuthToken"])
if PlaceServerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}),400
ValidationRequestId = JSONData["ReqId"]
LoadSuccess = JSONData["LoadSuccess"]
ErrorMessage = None
if "ErrorMessage" in JSONData:
ErrorMessage = JSONData["ErrorMessage"]
redis_controller.set(f"ValidatePlaceFileRequest:{ValidationRequestId}", json.dumps({
"valid": LoadSuccess,
"error": ErrorMessage
}), ex=600)
logging.info(f"Place validation request ( {ValidationRequestId} ) has been completed")
return jsonify({"status": "success"}),200
def HandleUserTimePlayed( UserObj : User, Timeplayed : int, serverUUID : str = None, placeId : int = None ):
"""
For every 40 seconds the user has played a game we give them 1 ticket
however we limit the tickets to 100 per day and the user account must be
more than 2 days old
"""
if serverUUID is not None and placeId is not None:
GameSessionLogObject : GameSessionLog = GameSessionLog.query.filter_by(serveruuid=serverUUID, place_id=placeId, user_id=UserObj.id).first()
if GameSessionLogObject is None:
GameSessionLogObject = GameSessionLog(
user_id = UserObj.id,
serveruuid = serverUUID,
place_id = placeId,
joined_at = datetime.utcnow() - timedelta(seconds=Timeplayed),
left_at = datetime.utcnow()
)
db.session.add(GameSessionLogObject)
else:
GameSessionLogObject.left_at = datetime.utcnow()
db.session.commit()
if datetime.utcnow() - timedelta(days=2) < UserObj.created:
return
RawTicketsEarned = math.floor(Timeplayed / 40)
CurrentDay = datetime.utcnow().day
TicketsEarnedToday = redis_controller.get(f"UserTicketsEarned:{UserObj.id}:{CurrentDay}")
if TicketsEarnedToday is None:
TicketsEarnedToday = 0
else:
TicketsEarnedToday = int(TicketsEarnedToday)
if TicketsEarnedToday >= 500:
return
TicketsToGive = 500 - TicketsEarnedToday
if RawTicketsEarned > TicketsToGive:
RawTicketsEarned = TicketsToGive
if RawTicketsEarned <= 0:
return
IncrementTargetBalance(UserObj, RawTicketsEarned, 1)
CreateTransaction(
Reciever = UserObj,
Sender = User.query.filter_by(id=1).first(),
CurrencyAmount = RawTicketsEarned,
CurrencyType = 1,
TransactionType = TransactionType.BuildersClubStipend,
AssetId = None,
CustomText = f"Played game for {str(round(Timeplayed,1))} seconds"
)
AmountOfTicketsEarnedToday = TicketsEarnedToday + RawTicketsEarned
redis_controller.set(f"UserTicketsEarned:{UserObj.id}:{CurrentDay}", AmountOfTicketsEarnedToday, ex=86400)
@JobReportHandler.route('/internal/gameserver/verifyplayer', methods=['POST'])
@csrf.exempt
def verifyplayer():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
if "AuthToken" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
PlaceServerOwner = isValidAuthorizationToken(JSONData["AuthToken"])
if PlaceServerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}),400
jobId = JSONData["JobId"]
PlaceServerObject : PlaceServer = PlaceServer.query.filter_by(serveruuid=jobId).first()
if PlaceServerObject is None:
return jsonify({"status": "error", "message": "Invalid place server"}),400
PlaceServerObject.lastping = datetime.utcnow()
PlaceObject : Place = Place.query.filter_by(placeid=PlaceServerObject.serverPlaceId).first()
if PlaceObject is None:
return jsonify({"status": "error", "message": "Invalid place"}),400
UserId = JSONData["UserId"]
UserObject : User = User.query.filter_by(id=UserId).first()
if UserObject is None or UserObject.accountstatus != 1:
return jsonify({"status": "error", "message": "Invalid user", "authenticated": False}), 200
if "Username" not in JSONData or "CharacterAppearance" not in JSONData or "VerificationTicket" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data", "authenticated": False}), 200
Username = JSONData["Username"]
CharacterAppearance = JSONData["CharacterAppearance"]
VerificationTicket = JSONData["VerificationTicket"]
authKeyName = f"joinashx-auth:{str(jobId)}:{str(UserId)}:{str(PlaceObject.placeid)}:{VerificationTicket}"
if not redis_controller.exists(authKeyName):
return jsonify({"status": "error", "message": "Invalid join request", "authenticated": False}), 200
JoinInfo = json.loads(redis_controller.get(authKeyName))
if JoinInfo is None:
return jsonify({"status": "error", "message": "Invalid join request", "authenticated": False}), 200
redis_controller.delete(authKeyName)
if JoinInfo["CharacterAppearance"] != CharacterAppearance or JoinInfo["Username"] != Username:
return jsonify({"status": "error", "message": "Invalid join request", "authenticated": False}), 200
return jsonify({"status": "success", "message": "Valid join request", "authenticated": True}), 200
@JobReportHandler.route('/internal/gameserver/reportplayers', methods=['POST'])
@csrf.exempt
def reportplayers():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
if "AuthToken" not in JSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
PlaceServerOwner = isValidAuthorizationToken(JSONData["AuthToken"])
if PlaceServerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}),400
jobId = JSONData["JobId"]
players = JSONData["Players"] # Array of players in the server which each player is a dictionary ( { "UserId": 1, "Name": "test" } )
playerCount = len(players)
PlaceServerObject : PlaceServer = PlaceServer.query.filter_by(serveruuid=jobId).first()
if PlaceServerObject is None:
return jsonify({"status": "error", "message": "Invalid place server"}),400
PlaceServerObject.lastping = datetime.utcnow()
PlaceObject : Place = Place.query.filter_by(placeid=PlaceServerObject.serverPlaceId).first()
if PlaceObject is None:
return jsonify({"status": "error", "message": "Invalid place"}),400
AssetObject : Asset = Asset.query.filter_by(id=PlaceObject.placeid).first()
CreatorObject : User | Group = None
if AssetObject.creator_type == 0:
CreatorObject = User.query.filter_by(id=AssetObject.creator_id).first()
else:
CreatorObject = Group.query.filter_by(id=AssetObject.creator_id).first()
BadPlayers = [] # Array of players which must be kicked from the server
for player in players:
UserObject : User = User.query.filter_by(id=player["UserId"]).first()
if UserObject is None or UserObject.username != player["Name"] or UserObject.accountstatus != 1:
logging.info(f"/internal/gameserver/reportplayers - {jobId} - Invalid Player ( {player['UserId']} ) - {player['Name']}")
BadPlayers.append(player["UserId"])
playerCount -= 1
continue
if redis_controller.exists(f"EvictPlayerRequest:{jobId}:{player['UserId']}"):
redis_controller.delete(f"EvictPlayerRequest:{jobId}:{player['UserId']}")
logging.info(f"/internal/gameserver/reportplayers - {jobId} - Player ( {player['UserId']} ) requested kick")
BadPlayers.append(player["UserId"])
playerCount -= 1
continue
PlaceServerPlayerObject : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(userid=player["UserId"]).first()
if PlaceServerPlayerObject is None:
if not redis_controller.exists(f"allow_join:{str(player['UserId'])}:{PlaceObject.placeid}:{str(jobId)}"):
logging.info(f"/internal/gameserver/reportplayers - {jobId} - Player ( {player['UserId']} ) is not allowed to join")
BadPlayers.append(player["UserId"])
playerCount -= 1
continue
PlaceServerPlayerObject = PlaceServerPlayer(serveruuid=jobId, userid=player["UserId"], joinTime= datetime.utcnow())
IncrementPlaceVisits(PlaceObject)
if CreatorObject is not None:
IncrementTargetBalance(CreatorObject, 1, 1)
UserObject.lastonline = datetime.utcnow()
InsertRecentlyPlayed(UserObj = UserObject, PlaceId = PlaceObject.placeid)
db.session.add(PlaceServerPlayerObject)
else:
UserId = player["UserId"]
if str(PlaceServerPlayerObject.serveruuid) == jobId:
PlaceServerPlayerObject.lastHeartbeat = datetime.utcnow()
UserObject.lastonline = datetime.utcnow()
else:
OtherPlaceServerObj = PlaceServer.query.filter_by(serveruuid=PlaceServerPlayerObj.serveruuid).first()
if OtherPlaceServerObj is not None:
EvictPlayer( OtherPlaceServerObj, UserId )
TotalTimePlayed = (datetime.utcnow() - PlaceServerPlayerObject.joinTime).total_seconds()
UserObj : User = User.query.filter_by(id=PlaceServerPlayerObject.userid).first()
HandleUserTimePlayed(UserObj, TotalTimePlayed, serverUUID=jobId, placeId=PlaceObject.placeid)
db.session.delete(PlaceServerPlayerObj)
PlaceServerPlayerObj = PlaceServerPlayer(serveruuid=jobId, userid=UserId, joinTime= datetime.utcnow())
IncrementPlaceVisits(PlaceObject)
if CreatorObject is not None:
IncrementTargetBalance(CreatorObject, 1, 1)
UserObject.lastonline = datetime.utcnow()
InsertRecentlyPlayed(UserObj = UserObject, PlaceId = PlaceObject.placeid)
PlaceServerPlayers = PlaceServerPlayer.query.filter_by(serveruuid=jobId).all()
for PlaceServerPlayerObject in PlaceServerPlayers:
playerFound = False
for player in players:
if PlaceServerPlayerObject.userid == player["UserId"]:
playerFound = True
break
if playerFound == False:
TotalTimePlayed = (datetime.utcnow() - PlaceServerPlayerObject.joinTime).total_seconds()
UserObj : User = User.query.filter_by(id=PlaceServerPlayerObject.userid).first()
HandleUserTimePlayed(UserObj, TotalTimePlayed, serverUUID=jobId, placeId=PlaceObject.placeid)
db.session.delete(PlaceServerPlayerObject)
PlaceServerObject.playerCount = playerCount
db.session.commit()
ClearPlayingCountCache(PlaceObject)
return jsonify({"status": "success", "bad": BadPlayers}),200
# 2022+ endpoint
from sqlalchemy import func
from datetime import datetime
import time
@JobReportHandler.route('/internal/gameserver/reportjobid', methods=['POST'])
@JobReportHandler.route('//internal/gameserver/reportjobid', methods=['POST'])
@csrf.exempt
def reportjobid():
MAX_RETRIES = 3
RETRY_DELAY = 0.5 # seconds
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}), 400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}), 400
except Exception as e:
logging.error(f"Error parsing payload data: {str(e)}", exc_info=True)
return jsonify({"status": "error", "message": "Unknown error occurred while parsing data"}), 400
if "PlaceId" not in JSONData or "StartTime" not in JSONData:
return jsonify({"status": "error", "message": "Missing PlaceId or StartTime in request"}), 400
place_id = JSONData["PlaceId"]
start_time = JSONData["StartTime"]
for attempt in range(MAX_RETRIES):
try:
active_servers = PlaceServer.query.filter_by(serverPlaceId=place_id)\
.filter(PlaceServer.lastping > datetime.utcnow() - timedelta(minutes=5))\
.count()
logging.debug(f"Attempt {attempt + 1}: Found {active_servers} active servers for place {place_id}")
time_diff = func.abs(func.extract('epoch', PlaceServer.created_at) - start_time)
place_server = PlaceServer.query.filter_by(serverPlaceId=place_id)\
.filter(PlaceServer.lastping > datetime.utcnow() - timedelta(minutes=5))\
.order_by(time_diff)\
.first()
if not place_server:
place_server = PlaceServer.query.filter_by(serverPlaceId=place_id)\
.order_by(time_diff)\
.first()
if not place_server:
logging.warning(f"Attempt {attempt + 1}: No matching server found for place_id: {place_id}")
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_DELAY)
continue
return jsonify({
"status": "error",
"message": "No available server found for this place",
"suggested_action": "create_new_server",
"debug_info": {
"place_id": place_id,
"active_servers_count": active_servers,
"total_servers_count": PlaceServer.query.filter_by(serverPlaceId=place_id).count()
}
}), 404
game_server = GameServer.query.filter_by(serverId=place_server.originServerId).first()
if not game_server:
logging.error(f"Original GameServer not found for PlaceServer {place_server.serveruuid}")
return jsonify({
"status": "error",
"message": "Server configuration error",
"debug_info": {
"place_server_uuid": str(place_server.serveruuid),
"origin_server_id": str(place_server.originServerId)
}
}), 500
logging.info(f"Found matching server {place_server.serveruuid} for place {place_id}")
return jsonify({
"status": "success",
"JobId": str(place_server.serveruuid),
"ApiKey": game_server.accessKey,
"ServerIP": place_server.serverIP,
"ServerPort": place_server.serverPort,
"LastPing": place_server.lastping.isoformat() if place_server.lastping else None,
"ServerAge": (datetime.utcnow() - place_server.created_at).total_seconds() if place_server.created_at else None
})
except Exception as e:
logging.error(f"Attempt {attempt + 1} failed for place {place_id}: {str(e)}", exc_info=True)
if attempt == MAX_RETRIES - 1:
return jsonify({
"status": "error",
"message": "Internal server error",
"error_details": str(e),
"attempts": attempt + 1
}), 500
time.sleep(RETRY_DELAY)
@JobReportHandler.route('/internal/gameserver/reportfailure', methods=['POST'])
@csrf.exempt
def reportfailure():
# TODO: Implement this
return jsonify({"status": "success"}),200
# 2018+ endpoints
@JobReportHandler.route("/v2/CreateOrUpdate/", methods=['POST'])
@csrf.exempt
def CreateOrUpdate():
try:
JSONData = ParsePayloadData()
except InvalidGZIPData:
return jsonify({"status": "error", "message": "Invalid gzip data"}),400
except InvalidJSONData:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
except Exception as e:
return jsonify({"status": "error", "message": "Unknown error occured while parsing data"}),400
RequestHost = request.headers.get('Host')
if RequestHost is None:
logging.warning(f"Request rejected: No host found")
return abort(404)
if not (RequestHost.startswith("gameinstancesapis.") or RequestHost.startswith("gameinstances.api.") or RequestHost.startswith("gameinstances-api.")):
logging.warning(f"Request rejected: Invalid host domain - {RequestHost}")
return abort(404)
AccessKey = request.args.get( key = 'apiKey', default = None, type = str)
JobId = request.args.get( key = 'gameId', default = None, type = str)
if AccessKey is None or JobId is None:
return abort(404)
# Newer RCCs uses temporary access keys to report their game status and stuff
if not redis_controller.exists(f"GameServerAccessKey:{AccessKey}:{JobId}"):
logging.warning(f"Invalid access key ( {AccessKey} ) for server ( {JobId} )")
return abort(404)
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=JobId).first()
if PlaceServerObj is None:
originServerUUID = redis_controller.get(f"place:{JobId}:origin")
if originServerUUID is not None:
PlaceServerOwner : GameServer = GameServer.query.filter_by(serverId=originServerUUID).first()
if PlaceServerOwner is not None:
logging.info(f"CloseJob : Closing {JobId} because server does not exist in database")
perform_post(
TargetGameserver = PlaceServerOwner,
Endpoint = "CloseJob",
JSONData = {
"jobid": JobId,
}
)
logging.warning(f"Invalid server ( {JobId} )")
return abort(404)
PlaceServerOwner : GameServer = GameServer.query.filter_by(serverId=PlaceServerObj.originServerId).first()
PlaceObject : Place = Place.query.filter_by(placeid=PlaceServerObj.serverPlaceId).first()
if PlaceObject is None:
logging.warning(f"Invalid place ( {PlaceServerObj.serverPlaceId} ) for server ( {JobId} )")
return jsonify({"status": "error", "message": "Invalid place"}),400
AssetObject : Asset = Asset.query.filter_by(id=PlaceObject.placeid).first()
CreatorObject : User | Group = None
if AssetObject.creator_type == 0:
CreatorObject = User.query.filter_by(id=AssetObject.creator_id).first()
else:
CreatorObject = Group.query.filter_by(id=AssetObject.creator_id).first()
PlaceServerObj.lastping = datetime.utcnow()
GameSessions : list = JSONData["GameSessions"]
if GameSessions is None:
return jsonify({"status": "error", "message": "Invalid JSON data"}),400
for GameSession in GameSessions:
UserId = GameSession["UserId"]
UserObject : User = User.query.filter_by(id=UserId).first()
if UserObject is None or UserObject.accountstatus != 1:
EvictPlayer( PlaceServerObj, UserId )
logging.warning(f"User ( {UserId} ) is not a valid user, on server ( {PlaceServerObj.serveruuid} )")
continue
PlaceServerPlayerObj : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(serveruuid=JobId, userid=UserId).first()
if PlaceServerPlayerObj is None:
PlaceServerPlayerObj = PlaceServerPlayer(serveruuid=JobId, userid=UserId, joinTime=datetime.utcnow())
db.session.add(PlaceServerPlayerObj)
IncrementPlaceVisits(PlaceObject)
if CreatorObject is not None:
IncrementTargetBalance(CreatorObject, 1, 1)
UserObject.lastonline = datetime.utcnow()
InsertRecentlyPlayed(UserObj = UserObject, PlaceId = PlaceObject.placeid)
else:
if str(PlaceServerPlayerObj.serveruuid) == JobId:
PlaceServerPlayerObj.lastHeartbeat = datetime.utcnow()
UserObject.lastonline = datetime.utcnow()
else:
OtherPlaceServerObj = PlaceServer.query.filter_by(serveruuid=PlaceServerPlayerObj.serveruuid).first()
if OtherPlaceServerObj is not None:
EvictPlayer( OtherPlaceServerObj, UserId )
TotalTimePlayed = (datetime.utcnow() - PlaceServerPlayerObject.joinTime).total_seconds()
UserObj : User = User.query.filter_by(id=PlaceServerPlayerObject.userid).first()
HandleUserTimePlayed(UserObj, TotalTimePlayed, serverUUID=JobId, placeId=PlaceObject.placeid)
db.session.delete(PlaceServerPlayerObj)
PlaceServerPlayerObj = PlaceServerPlayer(serveruuid=JobId, userid=UserId, joinTime= datetime.utcnow())
IncrementPlaceVisits(PlaceObject)
if CreatorObject is not None:
IncrementTargetBalance(CreatorObject, 1, 1)
UserObject.lastonline = datetime.utcnow()
InsertRecentlyPlayed(UserObj = UserObject, PlaceId = PlaceObject.placeid)
PlaceServerPlayers = PlaceServerPlayer.query.filter_by(serveruuid=JobId).all()
for PlaceServerPlayerObject in PlaceServerPlayers:
playerFound = False
for GameSession in GameSessions:
if PlaceServerPlayerObject.userid == GameSession["UserId"]:
playerFound = True
break
if playerFound == False:
TotalTimePlayed = (datetime.utcnow() - PlaceServerPlayerObject.joinTime).total_seconds()
UserObj : User = User.query.filter_by(id=PlaceServerPlayerObject.userid).first()
HandleUserTimePlayed(UserObj, TotalTimePlayed, serverUUID=JobId, placeId=PlaceObject.placeid)
db.session.delete(PlaceServerPlayerObject)
if PlaceServerObj.playerCount == 0 and len(GameSessions) == 0 and PlaceServerObj.serverRunningTime > 60:
logging.info(f"CloseJob : Closing {JobId} for place [{PlaceServerObj.serverPlaceId}] because there was no players in the server for more than 60 seconds")
perform_post(
TargetGameserver = PlaceServerOwner,
Endpoint = "CloseJob",
JSONData = {
"jobid": JobId,
}
)
logging.info(f"Server ( {JobId} ) has been shutdown because there was no players in the server")
db.session.delete(PlaceServerObj)
db.session.commit()
return jsonify({"status": "success"}),200
PlaceServerObj.serverRunningTime = int(float(request.args.get('gameTime', '1', type=str))) + 1
PlaceServerObj.playerCount = len(GameSessions)
db.session.commit()
ClearPlayingCountCache(PlaceObject)
return jsonify({"status": "success"}),200
@JobReportHandler.route("/v2.0/Refresh", methods=['POST'])
@csrf.exempt
def Refresh():
return jsonify({"status": "success"}),200 # We don't care about this endpoint since the above endpoint will handle it
@JobReportHandler.route("/v1/Close/", methods=['POST'])
@csrf.exempt
def CloseJob():
AccessKey = request.args.get( key = 'apiKey', default = None, type = str)
JobId = request.args.get( key = 'gameId', default = None, type = str)
if AccessKey is None or JobId is None:
return abort(404)
if not redis_controller.exists(f"GameServerAccessKey:{AccessKey}:{JobId}"):
logging.warning(f"Invalid access key ( {AccessKey} ) for server ( {JobId} )")
return abort(404)
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=JobId).first()
if PlaceServerObj is not None:
AllPlaceServerPlayers : list[PlaceServerPlayer] = PlaceServerPlayer.query.filter_by(serveruuid=JobId).all()
for PlaceServerPlayerObj in AllPlaceServerPlayers:
TotalTimePlayed = (datetime.utcnow() - PlaceServerPlayerObj.joinTime).total_seconds()
UserObj : User = User.query.filter_by(id=PlaceServerPlayerObj.userid).first()
HandleUserTimePlayed(UserObj, TotalTimePlayed, serverUUID=JobId, placeId=PlaceServerObj.serverPlaceId)
db.session.delete(PlaceServerPlayerObj)
db.session.delete(PlaceServerObj)
db.session.commit()
return jsonify({"status": "success"}),200
@JobReportHandler.route("/game/report-water-sys", methods=['GET'])
@csrf.exempt
def ReportCheatersHandler():
RequestHost = request.headers.get('Host')
if RequestHost is None:
return abort(404)
if not RequestHost.startswith("gameinstances.api."):
return abort(404)
ReportingUserId = request.args.get( key = 'UserID', default = None, type = int)
if ReportingUserId is None:
return abort(404)
ReportingMessage = request.args.get( key = 'Message', default = None, type = str)
if ReportingMessage is None:
return abort(404)
AccessKey = request.args.get( key = 'AccessKey', default = '', type = str )
if "UserRequest" in AccessKey:
return abort(404)
ReportingRemoteAddress = get_remote_address()
ReportingGameServer : GameServer = GameServer.query.filter_by(serverIP=ReportingRemoteAddress).first()
if ReportingGameServer is None:
return abort(404)
UserObj : User = User.query.filter_by(id=ReportingUserId).first()
if UserObj is None:
return abort(404)
BannableErrorCodes = {
"carol": "Lua vm hooked (20)",
"murdle": "Cheat Engine Stable Methods (0)",
"olivia": "Debugger found (10)"
}
isBanned = False
if ReportingMessage.lower() in BannableErrorCodes:
LastestUserBanObj : UserBan = UserBan.query.filter_by(userid=UserObj.id, acknowledged = False).order_by(UserBan.id.desc()).first()
if LastestUserBanObj is None and UserObj.accountstatus == 1:
NewUserBanObj = UserBan(
userid = UserObj.id,
author_userid = 1,
ban_type = BanType.Deleted,
reason = "Exploiting in games is not tolerated on Vortexi",
moderator_note = f"Automatic ban, received detection from gameserver. Error Code: {BannableErrorCodes[ReportingMessage.lower()]} / {BannableErrorCodes[ReportingMessage.lower()]}",
expires_at = None
)
db.session.add(NewUserBanObj)
UserObj.accountstatus = 3
db.session.commit()
isBanned = True
try:
requests.post(
url = config.CHEATER_REPORTS_DISCORD_WEBHOOK,
json = {
"content": f"Received Cheater Report from GameServer {ReportingGameServer.serverName} ( {ReportingGameServer.serverId} ) for User {UserObj.username} ( {UserObj.id} )\n```{ReportingMessage}```\n Was the user banned? **{isBanned}**",
"username": "Cheater Reports"
}
)
except Exception as e:
logging.error(f"jobreporthandler : ReportCheatersHandler: Failed to send cheater report to discord, {e}")
return "OK", 200
@JobReportHandler.route("/Game/ClientPresence.ashx", methods=['GET'])
def ClientPresence(): # Does nothing
return "OK", 200
+124
View File
@@ -0,0 +1,124 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response, abort
from config import Config
import json
import logging
from app.models.giftcard_key import GiftcardKey
from app.enums.GiftcardType import GiftcardType
from app.models.kofi_transaction import KofiTransaction
from app.extensions import db, csrf
import requests
import datetime
import secrets
import string
from requests.auth import HTTPBasicAuth
from config import Config
config = Config()
KofiHandlerRoute = Blueprint("kofihandler", __name__, template_folder="pages")
def GenerateCode():
Code = ""
for i in range(0, 5):
Chunk = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(5))
Code += Chunk
if i != 4:
Code += "-"
return Code
@KofiHandlerRoute.before_request
def before_request():
if config.KOFI_ENABLED is False:
return abort(404)
@KofiHandlerRoute.route("/internal/kofi_handler", methods=["POST"])
@csrf.exempt
def kofi_handler():
try:
PurchaseData = json.loads(
request.form.get(key="data", default=None, type=str)
)
if PurchaseData is None:
return abort(400)
except:
return abort(400)
if PurchaseData["verification_token"] != Config.KOFI_VERIFICATION_TOKEN:
return abort(401)
if "email" not in PurchaseData:
logging.error("KofiHandler: Email not in PurchaseData")
return abort(400)
UserEmail : str = PurchaseData["email"]
logging.info(f"KofiHandler: Received donation from {UserEmail}, Transaction ID: {PurchaseData['kofi_transaction_id']}")
TransactionObj : KofiTransaction = KofiTransaction.query.filter_by(kofi_transaction_id=PurchaseData["kofi_transaction_id"]).first()
if TransactionObj is not None:
logging.error(f"KofiHandler: Transaction ID {PurchaseData['kofi_transaction_id']} already exists")
return abort(400)
NewGiftcardCode : str = GenerateCode()
NewGiftcardObj : GiftcardKey = GiftcardKey(
key = NewGiftcardCode,
type = GiftcardType.Outrageous_BuildersClub,
value = 1
)
db.session.add(NewGiftcardObj)
db.session.commit()
NewTransactionObj : KofiTransaction = KofiTransaction(
kofi_transaction_id = PurchaseData["kofi_transaction_id"],
timestamp = datetime.datetime.strptime(PurchaseData["timestamp"], "%Y-%m-%dT%H:%M:%SZ"),
donation_type = PurchaseData["type"],
amount = float(PurchaseData["amount"]),
currency = PurchaseData["currency"],
is_subscription_payment = PurchaseData["is_subscription_payment"],
message = PurchaseData["message"],
from_name = PurchaseData["from_name"],
from_email = PurchaseData["email"],
assigned_key = NewGiftcardObj.key
)
db.session.add(NewTransactionObj)
db.session.commit()
EmailData = {
"Messages": [
{
"From": {
"Email": Config.MAILJET_NOREPLY_SENDER,
"Name": "Vortexi Donation Processor"
},
"To": [
{
"Email": UserEmail,
"Name": PurchaseData["from_name"]
}
],
"TemplateID": Config.MAILJET_DONATION_TEMPLATE_ID,
"TemplateLanguage": True,
"Subject": "Thank you for your donation!",
"Variables": {
"redeem_key": NewGiftcardObj.key,
}
}
]
}
EmailResponse = requests.post(
url="https://api.mailjet.com/v3.1/send",
data=json.dumps(EmailData),
headers={
"Content-Type": "application/json"
},
auth = HTTPBasicAuth(
Config.MAILJET_APIKEY,
Config.MAILJET_SECRETKEY
)
)
if EmailResponse.status_code != 200:
logging.error(f"KofiHandler: Failed to send email to {UserEmail}")
logging.error(EmailResponse.json())
return "OK", 200 # We don't want to return an error to Ko-fi since we already processed the donation, so we just return OK
logging.info(f"KofiHandler: Successfully sent email to {UserEmail} and processed donation")
return "OK", 200
+125
View File
@@ -0,0 +1,125 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, after_this_request
from config import Config
from app.models.user import User
from app.models.gameservers import GameServer
from app.models.place import Place
from app.models.universe import Universe
from app.models.legacy_data_persistence import LegacyDataPersistence
from app.extensions import redis_controller, get_remote_address, csrf, limiter, db
from datetime import datetime
import logging
import gzip
LegacyDataPersistenceRoute = Blueprint('legacyDataPersistence', __name__, url_prefix='/persistence/legacy')
@LegacyDataPersistenceRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
logging.info(f"LegacyDataPersistence - UserAgent {request.user_agent} - Not allowed")
abort(404)
RemoteAddress = get_remote_address()
GameServerObj : GameServer = GameServer.query.filter_by( serverIP = RemoteAddress ).first()
if GameServerObj is None:
logging.info(f"LegacyDataPersistence - {RemoteAddress} - Not found")
abort(404)
if "Roblox-Place-Id" not in request.headers and "placeId" not in request.args:
logging.info(f"LegacyDataPersistence - GameServer {RemoteAddress} - Roblox-Place-Id header and cookie not found")
abort(404)
PlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int ) or request.args.get( key = "placeId", default = None, type = int )
if PlaceId is None:
logging.info(f"LegacyDataPersistence - GameServer {RemoteAddress} - Roblox-Place-Id header or arg not expected type")
abort(404)
PlaceObj : Place = Place.query.filter_by( placeid = PlaceId ).first()
if PlaceObj is None:
logging.info(f"LegacyDataPersistence - Place {PlaceId} - Place not found")
abort(404)
@LegacyDataPersistenceRoute.route('/load', methods=['POST', 'GET'])
@csrf.exempt
def LoadData():
PlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int ) or request.args.get( key = "placeId", default = None, type = int )
RequestedUserId = request.args.get(
key = "userId",
default = None,
type = int
)
if RequestedUserId is None:
return "Invalid request", 400
UserObj : User = User.query.filter_by( id = RequestedUserId ).first()
if UserObj is None:
return "Invalid request", 400
PlaceObj : Place = Place.query.filter_by( placeid = PlaceId ).first()
if PlaceObj is None:
return "Invalid request", 400
UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
if UniverseObj is None:
return "Invalid request", 400
LegacyDataPersistenceObj : LegacyDataPersistence = LegacyDataPersistence.query.filter_by(
userid = RequestedUserId,
universe_id = UniverseObj.id
).first()
logging.info(f"LegacyDataPersistence - User {UserObj.id} - Place {PlaceId} - Universe {UniverseObj.id} - Data requested")
if LegacyDataPersistenceObj is None:
return "", 200
return gzip.decompress(LegacyDataPersistenceObj.data), 200
@LegacyDataPersistenceRoute.route('/save', methods=['POST'])
@csrf.exempt
def SaveData():
PlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int ) or request.args.get( key = "placeId", default = None, type = int )
PayloadData = request.data
if request.content_encoding != "gzip":
PayloadData = gzip.compress( PayloadData )
RequestedUserId = request.args.get(
key = "userId",
default = None,
type = int
)
if RequestedUserId is None:
return "Invalid request", 400
UserObj : User = User.query.filter_by( id = RequestedUserId ).first()
if UserObj is None:
return "Invalid request", 400
if len(PayloadData) > 1024 * 1024 * 2:
logging.info(f"LegacyDataPersistence - User {UserObj.id} - Place {PlaceId} - Universe {UniverseObj.id} - Payload too large, {len(PayloadData)} bytes")
return "Payload too large", 400
PlaceObj : Place = Place.query.filter_by( placeid = PlaceId ).first()
if PlaceObj is None:
return "Invalid request", 400
UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
if UniverseObj is None:
return "Invalid request", 400
LegacyDataPersistenceObj : LegacyDataPersistence = LegacyDataPersistence.query.filter_by(
userid = RequestedUserId,
universe_id = UniverseObj.id
).first()
if LegacyDataPersistenceObj is None:
LegacyDataPersistenceObj = LegacyDataPersistence(
placeid = PlaceId,
userid = RequestedUserId,
universe_id = UniverseObj.id
)
db.session.add( LegacyDataPersistenceObj )
LegacyDataPersistenceObj.data = PayloadData
LegacyDataPersistenceObj.last_updated = datetime.utcnow()
db.session.commit()
logging.info(f"LegacyDataPersistence - User {UserObj.id} - Place {PlaceId} - Universe {UniverseObj.id} - Saved {len(LegacyDataPersistenceObj.data)} bytes")
return "OK", 200
+560
View File
@@ -0,0 +1,560 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify
from config import Config
from app.models.asset import Asset
from app.models.userassets import UserAsset
from app.enums.AssetType import AssetType
from app.models.follow_relationship import FollowRelationship
from app.models.friend_relationship import FriendRelationship
from app.models.friend_request import FriendRequest
from app.models.package_asset import PackageAsset
from app.models.placeserver_players import PlaceServerPlayer
from app.util import auth, friends, websiteFeatures, membership
from app.services import economy
from app.enums.MembershipType import MembershipType
from app.models.user import User
from app.models.user_email import UserEmail
from sqlalchemy import or_, func
from app.extensions import limiter, db, csrf
import logging
from datetime import datetime, timedelta
from app.models.groups import GroupRole, GroupMember
from app.services.groups import GetGroupMemberCount
from app.services.groups import GetUserRankInGroup, GroupExceptions, GetUserRolesetInGroup
from app.services.user_relationships import followings
config = Config()
LuaWebServiceRoute = Blueprint('luawebservice', __name__, url_prefix='/')
def MakeReturnFalseResponse():
Resposne = make_response("""<Value Type="boolean">false</Value>""")
Resposne.headers["Content-Type"] = "application/xml; charset=utf-8"
return Resposne
def MakeReturnTrueResponse():
Resposne = make_response("""<Value Type="boolean">true</Value>""")
Resposne.headers["Content-Type"] = "application/xml; charset=utf-8"
return Resposne
def MakeReturnIntResponse(Value : int):
Resposne = make_response(f"""<Value Type="integer">{str(Value)}</Value>""")
Resposne.headers["Content-Type"] = "application/xml; charset=utf-8"
return Resposne
def MakeReturnStringResponse(Value : str):
Resposne = make_response(Value)
#Resposne = make_response(f"""<Value Type="string">{Value}</Value>""")
Resposne.headers["Content-Type"] = "application/xml; charset=utf-8"
return Resposne
@LuaWebServiceRoute.route('/Game/LuaWebService/HandleSocialRequest.ashx', methods=['GET'])
def HandleSocialRequest():
playerid = request.args.get("playerid", None, int)
groupid = request.args.get("groupid", None, int)
userid = request.args.get("userid", None, int)
method = request.args.get("method", None, str)
if method is None:
return MakeReturnFalseResponse()
if groupid == 1200769:
groupid = config.ADMIN_GROUP_ID
if method == "GetGroupRank":
try:
if playerid is None or groupid is None:
return MakeReturnIntResponse(0)
if playerid < 1 or groupid < 1:
return MakeReturnIntResponse(0)
return MakeReturnIntResponse(GetUserRankInGroup(playerid, groupid))
except GroupExceptions.GroupDoesNotExist:
return MakeReturnIntResponse(0)
except GroupExceptions.UserDoesNotExist:
return MakeReturnIntResponse(0)
elif method == "IsInGroup":
try:
if playerid is None or groupid is None:
return MakeReturnFalseResponse()
if playerid < 1 or groupid < 1:
return MakeReturnFalseResponse()
if GetUserRankInGroup(playerid, groupid) == 0:
return MakeReturnFalseResponse()
return MakeReturnTrueResponse()
except GroupExceptions.GroupDoesNotExist:
return MakeReturnFalseResponse()
except GroupExceptions.UserDoesNotExist:
return MakeReturnFalseResponse()
elif method == "GetGroupRole":
try:
if playerid is None or groupid is None:
return MakeReturnStringResponse("Guest")
if playerid < 1 or groupid < 1:
return MakeReturnStringResponse("Guest")
if GetUserRankInGroup(playerid, groupid) == 0:
return MakeReturnStringResponse("Guest")
Roleset : GroupRole = GetUserRolesetInGroup(playerid, groupid)
return MakeReturnStringResponse(Roleset.name)
except GroupExceptions.GroupDoesNotExist:
return MakeReturnStringResponse("Guest")
except GroupExceptions.UserDoesNotExist:
return MakeReturnStringResponse("Guest")
elif method == "IsFriendsWith":
try:
if playerid is None or userid is None:
return MakeReturnFalseResponse()
if playerid < 1 or userid < 1:
return MakeReturnFalseResponse()
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(playerid, userid)
if FriendRelationshipObj is None:
return MakeReturnFalseResponse()
return MakeReturnTrueResponse()
except Exception as e:
logging.error(f"IsFriendsWith: {e}")
return MakeReturnFalseResponse()
return MakeReturnFalseResponse()
@LuaWebServiceRoute.route("/Game/GamePass/GamePassHandler.ashx", methods=['GET'])
def GamePassHandler():
Action = request.args.get("Action", None, str)
UserID = request.args.get("UserID", None, int)
PassID = request.args.get("PassID", None, int)
if Action is None or UserID is None or PassID is None:
return MakeReturnFalseResponse()
if Action == "HasPass":
AssetObj : Asset = Asset.query.filter_by(id=PassID).first()
if AssetObj is None:
return MakeReturnFalseResponse()
if AssetObj.asset_type != AssetType.GamePass:
return MakeReturnFalseResponse()
UserAssetObj : UserAsset = UserAsset.query.filter_by(userid=UserID, assetid=PassID).first()
if UserAssetObj is None:
return MakeReturnFalseResponse()
return MakeReturnTrueResponse()
return MakeReturnFalseResponse()
@LuaWebServiceRoute.route("/user/request-friendship", methods=['POST'])
@auth.authenticated_client_endpoint
@csrf.exempt
@limiter.limit("1/second")
def RequestFriendship():
UserID = request.args.get("recipientUserId", None, int)
if UserID is None:
return jsonify({"success": False, "message": "Invalid request"}), 400
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser.id == UserID:
return jsonify({"success": False, "message": "Invalid request"}), 400
TargetUserObj : User = User.query.filter_by(id=UserID).first()
if TargetUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}), 400
TargetPlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by(userid=UserID).first()
if TargetPlaceServerPlayerObj is None:
return jsonify({"success": False, "message": "Invalid request"}), 400
AuthenticatedPlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by(userid=AuthenticatedUser.id).first()
if AuthenticatedPlaceServerPlayerObj is None:
return jsonify({"success": False, "message": "Invalid request"}), 400
if TargetPlaceServerPlayerObj.serveruuid != AuthenticatedPlaceServerPlayerObj.serveruuid:
return jsonify({"success": False, "message": "Invalid request"}), 400
FriendRelationshipObj : FriendRelationship | None = friends.GetFriendRelationship(AuthenticatedUser.id, UserID)
if FriendRelationshipObj is not None:
return jsonify({"success": True }),200
FriendRequestObj : FriendRequest | None = FriendRequest.query.filter_by(requester_id=AuthenticatedUser.id, requestee_id=UserID).first()
if FriendRequestObj is not None:
return jsonify({"success": True }),200
FriendRequestObj : FriendRequest | None = FriendRequest.query.filter_by(requester_id=UserID, requestee_id=AuthenticatedUser.id).first()
if FriendRequestObj is not None:
FriendRelationshipObj : FriendRelationship = FriendRelationship(
user_id=AuthenticatedUser.id,
friend_id=UserID
)
db.session.add(FriendRelationshipObj)
db.session.delete(FriendRequestObj)
db.session.commit()
return jsonify({"success": True }),200
FriendRequestObj : FriendRequest = FriendRequest(
requester_id=AuthenticatedUser.id,
requestee_id=UserID
)
db.session.add(FriendRequestObj)
db.session.commit()
return jsonify({"success": True }),200
@LuaWebServiceRoute.route("/user/decline-friend-request", methods=['POST'])
@auth.authenticated_client_endpoint
@csrf.exempt
@limiter.limit("1/second")
def DeclineFriendRequest():
UserID = request.args.get("requesterUserId", None, int)
if UserID is None:
jsonify({"success": False, "message": "Invalid request"}), 400
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser.id == UserID:
jsonify({"success": False, "message": "Invalid request"}), 400
TargetUserObj : User = User.query.filter_by(id=UserID).first()
if TargetUserObj is None:
jsonify({"success": False, "message": "Invalid request"}), 400
FriendRequestObj : FriendRequest | None = FriendRequest.query.filter_by(requester_id=UserID, requestee_id=AuthenticatedUser.id).first()
if FriendRequestObj is None:
FriendRelationshipObj : FriendRelationship | None = friends.GetFriendRelationship(AuthenticatedUser.id, UserID)
if FriendRelationshipObj is None:
jsonify({"success": False, "message": "Invalid request"}), 400
db.session.delete(FriendRelationshipObj)
db.session.commit()
return jsonify({"success": True }),200
db.session.delete(FriendRequestObj)
db.session.commit()
return jsonify({"success": True }),200
@LuaWebServiceRoute.route("/user/following-exists", methods=["GET"])
@limiter.limit("60/minute")
def FollowingExists():
UserID = request.args.get("userId", None, int)
FollowerUserID = request.args.get("followerUserId", None, int)
if UserID is None or FollowerUserID is None:
return jsonify({"success": False, "isFollowing": False})
FollowedUserObj : User = User.query.filter_by(id=UserID).first()
FollowerUserObj : User = User.query.filter_by(id=FollowerUserID).first()
if FollowedUserObj is None or FollowerUserObj is None:
return jsonify({"success": False, "isFollowing": False})
return jsonify({"success": True, "isFollowing": followings.is_following( follower_user = FollowerUserObj, followed_user = FollowedUserObj)})
@LuaWebServiceRoute.route("/user/get-friendship-count", methods=["GET"])
@limiter.limit("60/minute")
def GetFriendshipCount():
UserID = request.args.get("userId", None, int)
if UserID is None:
AuthenticatedUser = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "count": 0})
UserID = AuthenticatedUser.id
FriendRelationshipCount : int = FriendRelationship.query.filter(or_( FriendRelationship.user_id == UserID, FriendRelationship.friend_id == UserID)).count()
return jsonify({"success": True, "count": FriendRelationshipCount})
@LuaWebServiceRoute.route("/user/follow", methods=["POST"])
@csrf.exempt
@auth.authenticated_client_endpoint
@limiter.limit("20/minute")
def FollowUser():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
TargetUserId = request.form.get("followedUserId", None, int)
if TargetUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
if TargetUserId == AuthenticatedUser.id:
return jsonify({"success": False, "message": "Invalid request"}),400
TargetUserObj : User = User.query.filter_by(id=TargetUserId).first()
if TargetUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
try:
followings.follow_user(
follower_user = AuthenticatedUser,
followed_user = TargetUserObj
)
except followings.FollowingExceptions.AlreadyFollowing:
return jsonify({"success": True}),200
except followings.FollowingExceptions.UserRateLimited:
return jsonify({"success": False, "message": "Rate limited"}),429
except followings.FollowingExceptions.FollowingIsDisabled:
return jsonify({"success": False, "message": "Following is disabled"}),400
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/user/unfollow", methods=["POST"])
@csrf.exempt
@auth.authenticated_client_endpoint
@limiter.limit("20/minute")
def UnfollowUser():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
TargetUserId = request.form.get("followedUserId", None, int)
if TargetUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
if TargetUserId == AuthenticatedUser.id:
return jsonify({"success": False, "message": "Invalid request"}),400
TargetUserObj : User = User.query.filter_by(id=TargetUserId).first()
if TargetUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
try:
followings.unfollow_user(
current_follower = AuthenticatedUser,
followed_user = TargetUserObj
)
except followings.FollowingExceptions.UserNotFollowing:
return jsonify({"success": True}),200
except followings.FollowingExceptions.UserRateLimited:
return jsonify({"success": False, "message": "Rate limited"}),429
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/my/economy-status", methods=["GET"])
@auth.authenticated_client_endpoint
def EconomyStatus():
return jsonify({
"success": True,
"isMarketplaceEnabled": websiteFeatures.GetWebsiteFeature("EconomyPurchase")
})
@LuaWebServiceRoute.route("/currency/balance", methods=["GET"])
#@auth.authenticated_client_endpoint
def EconomyBalance():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
RobuxBal, TixBal = economy.GetUserBalance(AuthenticatedUser)
return jsonify({
"success": True,
"robux": RobuxBal,
"tickets": TixBal
})
@LuaWebServiceRoute.route("/ownership/hasasset", methods=["GET"])
@LuaWebServiceRoute.route("/ownership/hasAsset", methods=["GET"])
#@auth.gameserver_authenticated_required
def HasAsset():
AssetID = request.args.get("assetId", None, int)
if AssetID is None:
return jsonify({"success": False, "message": "Invalid request"}),400
UserId = request.args.get("userId", None, int)
if UserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
AssetObj : Asset = Asset.query.filter_by(id=AssetID).first()
if AssetObj is None:
return "false",200
UserAssetObj : UserAsset = UserAsset.query.filter_by(userid=UserId, assetid=AssetID).first()
if UserAssetObj is None:
return "false",200
return "true",200
@LuaWebServiceRoute.route("/Friend/AreFriends", methods=["GET"])
@auth.gameserver_authenticated_required
def AreFriends():
userId = request.args.get("userId", None, int)
otherUserId = request.args.get("otherUserId", None, int)
otherUserIdList = request.args.getlist("otherUserIds")
if userId is None or ( otherUserId is None and len(otherUserIdList) == 0):
return jsonify({"success": False, "message": "Invalid request"}),400
if otherUserId is not None:
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(userId, otherUserId)
if FriendRelationshipObj is not None:
return jsonify({"success": True, "friendStatus": 2})
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id = userId, requestee_id=otherUserId).first()
if FriendRequestObj is not None:
return jsonify({"success": True, "friendStatus": 3})
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id = otherUserId, requestee_id=userId).first()
if FriendRequestObj is not None:
return jsonify({"success": True, "friendStatus": 4})
return jsonify({"success": True, "friendStatus": 1})
else:
if len(otherUserIdList) > 100:
return jsonify({"success": False, "message": "Invalid request"}),400
RequestResult = ""
for TargetUserId in otherUserIdList:
try:
TargetUserId = int(TargetUserId)
except:
continue
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(userId, TargetUserId)
if FriendRelationshipObj is not None:
RequestResult += f"{str(TargetUserId)},"
continue
return RequestResult,200
@LuaWebServiceRoute.route("/Friend/CreateFriend", methods=["POST"])
@csrf.exempt
@auth.gameserver_authenticated_required
def CreateFriend():
firstUserId = request.args.get( "firstUserId", default = None, type = int )
secondUserId = request.args.get( "secondUserId", default = None, type = int )
if firstUserId is None or secondUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
firstUserObj : User = User.query.filter_by(id=firstUserId).first()
secondUserObj : User = User.query.filter_by(id=secondUserId).first()
if firstUserObj is None or secondUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(firstUserId, secondUserId)
if FriendRelationshipObj is not None:
return jsonify({"success": True}),200
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id=firstUserId, requestee_id=secondUserId).first()
if FriendRequestObj is not None:
db.session.delete(FriendRequestObj)
SecondFriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id=secondUserId, requestee_id=firstUserId).first()
if SecondFriendRequestObj is not None:
db.session.delete(SecondFriendRequestObj)
FriendRelationshipObj = FriendRelationship(
user_id=firstUserId,
friend_id=secondUserId
)
db.session.add(FriendRelationshipObj)
db.session.commit()
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/Friend/BreakFriend", methods=["POST"])
@csrf.exempt
@auth.gameserver_authenticated_required
def BreakFriend():
firstUserId = request.args.get( "firstUserId", default = None, type = int )
secondUserId = request.args.get( "secondUserId", default = None, type = int )
if firstUserId is None or secondUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
firstUserObj : User = User.query.filter_by(id=firstUserId).first()
secondUserObj : User = User.query.filter_by(id=secondUserId).first()
if firstUserObj is None or secondUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(firstUserId, secondUserId)
if FriendRelationshipObj is not None:
db.session.delete(FriendRelationshipObj)
db.session.commit()
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/Friend/CreateFriendRequest", methods=["POST"])
@csrf.exempt
@auth.gameserver_authenticated_required
def CreateFriendRequest():
requesterUserId = request.args.get( "requesterUserId", default = None, type = int )
requestedUserId = request.args.get( "requestedUserId", default = None, type = int )
if requesterUserId is None or requestedUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
requesterUserObj : User = User.query.filter_by(id=requesterUserId).first()
requestedUserObj : User = User.query.filter_by(id=requestedUserId).first()
if requesterUserObj is None or requestedUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
FriendRelationshipObj : FriendRelationship = friends.GetFriendRelationship(requesterUserId, requestedUserId)
if FriendRelationshipObj is not None:
return jsonify({"success": True}),200
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id=requesterUserId, requestee_id=requestedUserId).first()
OtherFriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id=requestedUserId, requestee_id=requesterUserId).first()
if FriendRequestObj is not None and OtherFriendRequestObj is None:
return jsonify({"success": True}),200
if OtherFriendRequestObj is not None:
if FriendRequestObj is not None:
db.session.delete(FriendRequestObj)
db.session.delete(OtherFriendRequestObj)
FriendRelationshipObj = FriendRelationship(
user_id=requesterUserId,
friend_id=requestedUserId
)
db.session.add(FriendRelationshipObj)
db.session.commit()
return jsonify({"success": True}),200
FriendRequestObj = FriendRequest(
requester_id=requesterUserId,
requestee_id=requestedUserId
)
db.session.add(FriendRequestObj)
db.session.commit()
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/Friend/DeleteFriendRequest", methods=["POST"])
@csrf.exempt
@auth.gameserver_authenticated_required
def DeleteFriendRequest():
requesterUserId = request.args.get( "requesterUserId", default = None, type = int )
requestedUserId = request.args.get( "requestedUserId", default = None, type = int )
if requesterUserId is None or requestedUserId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
requesterUserObj : User = User.query.filter_by(id=requesterUserId).first()
requestedUserObj : User = User.query.filter_by(id=requestedUserId).first()
if requesterUserObj is None or requestedUserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
FriendRequestObj : FriendRequest = FriendRequest.query.filter_by(requester_id=requesterUserId, requestee_id=requestedUserId).first()
if FriendRequestObj is not None:
db.session.delete(FriendRequestObj)
db.session.commit()
return jsonify({"success": True}),200
@LuaWebServiceRoute.route("/v2/users/<int:userId>/groups/roles", methods=["GET"])
def GetUserGroupRoles( userId : int ):
if userId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
UserObj : User = User.query.filter_by(id=userId).first()
if UserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
GroupRoles = []
UserGroupMemberList : list[GroupMember] = GroupMember.query.filter_by(user_id = userId).all()
for GroupMemberObj in UserGroupMemberList:
GroupRoles.append({
"group": {
"id": GroupMemberObj.group_id,
"name": GroupMemberObj.group.name,
"memberCount": GetGroupMemberCount(GroupMemberObj.group)
},
"role" : {
"id": GroupMemberObj.group_role_id,
"name": GroupMemberObj.group_role.name,
"rank": GroupMemberObj.group_role.rank
}
})
if GroupMemberObj.group_id == config.ADMIN_GROUP_ID:
GroupRoles.append({
"group": {
"id": 1200769,
"name": GroupMemberObj.group.name,
"memberCount": GetGroupMemberCount(GroupMemberObj.group)
},
"role" : {
"id": GroupMemberObj.group_role_id,
"name": GroupMemberObj.group_role.name,
"rank": GroupMemberObj.group_role.rank
}
})
return jsonify({
"data": GroupRoles
})
@LuaWebServiceRoute.route("/users/get-by-username/", methods=["GET"])
def GetUserByUsername():
RequestedUsername = request.args.get(
key = "username",
default = None,
type = str
)
if RequestedUsername is None:
return jsonify({"success": False, "message": "Invalid request"}),400
UserObj : User = User.query.filter(func.lower(User.username) == func.lower(RequestedUsername)).first()
if UserObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
return jsonify({
"Id": UserObj.id,
"Username": UserObj.username
}), 200
+408
View File
@@ -0,0 +1,408 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response, after_this_request
from app.util import auth, friends, websiteFeatures, transactions, redislock
import logging
from app.services import economy
from app.extensions import db, limiter, csrf, redis_controller, user_limiter
from app.models.asset import Asset
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.package_asset import PackageAsset
from app.models.place_developer_product import DeveloperProduct
from app.models.product_receipt import ProductReceipt
from app.models.groups import Group
from app.enums.AssetType import AssetType
from app.enums.TransactionType import TransactionType
from app.pages.catalog.catalog import IncrementAssetCreator, CreateTransactionForSale
import math
MarketPlaceRoute = Blueprint("marketplace", __name__, url_prefix="/marketplace")
EconomyV1Route = Blueprint("economyv1", __name__, url_prefix="/")
@MarketPlaceRoute.route("/game-pass-product-info", methods=["GET"])
@MarketPlaceRoute.route("/productinfo", methods=["GET"])
def productinfo():
assetid = request.args.get("assetId") or request.args.get("gamePassId")
if assetid is None:
return "Invalid request",400
asset : Asset = Asset.query.filter_by(id=assetid).first()
if asset is None:
return "Asset not found",404
AssetCreator : User | Group = User.query.filter_by(id=asset.creator_id).first() if asset.creator_type == 0 else Group.query.filter_by(id=asset.creator_id).first()
if AssetCreator is None:
AssetCreatorName = "Unknown"
else:
AssetCreatorName = AssetCreator.username if isinstance(AssetCreator, User) else AssetCreator.name
return jsonify({
"Name": asset.name,
"Description": asset.description,
"Created": asset.created_at,
"Updated": asset.updated_at,
"PriceInRobux": asset.price_robux,
"PriceInTickets": asset.price_tix,
"AssetId": asset.id,
"ProductId": asset.id,
"AssetTypeId": asset.asset_type.value,
"Creator": {
"Id": asset.creator_id,
"Name": AssetCreatorName,
"CreatorType": asset.creator_type
},
"MinimumMembershipLevel": 0,
"IsForSale": asset.is_for_sale
})
@MarketPlaceRoute.route("/productDetails", methods=["GET"])
def productDetails():
productId = request.args.get( key = "productId", default = None, type = int )
if productId is None:
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
TargetDeveloperProduct : DeveloperProduct = DeveloperProduct.query.filter_by( productid = productId ).first()
if TargetDeveloperProduct is None:
return jsonify({
"success": False,
"message": "Invalid request"
}), 400
return jsonify({
"TargetId" : 1,
"ProductType": "Developer Product",
"AssetId": 0,
"ProductId": TargetDeveloperProduct.productid,
"Name": TargetDeveloperProduct.name,
"Description": TargetDeveloperProduct.description,
"AssetTypeId": 0,
"Creator": {
"Id": 0,
"Name": None,
"CreatorType": None,
"CreatorTargetId": 0
},
"IconImageAssetId": TargetDeveloperProduct.iconimage_assetid,
"Created": TargetDeveloperProduct.created_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
"Updated": TargetDeveloperProduct.updated_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
"PriceInRobux": TargetDeveloperProduct.robux_price,
"PremiumPriceInRobux": 0,
"PriceInTickets": 0,
"IsNew": False,
"IsForSale": TargetDeveloperProduct.is_for_sale,
"IsPublicDomain": False,
"IsLimited": False,
"IsLimitedUnique": False,
"Remaining": None,
"Sales": None,
"MinimumMembershipLevel": 0
})
@MarketPlaceRoute.route("/purchase", methods=["POST"])
@csrf.exempt
@auth.authenticated_required_api
@limiter.limit("20/minute")
@limiter.limit("1/second")
@user_limiter.limit("20/minute")
@user_limiter.limit("1/second")
def inGamePurchaseHandler():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
productId = request.form.get("productId", None, int)
currencyTypeId = request.form.get("currencyTypeId", None, int)
expectedPrice = request.form.get("purchasePrice", None, int)
if not websiteFeatures.GetWebsiteFeature("EconomyPurchase"):
return jsonify({"success": False, "status": "EconomyDisabled"}),400
if productId is None or currencyTypeId is None or expectedPrice is None:
return jsonify({"success": False, "message": "Invalid request"}),400
currencyType = 0 if currencyTypeId == 1 else 1
AssetObj : Asset = Asset.query.filter_by(id=productId).first()
if AssetObj is None:
return jsonify({"success": False, "message": "Invalid request"}),400
if AssetObj.is_limited or not AssetObj.is_for_sale:
return jsonify({"success": False, "status": "NotForSale"}),400
LockAssetName = f"asset:{str(AssetObj.id)}"
AssetLock = redislock.acquire_lock(LockAssetName, acquire_timeout=15, lock_timeout=1)
#if AssetLock is False:
# return jsonify({"success": False, "status": "InternalServerError"}),500
@after_this_request
def release_asset_lock(response):
if AssetLock:
redislock.release_lock(LockAssetName, AssetLock)
UserRobuxBalance, UserTixBalance = economy.GetUserBalance(AuthenticatedUser)
if currencyType == 0:
if AssetObj.price_robux == 0 and AssetObj.price_tix != 0:
return jsonify({"success": False, "status": "CurrencyNotAccepted"}),400
if AssetObj.price_robux != expectedPrice:
return jsonify({"success": False, "status": "PriceChanged"}),400
if UserRobuxBalance < expectedPrice:
return jsonify({"success": False, "status": "InsufficientFunds"}),400
else:
if AssetObj.price_tix == 0 and AssetObj.price_robux != 0:
return jsonify({"success": False, "status": "CurrencyNotAccepted"}),400
if AssetObj.price_tix != expectedPrice:
return jsonify({"success": False, "status": "PriceChanged"}),400
if UserTixBalance < expectedPrice:
return jsonify({"success": False, "status": "InsufficientFunds"}),400
UserAssetObj : UserAsset = UserAsset.query.filter_by(userid=AuthenticatedUser.id, assetid=productId).first()
if UserAssetObj is not None:
return jsonify({"success": False, "status": "AlreadyOwned"}),400
try:
economy.DecrementTargetBalance(AuthenticatedUser, expectedPrice, currencyType)
except economy.InsufficientFundsException:
return jsonify({"success": False, "status": "InsufficientFunds"}),400
UserAssetObj = UserAsset(
userid = AuthenticatedUser.id,
assetid = productId
)
if AssetObj.asset_type == AssetType.Package:
PackageAssets = PackageAsset.query.filter_by(package_asset_id=AssetObj.id).all()
for PackageAssetObj in PackageAssets:
NewPackageAssetObj = UserAsset(userid=AuthenticatedUser.id, assetid=PackageAssetObj.asset_id)
db.session.add(NewPackageAssetObj)
IncrementAssetCreator(AssetObj, expectedPrice, currencyType)
AssetObj.sale_count += 1
db.session.add(UserAssetObj)
db.session.commit()
try:
if AssetObj.creator_type == 0:
SellerUserObj : User = User.query.filter_by(id=AssetObj.creator_id).first()
CreateTransactionForSale(
AssetObj = AssetObj,
PurchasePrice = expectedPrice,
PurchaseCurrencyType = currencyType,
Seller = SellerUserObj,
Buyer = AuthenticatedUser,
ApplyTaxAutomatically = True
)
elif AssetObj.creator_type == 1:
SellerGroupObj : Group = Group.query.filter_by(id=AssetObj.creator_id).first()
CreateTransactionForSale(
AssetObj = AssetObj,
PurchasePrice = expectedPrice,
PurchaseCurrencyType = currencyType,
Seller = SellerGroupObj,
Buyer = AuthenticatedUser,
ApplyTaxAutomatically = True
)
except Exception as e:
logging.warn(f"Failed to create transaction log for sale of asset {str(AssetObj.id)}, message: {str(e)}")
pass
return jsonify({"success": True}),200
@EconomyV1Route.route("/v1/purchases/products/<int:assetid>", methods=["POST"])
@csrf.exempt
@auth.authenticated_required_api
@user_limiter.limit("30/minute")
@user_limiter.limit("1/second")
def SubmitItemPurchaseEconomy( assetid : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"purchased": False, "reason": "Unauthorized"}),401
if "expectedPrice" not in request.json or "expectedCurrency" not in request.json:
return jsonify({"purchased": False, "reason": "Invalid request", "errorMsg": "Invalid Request"}),400
productId = assetid
currencyTypeId = request.json["expectedCurrency"]
expectedPrice = request.json["expectedPrice"]
if not websiteFeatures.GetWebsiteFeature("EconomyPurchase"):
return jsonify({"purchased": False, "reason": "EconomyDisabled", "errorMsg": "Purchasing is temporarily disabled, try again later."}),400
if productId is None or currencyTypeId is None or expectedPrice is None:
return jsonify({"purchased": False, "reason": "Invalid request", "errorMsg": "Invalid Request"}),400
currencyType = 0 if currencyTypeId == 1 else 1
AssetObj : Asset = Asset.query.filter_by(id=productId).first()
if AssetObj is None:
return jsonify({"purchased": False, "reason": "Invalid request", "errorMsg": "Invalid Request"}),400
if AssetObj.is_limited or not AssetObj.is_for_sale:
return jsonify({"purchased": False, "reason": "NotForSale", "errorMsg": "This item is not for sale"}),400
LockAssetName = f"asset:{str(AssetObj.id)}"
AssetLock = redislock.acquire_lock(LockAssetName, acquire_timeout=15, lock_timeout=1)
# if AssetLock is False:
# return jsonify({"success": False, "status": "InternalServerError"}),500
@after_this_request
def release_asset_lock(response):
if AssetLock:
redislock.release_lock(LockAssetName, AssetLock)
UserRobuxBalance, UserTixBalance = economy.GetUserBalance(AuthenticatedUser)
if currencyType == 0:
if AssetObj.price_robux == 0 and AssetObj.price_tix != 0:
return jsonify({"purchased": False, "reason": "CurrencyNotAccepted", "errorMsg": "This currency is not accepted for this item"}),400
if AssetObj.price_robux != expectedPrice:
return jsonify({"purchased": False, "reason": "PriceChanged", "errorMsg": "The price has changed"}),400
if UserRobuxBalance < expectedPrice:
return jsonify({"purchased": False, "reason": "InsufficientFunds", "errorMsg": "You do not have enough Robux to purchase this item."}),400
else:
if AssetObj.price_tix == 0 and AssetObj.price_robux != 0:
return jsonify({"purchased": False, "reason": "CurrencyNotAccepted", "errorMsg": "This currency is not accepted for this item"}),400
if AssetObj.price_tix != expectedPrice:
return jsonify({"purchased": False, "reason": "PriceChanged", "errorMsg": "The price has changed"}),400
if UserTixBalance < expectedPrice:
return jsonify({"purchased": False, "reason": "InsufficientFunds", "errorMsg": "You do not have enough Robux to purchase this item."}),400
# Check if the user already owns the asset
UserAssetObj : UserAsset = UserAsset.query.filter_by(userid=AuthenticatedUser.id, assetid=productId).first()
if UserAssetObj is not None:
return jsonify({"purchased": False, "reason": "AlreadyOwned", "errorMsg": "You already own this item."}),400
try:
economy.DecrementTargetBalance(AuthenticatedUser, expectedPrice, currencyType)
except economy.InsufficientFundsException:
return jsonify({"purchased": False, "reason": "InsufficientFunds", "errorMsg": "You do not have enough Robux to purchase this item."}),400
UserAssetObj = UserAsset(
userid = AuthenticatedUser.id,
assetid = productId
)
if AssetObj.asset_type == AssetType.Package:
PackageAssets = PackageAsset.query.filter_by(package_asset_id=AssetObj.id).all()
for PackageAssetObj in PackageAssets:
NewPackageAssetObj = UserAsset(userid=AuthenticatedUser.id, assetid=PackageAssetObj.asset_id)
db.session.add(NewPackageAssetObj)
IncrementAssetCreator(AssetObj, expectedPrice, currencyType)
AssetObj.sale_count += 1
db.session.add(UserAssetObj)
db.session.commit()
try:
if AssetObj.creator_type == 0:
SellerUserObj : User = User.query.filter_by(id=AssetObj.creator_id).first()
CreateTransactionForSale(
AssetObj = AssetObj,
PurchasePrice = expectedPrice,
PurchaseCurrencyType = currencyType,
Seller = SellerUserObj,
Buyer = AuthenticatedUser,
ApplyTaxAutomatically = True
)
elif AssetObj.creator_type == 1:
SellerGroupObj : Group = Group.query.filter_by(id=AssetObj.creator_id).first()
CreateTransactionForSale(
AssetObj = AssetObj,
PurchasePrice = expectedPrice,
PurchaseCurrencyType = currencyType,
Seller = SellerGroupObj,
Buyer = AuthenticatedUser,
ApplyTaxAutomatically = True
)
except Exception as e:
logging.warn(f"Failed to create transaction log for sale of asset {str(AssetObj.id)}, message: {str(e)}")
pass
if AssetObj.creator_type == 0:
CreatorObj : User = User.query.filter_by(id = AssetObj.creator_id).first()
sellerName = CreatorObj.username
else:
CreatorObj : Group = Group.query.filter_by(id = AssetObj.creator_id).first()
sellerName = CreatorObj.name
return jsonify({
"purchased": True,
"reason": "Success",
"productId": assetid,
"currency": currencyTypeId,
"assetId": assetid,
"assetName": AssetObj.name,
"assetType": AssetObj.asset_type.name,
"assetTypeDisplayName": AssetObj.asset_type.name,
"assetIsWearable": False,
"sellerName": sellerName,
"transactionVerb": "bought",
"isMultiPrivateSale": False
})
@MarketPlaceRoute.route("/submitpurchase", methods=["POST"])
@csrf.exempt
@auth.authenticated_required_api
@user_limiter.limit("20/minute")
@user_limiter.limit("1/second")
def SubmitProductPurchase():
productId : int = request.form.get( key = "productId", default = None, type = int )
currencyTypeId : int = request.form.get( key = "currencyTypeId", default = None, type = int )
expectedUnitPrice : int = request.form.get( key = "expectedUnitPrice", default = None, type = int )
placeId : int = request.form.get( key = "placeId", default = None, type = int )
requestId : str = request.form.get( key = "requestId", default = None, type = str )
if not websiteFeatures.GetWebsiteFeature("EconomyPurchase"):
return jsonify({"success": False, "status": "EconomyDisabled"}),400
if productId is None or currencyTypeId is None or expectedUnitPrice is None or placeId is None or requestId is None:
return jsonify({"success": False, "message": "Invalid request"}),400
currencyType = 0 if currencyTypeId == 1 else 1
TargetDeveloperProduct : DeveloperProduct = DeveloperProduct.query.filter_by( productid = productId, placeid = placeId ).first()
if TargetDeveloperProduct is None:
return jsonify({"success": False, "message": "Invalid request"}),400
PlaceAssetObj : Asset = Asset.query.filter_by( id = TargetDeveloperProduct.placeid ).first()
if TargetDeveloperProduct.is_for_sale == False:
return jsonify({"success": False, "message": "Invalid request"}),400
if TargetDeveloperProduct.robux_price != expectedUnitPrice:
return jsonify({"success": False, "message": "Invalid request"}),400
if redis_controller.exists(f"purchase_request:{requestId}"):
return jsonify({"success": False, "message": "Invalid request"}),400
if currencyType != 0: # Developer Products can only be purchased with Robux
return jsonify({"success": False, "message": "Invalid request"}),400
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
UserRobuxBalance, _ = economy.GetUserBalance(AuthenticatedUser)
if UserRobuxBalance < expectedUnitPrice:
return jsonify({"success": False, "status": "InsufficientFunds"}),400
try:
economy.DecrementTargetBalance(AuthenticatedUser, expectedUnitPrice, currencyType)
except economy.InsufficientFundsException:
return jsonify({"success": False, "status": "InsufficientFunds"}),400
OwnerObj : User | Group = User.query.filter_by( id = PlaceAssetObj.creator_id ).first() if PlaceAssetObj.creator_type == 0 else Group.query.filter_by( id = PlaceAssetObj.creator_id ).first()
transactions.CreateTransaction(
Reciever = OwnerObj,
Sender = AuthenticatedUser,
CurrencyAmount = expectedUnitPrice,
CurrencyType = currencyType,
TransactionType = TransactionType.Purchase,
CustomText = f"Purchase of Product({TargetDeveloperProduct.name}) from {PlaceAssetObj.name}"
)
GrossProfitAfterTax = math.floor(expectedUnitPrice * 0.7)
economy.IncrementTargetBalance(OwnerObj, GrossProfitAfterTax, currencyType)
transactions.CreateTransaction(
Reciever = OwnerObj,
Sender = AuthenticatedUser,
CurrencyAmount = GrossProfitAfterTax,
CurrencyType = currencyType,
TransactionType = TransactionType.Sale,
CustomText = f"Sale of Product({TargetDeveloperProduct.name}) from {PlaceAssetObj.name}"
)
redis_controller.set(f"purchase_request:{requestId}", "1", ex = 60 * 10)
DeveloperProductReceiptObj : ProductReceipt = ProductReceipt(
user_id = AuthenticatedUser.id,
product_id = TargetDeveloperProduct.productid,
robux_amount = expectedUnitPrice
)
TargetDeveloperProduct.sales_count += 1
db.session.add(DeveloperProductReceiptObj)
db.session.commit()
return jsonify({
"success": True,
"receipt": DeveloperProductReceiptObj.receipt_id
}),200
+28
View File
@@ -0,0 +1,28 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify, make_response
from sqlalchemy import func
import hashlib
import random
import logging
from config import Config
from app.extensions import limiter, csrf, get_remote_address
from app.models.user import User
from app.models.user_email import UserEmail
from app.util import auth
from app.services import economy
from datetime import datetime, timedelta
config = Config()
MobileAPIRoute = Blueprint('mobile', __name__, url_prefix='/')
@MobileAPIRoute.route("/device/initialize", methods=["POST"])
@csrf.exempt
def DeviceInit():
return jsonify({"browserTrackerId":random.randint(100000000,9999999999),"appDeviceIdentifier":None})
@MobileAPIRoute.route("/mobileapi/check-app-version", methods=["GET"])
def check_app_version():
return jsonify({"data":{"UpgradeAction":"None"}})
@MobileAPIRoute.route("/mobile/pbe", methods=["GET"])
def mobile_pbe():
return "", 200
+52
View File
@@ -0,0 +1,52 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify
from app.models.gameservers import GameServer
from app.models.pointsservice import PointsService
from app.extensions import get_remote_address, db, csrf
import logging
PointsServiceRoute = Blueprint('pointsservice', __name__)
@PointsServiceRoute.route("/points/get-point-balance", methods=["GET"])
def get_point_balance():
userId = request.args.get('userId')
placeId = request.args.get('placeId')
if userId is None or placeId is None:
return jsonify({"success": False, "message": "Missing parameters"}),400
if userId.isnumeric() is False or placeId.isnumeric() is False:
return jsonify({"success": False, "message": "Invalid parameters"}),400
RequestIP = get_remote_address()
if RequestIP is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
server = GameServer.query.filter_by(serverIP=RequestIP).first()
if server is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
points : PointsService = PointsService.query.filter_by(userId=userId, placeId=placeId).first()
if points is None:
points = PointsService(userId=userId, placeId=placeId, points=0)
db.session.add(points)
db.session.commit()
return jsonify({"success": True, "pointBalance": points.points}),200
@PointsServiceRoute.route("/points/award-points", methods=["POST"])
@csrf.exempt
def award_points():
userId = request.args.get(key='userId', default=None, type=int)
placeId = request.args.get(key='placeId', default=None, type=int)
amount = request.args.get(key='amount', default=None, type=int)
if userId is None or placeId is None or amount is None:
return jsonify({"success": False, "message": "Missing parameters"}),400
RequestIP = get_remote_address()
if RequestIP is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
server = GameServer.query.filter_by(serverIP=RequestIP).first()
if server is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
points : PointsService = PointsService.query.filter_by(userId=userId, placeId=placeId).first()
if points is None:
points = PointsService(userId=userId, placeId=placeId, points=0)
db.session.add(points)
db.session.commit()
points.points += int(amount)
db.session.commit()
return jsonify({"success": True, "userBalance": points.points, "pointsAwarded": amount, "userGameBalance": points.points}),200
+17
View File
@@ -0,0 +1,17 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response
from app.extensions import csrf, db
from app.util import auth, friends
from app.models.user import User
from datetime import datetime
PresenceRoute = Blueprint("presence", __name__, url_prefix="/presence")
@PresenceRoute.route("/", methods=["GET"])
@auth.authenticated_required_api
def UpdatePresence():
Authuser : User = auth.GetCurrentUser()
if Authuser is None:
return "BAD REQUEST", 400
Authuser.lastonline = datetime.utcnow()
db.session.commit()
return "OK", 200
+105
View File
@@ -0,0 +1,105 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify, make_response
from app.util import auth
from app.extensions import db, csrf, limiter
from flask_wtf.csrf import CSRFError, generate_csrf
from datetime import datetime, timedelta
from app.models.user_email import UserEmail
from app.models.user import User
from app.models.placeserver_players import PlaceServerPlayer
from app.models.placeservers import PlaceServer
from app.models.place import Place
from app.models.asset import Asset
PresenceAPIRoute = Blueprint('presenceapiroute', __name__, url_prefix='/')
csrf.exempt(PresenceAPIRoute)
@PresenceAPIRoute.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@PresenceAPIRoute.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@PresenceAPIRoute.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
csrf.protect()
@PresenceAPIRoute.route("/v1/presence/users", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("30/minute")
def multi_get_users_presence():
if not request.is_json:
return jsonify({"errors": [{"code": 0,"message": "Invalid Request"}]}), 400
if "userIds" not in request.json:
return jsonify({"errors": [{"code": 0,"message": "Invalid Request"}]}), 400
userIds = request.json["userIds"]
if type(userIds) is not list:
return jsonify({"errors": [{"code": 0,"message": "Invalid Request"}]}), 400
if len(userIds) > 100:
return jsonify({"errors": [{"code": 0,"message": "Invalid Request"}]}), 400
requestedData = []
for userId in userIds:
try:
userId = int(userId)
except:
return jsonify({"errors": [{"code": 1,"message": "Invalid UserId"}]}), 400
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify({"errors": [{"code": 1,"message": "Invalid UserId"}]}), 400
else:
isUserOnline = userObject.lastonline > datetime.utcnow() - timedelta(minutes=1)
UserPlaceServerPlayerObj : PlaceServerPlayer | None = PlaceServerPlayer.query.filter_by( userid = userId ).first()
isUserInGame = UserPlaceServerPlayerObj is not None
userPresenceType = 2 if isUserInGame else 1 if isUserOnline else 0
PlaceServerHost : PlaceServer | None = None
PlaceObject : Place | None = None
if isUserInGame:
PlaceServerHost : PlaceServer = PlaceServer.query.filter_by( serveruuid = UserPlaceServerPlayerObj.serveruuid ).first()
if PlaceServerHost is not None:
PlaceObject : Place = Place.query.filter_by( placeid = PlaceServerHost.serverPlaceId ).first()
PlaceAssetObj : Asset = PlaceObject.assetObj
else:
PlaceAssetObj = None
UserPlaceServerPlayerObj = None
requestedData.append({
"userPresenceType": userPresenceType,
"lastLocation": "Website" if not isUserInGame else PlaceAssetObj.name,
"placeId": PlaceAssetObj.id if isUserInGame else None,
"rootPlaceId": PlaceAssetObj.id if isUserInGame else None,
"gameId": PlaceServerHost.serveruuid if isUserInGame else None,
"userId": userId,
"lastOnline": userObject.lastonline.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
})
return jsonify({
"userPresences": requestedData
}), 200
+100
View File
@@ -0,0 +1,100 @@
# prometheus.py
# Used for providing metrics to Prometheus
# https://prometheus.io/docs/instrumenting/writing_exporters/
import time
from flask import Blueprint, Response, abort
from datetime import datetime, timedelta
from sqlalchemy import func
from app.models.user import User
from app.models.placeserver_players import PlaceServerPlayer
from app.models.placeservers import PlaceServer
from app.models.gameservers import GameServer
from app.models.game_session_log import GameSessionLog
from app.models.asset import Asset
from app.extensions import get_remote_address
from config import Config
config = Config()
PrometheusRoute = Blueprint('prometheus', __name__, url_prefix='/')
@PrometheusRoute.before_request
def before_request():
if not config.PROMETHEUS_ENABLED:
return abort( 404 )
ClientRemoteAddress = get_remote_address()
if ClientRemoteAddress not in config.PROMETHEUS_ALLOWED_IPS:
return abort( 404 )
@PrometheusRoute.route('/metrics', methods=['GET'])
def metrics():
StartingMeasureTime = time.time()
UsersOnlineCount = User.query.filter(User.lastonline > (datetime.utcnow() - timedelta(minutes=1))).count()
UsersIngame = PlaceServerPlayer.query.count()
UsersInReservedServers = PlaceServerPlayer.query.join( PlaceServer, PlaceServerPlayer.serveruuid == PlaceServer.serveruuid ).filter(PlaceServer.reservedServerAccessCode != None).count()
ActivePlaceServers = PlaceServer.query.count()
ActiveReservedPlaceServers = PlaceServer.query.filter(PlaceServer.reservedServerAccessCode != None).count()
UsersSignedUpPast24Hours = User.query.filter(User.created > (datetime.utcnow() - timedelta(days=1))).count()
GameServersTotalMemoryUsage = GameServer.query.with_entities( func.sum(GameServer.RCCmemoryUsage) ).scalar() # Megabytes
UniqueGameSessionsPast24Hours = GameSessionLog.query.filter(GameSessionLog.joined_at > (datetime.utcnow() - timedelta(days=1))).distinct(GameSessionLog.user_id).count()
GameSessionsPast24Hours = GameSessionLog.query.filter(GameSessionLog.joined_at > (datetime.utcnow() - timedelta(days=1))).count()
TotalUsersSignedUp = User.query.count()
TotalAssets = Asset.query.count()
TimeTaken = time.time() - StartingMeasureTime
PROMETHEUS_RESPONSE = f"""# HELP syntaxeco_users_online The number of users currently online
# TYPE syntaxeco_users_online gauge
syntaxeco_users_online {UsersOnlineCount}
# HELP syntaxeco_users_ingame The number of users currently in a game
# TYPE syntaxeco_users_ingame gauge
syntaxeco_users_ingame {UsersIngame}
# HELP syntaxeco_active_placeservers The number of active place servers
# TYPE syntaxeco_active_placeservers gauge
syntaxeco_active_placeservers {ActivePlaceServers}
# HELP syntaxeco_active_reserved_placeservers The number of active reserved place servers
# TYPE syntaxeco_active_reserved_placeservers gauge
syntaxeco_active_reserved_placeservers {ActiveReservedPlaceServers}
# HELP syntaxeco_users_in_reserved_servers The number of users currently in reserved servers
# TYPE syntaxeco_users_in_reserved_servers gauge
syntaxeco_users_in_reserved_servers {UsersInReservedServers}
# HELP syntaxeco_users_signed_up_past_24_hours The number of users that signed up in the past 24 hours
# TYPE syntaxeco_users_signed_up_past_24_hours gauge
syntaxeco_users_signed_up_past_24_hours {UsersSignedUpPast24Hours}
# HELP syntaxeco_gameservers_total_memory_usage The total memory usage of all game servers
# TYPE syntaxeco_gameservers_total_memory_usage gauge
syntaxeco_gameservers_total_memory_usage {GameServersTotalMemoryUsage}
# HELP syntaxeco_unique_gamesessions_past_24_hours The number of unique game sessions in the past 24 hours
# TYPE syntaxeco_unique_gamesessions_past_24_hours gauge
syntaxeco_unique_gamesessions_past_24_hours {UniqueGameSessionsPast24Hours}
# HELP syntaxeco_gamesessions_past_24_hours The number of game sessions in the past 24 hours
# TYPE syntaxeco_gamesessions_past_24_hours gauge
syntaxeco_gamesessions_past_24_hours {GameSessionsPast24Hours}
# HELP syntaxeco_total_users_signed_up The total number of users that have signed up
# TYPE syntaxeco_total_users_signed_up gauge
syntaxeco_total_users_signed_up {TotalUsersSignedUp}
# HELP syntaxeco_total_assets The total number of assets
# TYPE syntaxeco_total_assets gauge
syntaxeco_total_assets {TotalAssets}
# HELP syntaxeco_prometheus_request_time The time taken to generate the Prometheus metrics
# TYPE syntaxeco_prometheus_request_time gauge
syntaxeco_prometheus_request_time {TimeTaken}
"""
return Response(PROMETHEUS_RESPONSE, mimetype="text/plain")
+525
View File
@@ -0,0 +1,525 @@
import time
import math
import calendar
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response, abort, flash
from sqlalchemy import func
from app.extensions import limiter, csrf, db, user_limiter
from app.models.user import User
from app.models.asset import Asset
from app.models.userassets import UserAsset
from app.models.user_trades import UserTrade
from app.models.user_trade_items import UserTradeItem
from app.models.groups import Group
from app.models.linked_discord import LinkedDiscord
from app.models.placeserver_players import PlaceServerPlayer
from app.models.limited_item_transfers import LimitedItemTransfer
from app.util import auth, redislock
from app.enums.AssetType import AssetType
from app.enums.MembershipType import MembershipType
from app.enums.TradeStatus import TradeStatus
from app.enums.LimitedItemTransferMethod import LimitedItemTransferMethod
from app.util.membership import GetUserMembership
from app.services.economy import GetAssetRap, GetUserBalance, DecrementTargetBalance, IncrementTargetBalance, CalculateUserRAP
from app.services.groups import GetGroupFromId
from app.pages.trades.trades import createTradePost
from datetime import datetime, timedelta
from sqlalchemy import or_, and_
from app.models.user_ban import UserBan
PublicAPIRoute = Blueprint("publicapi", __name__, url_prefix="/public-api")
def ReturnError( message : str, code : int = 403 ):
return jsonify({
"success": False,
"message": message,
"data": None
}), code
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 Exception("User does not exist.")
return TargetUser
def ReturnUserObject( UserObj : User ) -> dict:
return {
"id": UserObj.id,
"username": UserObj.username,
"last_online": int(calendar.timegm(UserObj.lastonline.timetuple())),
"created_at": int(calendar.timegm(UserObj.created.timetuple())),
"description": UserObj.description,
"membership": GetUserMembership(UserObj, changeToString=True),
"is_banned": UserObj.accountstatus != 1,
"inventory_rap": CalculateUserRAP(UserObj)
}
def ReturnGroupObject( GroupObj : Group ) -> dict:
return {
"id": GroupObj.id,
"name": GroupObj.name,
"created_at": int(calendar.timegm(GroupObj.created_at.timetuple())),
"user_owner_id": GroupObj.owner_id
}
def ReturnItemObject( AssetObj : Asset, ListCreator : bool = True, includeLimitedInfo : bool = True) -> dict:
Item = {
"id": AssetObj.id,
"name": AssetObj.name,
"description": AssetObj.description,
"asset_type": AssetObj.asset_type.name,
"asset_type_value": AssetObj.asset_type.value,
"creator_id": AssetObj.creator_id,
"creator_type": AssetObj.creator_type,
"created_at": int(calendar.timegm(AssetObj.created_at.timetuple())),
"updated_at": int(calendar.timegm(AssetObj.updated_at.timetuple())),
"is_for_sale": AssetObj.is_for_sale,
"price_robux": AssetObj.price_robux,
"price_tickets": AssetObj.price_tix,
"sales": AssetObj.sale_count
}
if includeLimitedInfo:
Item["is_limited"] = AssetObj.is_limited
Item["is_limited_unique"] = AssetObj.is_limited_unique
Item["asset_rap"] = GetAssetRap(AssetObj.id) if AssetObj.is_limited and not AssetObj.is_for_sale else None
if ListCreator:
Item["creator"] = ReturnUserObject(GetUserFromId(AssetObj.creator_id)) if AssetObj.creator_type == 0 else ReturnGroupObject(GetGroupFromId(AssetObj.creator_id))
return Item
@PublicAPIRoute.errorhandler(429)
def ratelimit_handler(e):
return ReturnError("You are being ratelimited, please try again later.", 429)
@PublicAPIRoute.errorhandler(500)
def internalerror_handler(e):
return ReturnError("An internal error occured, please try again later.", 500)
@PublicAPIRoute.route("/", methods=["GET"])
def PublicAPIDocs():
return render_template("swaggerdocs.html")
@PublicAPIRoute.route("/v1/users/<int:userid>", methods=["GET"])
@limiter.limit("20/minute")
def LookupUserId(userid : int):
UserObj : User = User.query.filter_by(id=userid).first()
if UserObj is None:
return ReturnError("User not found", 404)
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObj)
}), 200
@PublicAPIRoute.route("/v1/users/username/<string:username>", methods=["GET"])
@limiter.limit("20/minute")
def LookupUsername(username : str):
UserObj : User = User.query.filter(func.lower(User.username) == func.lower(username)).first()
if UserObj is None:
return ReturnError("User not found", 404)
if UserObj.accountstatus == 4: # GDPR
return ReturnError("User not found", 404)
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObj)
}), 200
@PublicAPIRoute.route("/sitestats", methods=["GET"])
@limiter.limit("30/minute")
def SiteStatsHTTP():
UsersOnline = User.query.filter(User.lastonline > (datetime.utcnow() - timedelta(minutes=1))).count()
UsersIngame = PlaceServerPlayer.query.count()
BannedByAnticheat = UserBan.query.filter_by(reason="Exploiting in games is not tolerated on Vortexi").count() # meme
UsersSignedUpToday = User.query.filter(User.created > (datetime.utcnow() - timedelta(days=1))).count()
UsersSignedUpYesterday = User.query.filter(and_( User.created > ( datetime.utcnow() - timedelta(days=2) ), User.created < ( datetime.utcnow() - timedelta(days=1) ) )).count() # i actually forgot why this is needed, but its probably needed to do some cool shit idk
TotalUsers = User.query.count()
return jsonify({
"success": True,
"message": "",
"data": {
"users_online": UsersOnline,
"users_ingame": UsersIngame,
"signed_up_today": UsersSignedUpToday,
"signed_up_yesterday": UsersSignedUpYesterday,
"total_users": TotalUsers,
"banned_by_ac": BannedByAnticheat
}
}), 200
@PublicAPIRoute.route("/v1/users/discord_id/<int:discordid>", methods=["GET"])
@limiter.limit("20/minute")
def LookupUserByDiscordId(discordid : int):
LinkedDiscordObj : LinkedDiscord = LinkedDiscord.query.filter_by( discord_id = discordid ).first()
if LinkedDiscordObj is None:
return ReturnError("No Vortexi account is associated with this Discord ID", 404)
UserObj : User = User.query.filter_by( id = LinkedDiscordObj.user_id ).first()
if UserObj.accountstatus == 4:
return ReturnError("No Vortexi account is associated with this Discord ID", 404)
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(UserObj)
}), 200
@PublicAPIRoute.route("/v1/asset/<int:assetid>", methods=["GET"])
@limiter.limit("20/minute")
def LookupItemId(assetid : int):
ItemObj : Asset = Asset.query.filter_by(id=assetid).first()
if ItemObj is None:
return ReturnError("Asset not found", 404)
if ItemObj.creator_type == 0 and ItemObj.creator_id == 1 and ( datetime.utcnow() < ( ItemObj.created_at + timedelta(minutes=10) ) ):
return ReturnError("Asset created recently, please wait...", 403)
return jsonify({
"success": True,
"message": "",
"data": ReturnItemObject(ItemObj, includeLimitedInfo=False)
}), 200
@PublicAPIRoute.route("/v1/inventory/collectibles/<int:userid>", methods=["GET"])
@limiter.limit("20/minute")
def LookupUserInventoryCollectibles(userid : int):
UserObj : User = User.query.filter_by(id=userid).first()
if UserObj is None:
return ReturnError("User not found", 404)
PageNumber = request.args.get("page", default=1, type=int)
if PageNumber < 1:
PageNumber = 1
UserAssets : list[UserAsset] = UserAsset.query.filter_by(userid=userid).join(Asset).filter_by(is_limited=True).paginate( page = PageNumber, per_page = 12, error_out = False )
FormattedUserAsset = []
for UserAssetObj in UserAssets.items:
FormattedUserAsset.append({
"uaid": UserAssetObj.id,
"serial": UserAssetObj.serial,
"price": UserAssetObj.price,
"asset": ReturnItemObject(UserAssetObj.asset, ListCreator=False)
})
return jsonify({
"success": True,
"message": "",
"data": FormattedUserAsset,
"page": PageNumber,
"total_pages": UserAssets.pages,
"next_page": UserAssets.next_num if UserAssets.has_next else None
})
@PublicAPIRoute.route("/v1/inventory/assets/<int:userid>/<int:assettypeid>", methods=["GET"])
@limiter.limit("20/minute")
def LookupUserInventory( userid : int, assettypeid : int ):
UserObj : User = User.query.filter_by(id=userid).first()
if UserObj is None:
return ReturnError("User not found", 404)
PageNumber = request.args.get("page", default=1, type=int)
try:
AssetTypeObj : AssetType = AssetType(assettypeid)
except ValueError:
ReturnError("Invalid asset type, please refer to documentation https://create.roblox.com/docs/reference/engine/enums/AssetType", 400)
if PageNumber < 1:
PageNumber = 1
UserAssets : list[UserAsset] = UserAsset.query.filter_by(userid=userid).join(Asset).filter_by(asset_type=AssetTypeObj).order_by(UserAsset.id.desc()).paginate( page = PageNumber, per_page = 12, error_out = False )
FormattedUserAsset = []
for UserAssetObj in UserAssets.items:
FormattedUserAsset.append({
"uaid": UserAssetObj.id,
"serial": UserAssetObj.serial,
"price": UserAssetObj.price,
"asset": ReturnItemObject(UserAssetObj.asset, ListCreator=False)
})
return jsonify({
"success": True,
"message": "",
"data": FormattedUserAsset,
"page": PageNumber,
"total_pages": UserAssets.pages,
"next_page": UserAssets.next_num if UserAssets.has_next else None
})
@PublicAPIRoute.route("/v1/economy/my-balance", methods=["GET"])
@auth.authenticated_required_api
@limiter.limit("60/minute")
def GetMyBalance():
AuthenticatedUser : User = auth.GetCurrentUser()
RobuxBal, TicketsBal = GetUserBalance(AuthenticatedUser)
return jsonify({
"success": True,
"message": "",
"data": {
"robux": RobuxBal,
"tickets": TicketsBal
}
})
@PublicAPIRoute.route("/v1/users/my-profile", methods=["GET"])
@auth.authenticated_required_api
def GetMyProfile():
AuthenticatedUser : User = auth.GetCurrentUser()
return jsonify({
"success": True,
"message": "",
"data": ReturnUserObject(AuthenticatedUser)
})
@PublicAPIRoute.route("/v1/trade/list", methods=["GET"])
@auth.authenticated_required_api
@limiter.limit("60/minute")
def GetMyTrades():
AuthenticatedUser : User = auth.GetCurrentUser()
PageNumber = request.args.get("page", default=1, type=int)
if PageNumber < 1:
PageNumber = 1
UserTradesList : list[UserTrade] = UserTrade.query.filter(or_(UserTrade.sender_userid == AuthenticatedUser.id, UserTrade.recipient_userid == AuthenticatedUser.id)).order_by(UserTrade.id.desc()).paginate( page = PageNumber, per_page = 12, error_out = False )
FormattedUserTrades = []
for tradeObj in UserTradesList:
tradeObj : UserTrade = tradeObj # type hinting
FormattedUserTrades.append({
"id": tradeObj.id,
"sender_userid": tradeObj.sender_userid,
"recipient_userid": tradeObj.recipient_userid,
"created_at": int(calendar.timegm(tradeObj.created_at.timetuple())),
"expires_at": int(calendar.timegm(tradeObj.expires_at.timetuple())),
"status": tradeObj.status.name
})
return jsonify({
"success": True,
"message": "",
"data": FormattedUserTrades,
"page": PageNumber,
"total_pages": UserTradesList.pages,
"next_page": UserTradesList.next_num if UserTradesList.has_next else None
})
@PublicAPIRoute.route("/v1/trade/<int:tradeid>", methods=["GET"])
@auth.authenticated_required_api
@limiter.limit("60/minute")
def GetTradeInfo(tradeid : int):
AuthenticatedUser : User = auth.GetCurrentUser()
TradeObj : UserTrade = UserTrade.query.filter_by(id=tradeid).first()
if TradeObj is None:
return ReturnError("Trade not found", 404)
if TradeObj.sender_userid != AuthenticatedUser.id and TradeObj.recipient_userid != AuthenticatedUser.id:
return ReturnError("You are not the sender or recipient of this trade")
TradeItems : list[UserTradeItem] = UserTradeItem.query.filter_by(tradeid=TradeObj.id).all()
SenderItems = []
RecipientItems = []
for TradeItem in TradeItems:
TradeItem : UserTradeItem = TradeItem
if TradeItem.userid == TradeObj.sender_userid:
SenderItems.append({
"uaid": TradeItem.userasset.id,
"serial": TradeItem.userasset.serial,
"price": TradeItem.userasset.price,
"asset": ReturnItemObject(TradeItem.userasset.asset, ListCreator=False)
})
else:
RecipientItems.append({
"uaid": TradeItem.userasset.id,
"serial": TradeItem.userasset.serial,
"price": TradeItem.userasset.price,
"asset": ReturnItemObject(TradeItem.userasset.asset, ListCreator=False)
})
return jsonify({
"success": True,
"message": "",
"data": {
"id": TradeObj.id,
"sender_userid": TradeObj.sender_userid,
"recipient_userid": TradeObj.recipient_userid,
"created_at": int(calendar.timegm(TradeObj.created_at.timetuple())),
"expires_at": int(calendar.timegm(TradeObj.expires_at.timetuple())),
"status": TradeObj.status.name,
"sender_items": SenderItems,
"recipient_items": RecipientItems,
"sender_robux": TradeObj.sender_userid_robux,
"recipient_robux": TradeObj.recipient_userid_robux
}
})
@PublicAPIRoute.route("/v1/trade/create/<int:recipient_userid>", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
@user_limiter.limit("5/minute")
@csrf.exempt
def createTradeProxy( recipient_userid : int ):
return createTradePost(recipient_userid)
@PublicAPIRoute.route("/v1/trade/accept/<int:tradeid>", methods=["POST"])
@auth.authenticated_required_api
@csrf.exempt
def acceptTrade( tradeid : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
TradeObj : UserTrade = UserTrade.query.filter_by(id=tradeid).first()
if TradeObj is None:
return ReturnError("Trade not found", 404)
if TradeObj.recipient_userid != AuthenticatedUser.id:
return ReturnError("You are not the recipient of this trade")
if TradeObj.status != TradeStatus.Pending:
return ReturnError("Trade is not pending")
if TradeObj.expires_at < datetime.utcnow():
TradeObj.status = TradeStatus.Expired
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
return ReturnError("Trade has expired")
if AuthenticatedUser.TOTPEnabled:
JSONPayload = request.json
if JSONPayload is None:
return ReturnError("Expected JSON payload", 400)
if "TOTPCode" not in JSONPayload:
return ReturnError("Missing parameter 'TOTPCode' in JSON payload", 400)
if not auth.Validate2FACode(AuthenticatedUser.id, JSONPayload["TOTPCode"]):
return ReturnError("Invalid 2FA code")
UserCurrentMembership : MembershipType = GetUserMembership(AuthenticatedUser.id)
if UserCurrentMembership == MembershipType.NonBuildersClub:
return ReturnError("You must be a Builders Club member to accept trades")
OppositeUser : User = User.query.filter_by(id=TradeObj.sender_userid).first()
if OppositeUser is None:
return ReturnError("An error occured while trying to complete this trade. Please try again later.")
OppositeUserCurrentMembership : MembershipType = GetUserMembership(OppositeUser.id)
if OppositeUserCurrentMembership == MembershipType.NonBuildersClub:
return ReturnError("The other user must be a Builders Club member to accept trades")
TradeItems : list[UserTradeItem] = UserTradeItem.query.filter_by(tradeid=TradeObj.id).all()
for TradeItem in TradeItems:
UserAssetObj : UserAsset = UserAsset.query.filter_by(id=TradeItem.user_asset_id).first()
if UserAssetObj is None:
return ReturnError("One of the items no longer exists and this trade cannot be completed.", 400)
if UserAssetObj.userid != TradeItem.userid:
return ReturnError("One of the items no longer belongs to its original owner and this trade cannot be completed.", 400)
SenderRobuxBal, _ = GetUserBalance(GetUserFromId(TradeObj.sender_userid))
RecipientRobuxBal, _ = GetUserBalance(GetUserFromId(TradeObj.recipient_userid))
if SenderRobuxBal < TradeObj.sender_userid_robux:
return ReturnError("You do not have enough Robux to complete this trade.")
if RecipientRobuxBal < TradeObj.recipient_userid_robux:
return ReturnError("The other user does not have enough Robux to complete this trade.")
ItemLocks = []
for TradeItem in TradeItems:
TradeItemLock = redislock.acquire_lock(f"item:{str(TradeItem.user_asset_id)}", acquire_timeout=20, lock_timeout=5)
if not TradeItemLock:
for ItemLock in ItemLocks:
redislock.release_lock(f"item:{str(ItemLock[1])}", ItemLock[0])
return ReturnError("An error occured while trying to complete this trade. Please try again later.")
ItemLocks.append([TradeItemLock, TradeItem.user_asset_id])
def ReleaseAllLocks():
for ItemLock in ItemLocks:
redislock.release_lock(f"item:{str(ItemLock[1])}", ItemLock[0])
if TradeObj.sender_userid_robux > 0:
DecrementTargetBalance(GetUserFromId(TradeObj.sender_userid), TradeObj.sender_userid_robux, 0)
FinalAdded = math.floor(TradeObj.sender_userid_robux * 0.7)
IncrementTargetBalance(GetUserFromId(TradeObj.recipient_userid), FinalAdded, 0)
if TradeObj.recipient_userid_robux > 0:
DecrementTargetBalance(GetUserFromId(TradeObj.recipient_userid), TradeObj.recipient_userid_robux, 0)
FinalAdded = math.floor(TradeObj.recipient_userid_robux * 0.7)
IncrementTargetBalance(GetUserFromId(TradeObj.sender_userid), FinalAdded, 0)
def CreateItemTransferLog( original_owner_id : int, new_owner_id : int, asset_id : int, user_asset_id : int ):
NewTransferLog = LimitedItemTransfer(
original_owner_id = original_owner_id,
new_owner_id = new_owner_id,
asset_id = asset_id,
user_asset_id = user_asset_id,
transfer_method = LimitedItemTransferMethod.Trade,
associated_trade_id = TradeObj.id
)
db.session.add(NewTransferLog)
for TradeItem in TradeItems:
UserAssetObj : UserAsset = UserAsset.query.filter_by(id=TradeItem.user_asset_id).first()
if UserAssetObj.userid == TradeObj.sender_userid:
CreateItemTransferLog( original_owner_id = TradeObj.sender_userid, new_owner_id = TradeObj.recipient_userid, asset_id = UserAssetObj.assetid, user_asset_id = UserAssetObj.id )
UserAssetObj.userid = TradeObj.recipient_userid
else:
CreateItemTransferLog( original_owner_id = TradeObj.sender_userid, new_owner_id = TradeObj.recipient_userid, asset_id = UserAssetObj.assetid, user_asset_id = UserAssetObj.id )
UserAssetObj.userid = TradeObj.sender_userid
UserAssetObj.price = 0
UserAssetObj.is_for_sale = False
UserAssetObj.updated = datetime.utcnow()
db.session.commit()
TradeObj.status = TradeStatus.Accepted
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
ReleaseAllLocks()
return jsonify({
"success": True,
"message": "Trade Completed",
"data": None
})
@PublicAPIRoute.route("/v1/trade/decline/<int:tradeid>", methods=["POST"])
@auth.authenticated_required_api
@csrf.exempt
def declineTrade( tradeid : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
TradeObj : UserTrade = UserTrade.query.filter_by(id=tradeid).first()
if TradeObj is None:
return ReturnError("Trade not found", 404)
if TradeObj.recipient_userid != AuthenticatedUser.id:
return ReturnError("You are not the recipient of this trade")
if TradeObj.status != TradeStatus.Pending:
return ReturnError("Trade is not pending")
if TradeObj.expires_at < datetime.utcnow():
TradeObj.status = TradeStatus.Expired
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
return ReturnError("Trade has expired")
TradeObj.status = TradeStatus.Declined
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
"success": True,
"message": "Trade Declined",
"data": None
})
@PublicAPIRoute.route("/v1/trade/cancel/<int:tradeid>", methods=["POST"])
@auth.authenticated_required_api
@csrf.exempt
def cancelTrade( tradeid : int ):
AuthenticatedUser : User = auth.GetCurrentUser()
TradeObj : UserTrade = UserTrade.query.filter_by(id=tradeid).first()
if TradeObj is None:
return ReturnError("Trade not found", 404)
if TradeObj.sender_userid != AuthenticatedUser.id:
return ReturnError("You are not the sender of this trade")
if TradeObj.status != TradeStatus.Pending:
return ReturnError("Trade is not pending")
if TradeObj.expires_at < datetime.utcnow():
TradeObj.status = TradeStatus.Expired
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
return ReturnError("Trade has expired")
TradeObj.status = TradeStatus.Cancelled
TradeObj.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
"success": True,
"message": "Trade Cancelled",
"data": None
})
+131
View File
@@ -0,0 +1,131 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response, abort
from app.models.asset_votes import AssetVote
from app.models.asset_favorite import AssetFavorite
from app.models.asset import Asset
from app.enums.AssetType import AssetType
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.previously_played import PreviouslyPlayed
from app.extensions import limiter, db, redis_controller
from app.util import auth
import json
AssetRateRoute = Blueprint("assetrate", __name__, template_folder="pages")
def ClearAssetVotesCache( assetId : int ):
redis_controller.delete(f"asset:{str(assetId)}:votes")
def GetAssetLikesAndDislikes( assetId : int ) -> tuple[int, int]:
"""
Returns 2 int values the first being the likes count and the second being the dislikes count.
"""
# See if we have the likes and dislikes in redis
CachedVoteData = redis_controller.get(f"asset:{str(assetId)}:votes")
if CachedVoteData is not None:
CachedVoteData = json.loads(CachedVoteData)
return CachedVoteData["likes"], CachedVoteData["dislikes"]
LikesCount = AssetVote.query.filter_by(assetid=assetId, vote=True).count()
DislikesCount = AssetVote.query.filter_by(assetid=assetId, vote=False).count()
redis_controller.set(f"asset:{str(assetId)}:votes", json.dumps({"likes":LikesCount, "dislikes":DislikesCount}), ex=600)
return LikesCount, DislikesCount
def GetUserVoteStatus( assetId : int, userId : int ) -> int: # 2 = dislike, 0 = no vote, 1 = like
"""
Returns an int value which is 2 if the user disliked the asset, 0 if the user didn't vote and 1 if the user liked the asset.
"""
UserVoteObj : AssetVote = AssetVote.query.filter_by(assetid=assetId, userid=userId).first()
if UserVoteObj is None:
return 0
if UserVoteObj.vote == True:
return 1
return 2
def GetAssetVotePercentage( assetId : int ) -> int:
"""
Returns an int value which is the percentage of likes the asset has.
"""
LikesCount, DislikesCount = GetAssetLikesAndDislikes(assetId)
if LikesCount == 0 and DislikesCount == 0:
return 50
return int((LikesCount / (LikesCount + DislikesCount)) * 100)
@AssetRateRoute.route("/vote/<int:assetid>/<int:status>", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
def vote_asset(assetid : int, status : int):
if status > 2 or status < 0:
return abort(400)
AuthenticatedUser = auth.GetCurrentUser()
AssetObj : Asset = Asset.query.filter_by(id=assetid).first()
if AssetObj is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
if AssetObj.asset_type == AssetType.Place:
if PreviouslyPlayed.query.filter_by(userid=AuthenticatedUser.id, placeid=AssetObj.id).first() is None:
return abort(400)
elif AssetObj.asset_type in [AssetType.GamePass, AssetType.Shirt, AssetType.TShirt, AssetType.Pants, AssetType.Hat, AssetType.Gear, AssetType.Plugin, AssetType.HairAccessory, AssetType.FaceAccessory, AssetType.NeckAccessory, AssetType.ShoulderAccessory, AssetType.FrontAccessory, AssetType.BackAccessory, AssetType.WaistAccessory, AssetType.EarAccessory, AssetType.EyeAccessory, AssetType.TShirtAccessory, AssetType.ShirtAccessory, AssetType.PantsAccessory, AssetType.JacketAccessory, AssetType.Package]:
if UserAsset.query.filter_by(userid=AuthenticatedUser.id, assetid=AssetObj.id).first() is None:
return abort(400)
AssetVoteObj : AssetVote = AssetVote.query.filter_by(assetid=assetid, userid=AuthenticatedUser.id).first()
if AssetVoteObj is None and status != 0:
AssetVoteObj = AssetVote(assetid=assetid, userid=AuthenticatedUser.id, vote=True)
db.session.add(AssetVoteObj)
if AssetVoteObj is not None and status == 0:
db.session.delete(AssetVoteObj)
if AssetVoteObj is not None and status != 0:
AssetVoteObj.vote = status == 1
db.session.commit()
ClearAssetVotesCache(assetid)
LikesCount, DislikesCount = GetAssetLikesAndDislikes(assetid)
return jsonify({"success":True}), 200
def GetAssetFavoriteCount( assetId : int ) -> int:
"""
Returns an int value which is the amount of users who favorited the asset.
"""
if redis_controller.exists(f"asset:{str(assetId)}:favorites"):
return int(redis_controller.get(f"asset:{str(assetId)}:favorites"))
favoriteCount = AssetFavorite.query.filter_by(assetid=assetId).count()
redis_controller.set(f"asset:{str(assetId)}:favorites", str(favoriteCount))
return favoriteCount
def GetUserFavoriteStatus( assetId : int, userId : int ) -> bool:
"""
Returns a bool value which is True if the user favorited the asset and False if the user didn't favorite the asset.
"""
UserFavoriteObj : AssetFavorite = AssetFavorite.query.filter_by(assetid=assetId, userid=userId).first()
if UserFavoriteObj is None:
return False
return True
@AssetRateRoute.route("/favorite/<int:assetid>", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
def favorite_asset(assetid : int):
AuthenticatedUser = auth.GetCurrentUser()
AssetFavoriteObj : AssetFavorite = AssetFavorite.query.filter_by(assetid=assetid, userid=AuthenticatedUser.id).first()
if AssetFavoriteObj is None:
AssetFavoriteObj = AssetFavorite(assetid=assetid, userid=AuthenticatedUser.id)
db.session.add(AssetFavoriteObj)
db.session.commit()
return jsonify({"success":True}), 200
@AssetRateRoute.route("/favorite/<int:assetid>", methods=["DELETE"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
def unfavorite_asset(assetid : int):
AuthenticatedUser = auth.GetCurrentUser()
AssetFavoriteObj : AssetFavorite = AssetFavorite.query.filter_by(assetid=assetid, userid=AuthenticatedUser.id).first()
if AssetFavoriteObj is not None:
db.session.delete(AssetFavoriteObj)
db.session.commit()
redis_controller.delete(f"asset:{str(assetid)}:favorites")
return jsonify({"success":True}), 200
+186
View File
@@ -0,0 +1,186 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from app.util import auth, membership
from app.enums.MembershipType import MembershipType
from app.extensions import db, redis_controller, get_remote_address
from app.models.user import User
from app.models.user_email import UserEmail
from app.services import economy
from datetime import datetime, timedelta
from config import Config
config = Config()
RBXAPIRoute = Blueprint('RBXAPIRoute', __name__, template_folder='pages')
# cuh what
@RBXAPIRoute.route("/users/<int:userid>", methods=["GET"])
def UserInfoAPI( userid : int ):
UserObj : User = User.query.filter_by( id = userid ).first()
if UserObj is None:
return jsonify( { "success" : False, "message" : "User not found" } ), 404
return jsonify({
"Id" : UserObj.id,
"Username" : UserObj.username
})
@RBXAPIRoute.route("/users/account-info", methods=["GET"])
@auth.authenticated_required_api
def GetUserAccountInfo():
AuthenticatedUser : User = auth.GetCurrentUser()
if AuthenticatedUser is None:
return jsonify({"success": False, "message": "Unauthorized"}),401
UserMembershipType : int = membership.GetUserMembership( AuthenticatedUser ).value
RobuxBalance, _ = economy.GetUserBalance( AuthenticatedUser )
UserEmailObj : UserEmail = UserEmail.query.filter_by(user_id = AuthenticatedUser.id ).first()
if UserEmailObj is not None:
emailParts = UserEmailObj.email.split("@")
FirstPart = emailParts[0][0] + "*" * (len(emailParts[0])-1)
SecondPart = emailParts[1]
HiddenEmail = FirstPart + "@" + SecondPart
else:
HiddenEmail = None
return jsonify({
"UserId": AuthenticatedUser.id,
"Username": AuthenticatedUser.username,
"DisplayName": AuthenticatedUser.username,
"HasPasswordSet": True,
"Email": HiddenEmail,
"MembershipType": UserMembershipType,
"RobuxBalance": RobuxBalance,
"AgeBracket": 0,
"Roles": [],
"EmailNotificationEnabled": False,
"PasswordNotifcationEnabled": False
})
@RBXAPIRoute.route("/my/settings/json", methods=["GET"])
@auth.authenticated_required_api
def GetMySettingsJSON():
AuthenticatedUser : User = auth.GetCurrentUser()
UserEmailObj : UserEmail = UserEmail.query.filter_by(user_id = AuthenticatedUser.id ).first()
if UserEmailObj is not None:
emailParts = UserEmailObj.email.split("@")
FirstPart = emailParts[0][0] + "*" * (len(emailParts[0])-1)
SecondPart = emailParts[1]
HiddenEmail = FirstPart + "@" + SecondPart
else:
HiddenEmail = None
UserMembershipType : MembershipType = membership.GetUserMembership( AuthenticatedUser )
return jsonify({
"ChangeUsernameEnabled": True,
"IsAdmin": False,
"UserId": AuthenticatedUser.id,
"Name": AuthenticatedUser.username,
"DisplayName": AuthenticatedUser.username,
"IsEmailOnFile": UserEmailObj is not None,
"IsEmailVerified": UserEmailObj is not None and UserEmailObj.verified,
"IsPhoneFeatureEnabled": False,
"RobuxRemainingForUsernameChange": 0,
"PreviousUserNames": "",
"UseSuperSafePrivacyMode": False,
"IsSuperSafeModeEnabledForPrivacySetting": False,
"UseSuperSafeChat": False,
"IsAppChatSettingEnabled": True,
"IsGameChatSettingEnabled": True,
"IsAccountPrivacySettingsV2Enabled": True,
"IsSetPasswordNotificationEnabled": False,
"ChangePasswordRequiresTwoStepVerification": False,
"ChangeEmailRequiresTwoStepVerification": False,
"UserEmail": HiddenEmail,
"UserEmailMasked": True,
"UserEmailVerified": UserEmailObj is not None and UserEmailObj.verified,
"CanHideInventory": False,
"CanTrade": UserMembershipType != MembershipType.NonBuildersClub,
"MissingParentEmail": False,
"IsUpdateEmailSectionShown": True,
"IsUnder13UpdateEmailMessageSectionShown": False,
"IsUserConnectedToFacebook": False,
"IsTwoStepToggleEnabled": False,
"AgeBracket": 0,
"UserAbove13": True,
"ClientIpAddress": get_remote_address(),
"AccountAgeInDays": (datetime.utcnow() - AuthenticatedUser.created).days,
"IsOBC": UserMembershipType == MembershipType.OutrageousBuildersClub,
"IsTBC": UserMembershipType == MembershipType.TurboBuildersClub,
"IsAnyBC": UserMembershipType != MembershipType.NonBuildersClub,
"IsPremium": False,
"IsBcRenewalMembership": False,
"BcExpireDate": "/Date(-0)/",
"BcRenewalPeriod": None,
"BcLevel": None,
"HasCurrencyOperationError": False,
"CurrencyOperationErrorMessage": None,
"BlockedUsersModel": {
"BlockedUserIds": [],
"BlockedUsers": [],
"MaxBlockedUsers": 50,
"Total": 1,
"Page": 1
},
"Tab": None,
"ChangePassword": False,
"IsAccountPinEnabled": True,
"IsAccountRestrictionsFeatureEnabled": True,
"IsAccountRestrictionsSettingEnabled": False,
"IsAccountSettingsSocialNetworksV2Enabled": False,
"IsUiBootstrapModalV2Enabled": True,
"IsI18nBirthdayPickerInAccountSettingsEnabled": True,
"InApp": False,
"MyAccountSecurityModel": {
"IsEmailSet": UserEmailObj is not None,
"IsEmailVerified": UserEmailObj is not None and UserEmailObj.verified,
"IsTwoStepEnabled": False,
"ShowSignOutFromAllSessions": True,
"TwoStepVerificationViewModel": {
"UserId": AuthenticatedUser.id,
"IsEnabled": False,
"CodeLength": 6,
"ValidCodeCharacters": None
}
},
"ApiProxyDomain": config.BaseURL,
"AccountSettingsApiDomain": config.BaseURL,
"AuthDomain": config.BaseURL,
"IsDisconnectFbSocialSignOnEnabled": True,
"IsDisconnectXboxEnabled": True,
"NotificationSettingsDomain": config.BaseURL,
"AllowedNotificationSourceTypes": [
"Test",
"FriendRequestReceived",
"FriendRequestAccepted",
"PartyInviteReceived",
"PartyMemberJoined",
"ChatNewMessage",
"PrivateMessageReceived",
"UserAddedToPrivateServerWhiteList",
"ConversationUniverseChanged",
"TeamCreateInvite",
"GameUpdate",
"DeveloperMetricsAvailable"
],
"AllowedReceiverDestinationTypes": [
"DesktopPush",
"NotificationStream"
],
"BlacklistedNotificationSourceTypesForMobilePush": [],
"MinimumChromeVersionForPushNotifications": 50,
"PushNotificationsEnabledOnFirefox": True,
"LocaleApiDomain": config.BaseURL,
"HasValidPasswordSet": True,
"IsUpdateEmailApiEndpointEnabled": True,
"FastTrackMember": None,
"IsFastTrackAccessible": False,
"HasFreeNameChange": False,
"IsAgeDownEnabled": False,
"IsSendVerifyEmailApiEndpointEnabled": True,
"IsPromotionChannelsEndpointEnabled": True,
"ReceiveNewsletter": False,
"SocialNetworksVisibilityPrivacy": 6,
"SocialNetworksVisibilityPrivacyValue": "AllUsers",
"Facebook": None,
"Twitter": None,
"YouTube": None,
"Twitch": None
})
+307
View File
@@ -0,0 +1,307 @@
import json
from flask import Blueprint, request, jsonify, abort, make_response, Response
from datetime import datetime, timedelta
from app.models.asset import Asset
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.placeserver_players import PlaceServerPlayer
from app.models.limited_item_transfers import LimitedItemTransfer
from app.enums.LimitedItemTransferMethod import LimitedItemTransferMethod
from app.services import economy
from app.extensions import csrf, redis_controller
from config import Config
config = Config()
RolimonsAPI = Blueprint('RolimonsAPI', __name__, url_prefix='/api/internal_rolimons')
@RolimonsAPI.before_request
def before_request_authentication():
if config.ROLIMONS_API_ENABLED != True:
abort(404)
rolimonAccessKey = request.headers.get(
key = "X-Rolimons-Access-Key",
default = None,
type = str
)
if rolimonAccessKey is None or rolimonAccessKey != config.ROLIMONS_API_KEY:
abort(404)
@RolimonsAPI.errorhandler( 500 )
def internal_error( error ):
return make_response(jsonify({
"error": "Internal Server Error",
"success": False
}), 500)
@RolimonsAPI.route('/get_item_owners/<int:assetid>', methods=['GET'])
def get_item_owners( assetid : int ):
AssetObj : Asset = Asset.query.filter_by( id = assetid ).first()
if AssetObj is None:
return make_response(jsonify({
"error": "Asset not found",
"success": False
}), 404)
if not AssetObj.is_limited:
return make_response(jsonify({
"error": "Asset is not a limited",
"success": False
}), 400)
UserAssets : list[UserAsset] = UserAsset.query.filter_by( assetid = assetid ).order_by( UserAsset.serial ).all()
AllItemOwners : list[dict] = []
for UserAssetObj in UserAssets:
OwnerUserObj : User = User.query.filter_by( id = UserAssetObj.userid ).first()
if OwnerUserObj is None:
continue
AllItemOwners.append({
"uaid": UserAssetObj.id,
"item_id": assetid,
"owner_id": OwnerUserObj.id if OwnerUserObj.accountstatus not in [3,4] else None,
"owner_name": OwnerUserObj.username if OwnerUserObj.accountstatus not in [3,4] else None,
"serial_number": UserAssetObj.serial,
"owned_since": f"{UserAssetObj.updated.isoformat()}Z"
})
return make_response(jsonify({
"success": True,
"data": AllItemOwners
}))
@RolimonsAPI.route('/get_player_inventory/<int:userid>', methods=['GET'])
def get_user_collectibles( userid : int ):
UserObj : User = User.query.filter_by( id = userid ).first()
if UserObj is None or UserObj.accountstatus == 4:
return make_response(jsonify({
"error": "User not found",
"success": False
}), 404)
PageNumber = max(
request.args.get(
key = "cursor",
default = 1,
type = int
),
1
)
UserAssets = UserAsset.query.filter_by( userid = userid ).join( Asset, UserAsset.assetid == Asset.id ).filter( Asset.is_limited == True ).order_by( UserAsset.id.desc() ).paginate(
page = PageNumber,
per_page = 50,
error_out = False
)
AllUserCollectibleAssets : list[dict] = []
for UserAssetObj in UserAssets.items:
UserAssetObj : UserAsset
AssetObj : Asset = UserAssetObj.asset
AllUserCollectibleAssets.append({
"assetId": UserAssetObj.assetid,
"userAssetId": UserAssetObj.id,
"serialNumber": UserAssetObj.serial,
"name": AssetObj.name
})
return jsonify({
"data": AllUserCollectibleAssets,
"nextPageCursor": str(UserAssets.next_num if UserAssets.has_next else None),
"previousPageCursor": str(UserAssets.prev_num if UserAssets.has_prev else None),
"success": True
})
@RolimonsAPI.route("/user_by_username/<username>", methods=['GET'])
def get_user_by_username( username : str ):
if len(username) < 3 or len(username) > 32:
return make_response(jsonify({
"error": "Username length must be between 3 and 32 characters",
"success": False
}), 400)
SearchQuery = username.strip().replace('%', '')
UserSearchResults = User.query.filter( User.username.ilike( '%' + SearchQuery + '%' ) ).filter( User.accountstatus != 4 ).order_by( User.id ).all()
UserSearchResultsData = []
for UserObj in UserSearchResults:
UserSearchResultsData.append({
"Name": UserObj.username,
"UserID": UserObj.id,
"Description": UserObj.description
})
return jsonify({
"UserSearchResults": UserSearchResultsData,
"success": True
})
@RolimonsAPI.route("/multi_get_user_presence", methods=['POST'])
@csrf.exempt
def multi_get_user_presence():
if not request.is_json:
return make_response(jsonify({
"error": "Invalid JSON",
"success": False
}), 400)
if "userIds" not in request.json:
return make_response(jsonify({
"error": "userIds not found in JSON Payload",
"success": False
}), 400)
UserIds = request.json["userIds"]
if not isinstance(UserIds, list):
return make_response(jsonify({
"error": "userIds must be a list",
"success": False
}), 400)
if len(UserIds) > 100:
return make_response(jsonify({
"error": "userIds list must not exceed 100 items",
"success": False
}), 400)
UserPresenceData : list[dict] = []
for UserId in UserIds:
if not isinstance(UserId, int):
continue
UserObj : User = User.query.filter_by( id = UserId ).first()
if UserObj is None or UserObj.accountstatus == 4:
continue
UserObjPresenceData = {
"userId": UserObj.id,
"lastOnline": f"{UserObj.lastonline.isoformat()}Z",
"userPresenceType": "InGame" if PlaceServerPlayer.query.filter_by( userid = UserObj.id ).first() is not None else ( "Online" if UserObj.lastonline > datetime.utcnow() - timedelta( minutes = 1 ) else "Offline" )
}
UserObjPresenceData["lastLocation"] = "Playing" if UserObjPresenceData["userPresenceType"] == "InGame" else "Website"
UserPresenceData.append(UserObjPresenceData)
return jsonify({
"userPresences": UserPresenceData,
"success": True
})
@RolimonsAPI.route("/get_user_by_id/<int:userid>", methods=['GET'])
def get_user_by_id( userid : int ):
UserObj : User = User.query.filter_by( id = userid ).first()
if UserObj is None or UserObj.accountstatus == 4:
return make_response(jsonify({
"error": "User not found",
"success": False
}), 404)
return jsonify({
"id": UserObj.id,
"name": UserObj.username,
"description": UserObj.description,
"isBanned": UserObj.accountstatus in [ 3, 4 ],
})
@RolimonsAPI.route("/get_all_limiteds", methods=["GET"])
def get_all_limiteds():
def _generate_response() -> list:
AllLimiteds = []
LimitedsListAssetObj : list[Asset] = Asset.query.filter(
Asset.is_limited == True
).order_by( Asset.id.desc() ).all()
for LimitedAsset in LimitedsListAssetObj:
LimitedAsset : Asset
LowestAvailablePrice : UserAsset | None = UserAsset.query.filter_by( assetid = LimitedAsset.id, is_for_sale = True ).order_by( UserAsset.price ).first()
AllLimiteds.append({
"name": LimitedAsset.name,
"id": LimitedAsset.id,
"description": LimitedAsset.description,
"rap": economy.GetAssetRap( LimitedAsset ),
"price": LowestAvailablePrice.price if LowestAvailablePrice is not None else None,
"original_price": LimitedAsset.price_robux if LimitedAsset.price_robux != 0 else LimitedAsset.price_tix,
"original_price_type": "Robux" if LimitedAsset.price_robux != 0 else "Tickets",
"type": LimitedAsset.asset_type.name,
"is_limited_unique": LimitedAsset.is_limited_unique,
"updated_at": f"{LimitedAsset.updated_at.isoformat()}Z"
})
return AllLimiteds
if redis_controller.exists("rolimons_all_limiteds"):
return jsonify({
"data": json.loads( redis_controller.get("rolimons_all_limiteds") ),
"success": True
})
AllLimiteds = _generate_response()
redis_controller.set( "rolimons_all_limiteds", json.dumps(AllLimiteds), ex = 5 )
return jsonify({
"data": AllLimiteds,
"success": True
})
@RolimonsAPI.route("/get_item_sales/<int:assetid>", methods=["GET"])
def get_item_sales( assetid : int ):
AssetObj : Asset = Asset.query.filter_by( id = assetid ).first()
if AssetObj is None:
return make_response(jsonify({
"error": "Asset not found",
"success": False
}), 404)
if not AssetObj.is_limited:
return make_response(jsonify({
"error": "Asset is not a limited",
"success": False
}), 400)
PageNumber = max(
request.args.get(
key = "cursor",
default = 1,
type = int
),
1
)
AllItemTransfers : list[LimitedItemTransfer] = LimitedItemTransfer.query.filter_by( asset_id = assetid, transfer_method = LimitedItemTransferMethod.Purchase ).order_by( LimitedItemTransfer.transferred_at.desc() ).paginate(
page = 1,
per_page = 15,
error_out = False
)
AllItemSalesData : list[dict] = []
for ItemTransfer in AllItemTransfers.items:
ItemTransfer : LimitedItemTransfer
OriginalOwnerObj : User = User.query.filter_by( id = ItemTransfer.original_owner_id ).first()
NewOwnerObj : User = User.query.filter_by( id = ItemTransfer.new_owner_id ).first()
AllItemSalesData.append({
"userAssetId": ItemTransfer.user_asset_id,
"sellerId": OriginalOwnerObj.id,
"sellerName": OriginalOwnerObj.username,
"buyerId": NewOwnerObj.id,
"buyerName": NewOwnerObj.username,
"price": ItemTransfer.purchased_price
})
return jsonify({
"sales": AllItemSalesData,
"success": True,
"nextPageCursor": str(AllItemTransfers.next_num) if AllItemTransfers.has_next else None,
"previousPageCursor": str(AllItemTransfers.prev_num) if AllItemTransfers.has_prev else None
})
+51
View File
@@ -0,0 +1,51 @@
import requests
from app.routes.asset import migrateAsset
from app.extensions import limiter, db, redis_controller
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, make_response, abort
SetsRoute = Blueprint("sets", __name__, template_folder="pages")
def FetchSetData( setId : int, avoidCache : bool = False ) -> str:
"""
Fetches the set data from sets.pizzaboxer.xyz and then caches it in redis.
"""
if not avoidCache:
CachedSetData = redis_controller.get(f"set:{str(setId)}:data")
if CachedSetData is not None:
return CachedSetData
SetData = requests.get(f"https://sets.pizzaboxer.xyz/Game/Tools/InsertAsset.ashx?sid={str(setId)}").text
redis_controller.set(f"set:{str(setId)}:data", SetData, ex=(60 * 60 * 24 * 31))
return SetData
def FetchUserSetsData( nsets : int = 20, type : str = "user", userid : int = 1, avoidCache : bool = False ) -> str:
"""
Fetches the user's sets data from sets.pizzaboxer.xyz and then caches it in redis.
"""
if not avoidCache:
CachedSetData = redis_controller.get(f"sets:{str(userid)}:data")
if CachedSetData is not None:
return CachedSetData
SetData = requests.get(f"https://sets.pizzaboxer.xyz/Game/Tools/InsertAsset.ashx?nsets={str(nsets)}&type={type}&userid={str(userid)}").text
redis_controller.set(f"sets:{str(userid)}:data", SetData, ex=(60 * 60 * 24 * 31))
return SetData
@SetsRoute.route("/Game/Tools/InsertAsset.ashx", methods=["GET"])
def insert_asset():
SetId = request.args.get( key = "sid", default = None, type = int )
if SetId is None:
NSets = request.args.get( key = "nsets", default = None, type = int )
OwnerType = request.args.get( key = "type", default = None, type = str )
UserId = request.args.get( key = "userid", default = None, type = int )
if NSets is None or OwnerType is None or UserId is None:
return abort(400)
SetData : str = FetchUserSetsData(nsets=NSets, type=OwnerType, userid=UserId)
DataResponse = make_response(SetData)
DataResponse.headers["Content-Type"] = "text/xml"
return DataResponse
SetData : str = FetchSetData(SetId)
DataResponse = make_response(SetData)
DataResponse.headers["Content-Type"] = "text/xml"
return DataResponse
+139
View File
@@ -0,0 +1,139 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, abort, after_this_request
from app.models.placeservers import PlaceServer
from app.models.gameservers import GameServer
from app.models.place import Place
from app.models.universe import Universe
from app.models.user import User
from app.extensions import redis_controller, get_remote_address, csrf, limiter, db
from app.enums.PlaceYear import PlaceYear
from app.routes.gamejoin import CreateNewPlaceServer
import logging
import secrets
import string
TeleportServiceRoute = Blueprint('teleportServiceinternal', __name__, url_prefix='/reservedservers')
@TeleportServiceRoute.before_request
def before_request_hook():
if "Roblox/" not in request.user_agent.string:
logging.info(f"TeleportServiceInternal - UserAgent {request.user_agent} - Not allowed")
abort(404)
RemoteAddress = get_remote_address()
GameServerObj : GameServer = GameServer.query.filter_by( serverIP = RemoteAddress ).first()
if GameServerObj is None:
logging.info(f"TeleportServiceInternal - {RemoteAddress} - Not found")
abort(404)
if "Roblox-Place-Id" not in request.headers:
logging.info(f"TeleportServiceInternal - GameServer {RemoteAddress} - Roblox-Place-Id header not found")
abort(404)
accessKeyRequest = request.headers.get( key = "accesskey", default = "" )
if "UserRequest" in accessKeyRequest:
logging.warning(f"TeleportServiceInternal - GameServer {RemoteAddress} - UserRequest access key used")
abort(404)
PlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int )
if PlaceId is None:
logging.info(f"TeleportServiceInternal - GameServer {RemoteAddress} - Roblox-Place-Id header not expected type")
abort(404)
PlaceObj : Place = Place.query.filter_by( placeid = PlaceId ).first()
if PlaceObj is None:
logging.info(f"TeleportServiceInternal - Place {PlaceId} - Place not found")
abort(404)
@TeleportServiceRoute.route('/create', methods=['POST'])
@csrf.exempt
def create_reserved_server():
OriginPlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int )
TargetPlaceId = request.args.get( key = "placeId", default = None, type = int )
if OriginPlaceId is None or TargetPlaceId is None:
logging.error(f"TeleportServiceInternal - create_reserved_server - Bad Request - OriginPlaceId {OriginPlaceId} TargetPlaceId {TargetPlaceId} - Both has to be not none")
return "Invalid request", 400
OriginPlaceObj : Place = Place.query.filter_by( placeid = OriginPlaceId ).first()
TargetPlaceObj : Place = Place.query.filter_by( placeid = TargetPlaceId ).first()
if OriginPlaceObj is None or TargetPlaceObj is None:
logging.error(f"TeleportServiceInternal - create_reserved_server - Bad Request - Place {OriginPlaceId} or {TargetPlaceId} not found")
return "Invalid request", 400
if OriginPlaceObj.parent_universe_id != TargetPlaceObj.parent_universe_id:
logging.error(f"TeleportServiceInternal - create_reserved_server - Bad Request - OriginPlace {OriginPlaceId} and TargetPlace {TargetPlaceId} are not in the same universe")
return "Invalid request", 400
UniverseObj : Universe = Universe.query.filter_by( id = OriginPlaceObj.parent_universe_id ).first()
if UniverseObj is None:
return "Invalid request", 400
if UniverseObj.place_year not in [ PlaceYear.Sixteen, PlaceYear.Eighteen, PlaceYear.Twenty, PlaceYear.TwentyOne]:
logging.error(f"TeleportServiceInternal - create_reserved_server - Bad Request - Universe {UniverseObj.id} - PlaceYear {UniverseObj.place_year} is not supported")
return "Invalid request", 400
CooldownKeyName = f"reserved_server_creation_cooldown:{OriginPlaceObj.parent_universe_id}"
ReservedServerAccessCode = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(64))
if not redis_controller.exists( CooldownKeyName ):
redis_controller.setex( name = CooldownKeyName, time = 10, value = "1" )
try:
NewPlaceServerObj : PlaceServer = CreateNewPlaceServer(
placeId = TargetPlaceId,
reserved_server_access_code = ReservedServerAccessCode
)
except Exception as e:
logging.error(f"TeleportServiceInternal - create_reserved_server - Place {TargetPlaceId} - Universe {UniverseObj.id} - {e}")
return "Failed to start new server", 400
redis_controller.set( f"reservedserveraccesscode_server:{ReservedServerAccessCode}", TargetPlaceObj.placeid, ex = 60 * 60 * 24 )
return jsonify({
"ReservedServerAccessCode": ReservedServerAccessCode,
"ReservedServerGameCode": ReservedServerAccessCode
})
@TeleportServiceRoute.route('/grantaccess', methods=['POST'])
@csrf.exempt
def grant_access_to_reserved():
given_reservedServerAccessCode = request.args.get( key = 'reservedServerAccessCode', default = None, type = str )
OriginPlaceId = request.headers.get( key = "Roblox-Place-Id", default = None, type = int )
if given_reservedServerAccessCode is None or OriginPlaceId is None:
return "Invalid request", 400
OriginPlaceObj : Place = Place.query.filter_by( placeid = OriginPlaceId ).first()
if OriginPlaceObj is None:
return "Invalid request", 400
TargetPlaceServerObj : PlaceServer = PlaceServer.query.filter_by( reservedServerAccessCode = given_reservedServerAccessCode ).first()
if TargetPlaceServerObj is None:
accessCodePlaceIdOrigin = redis_controller.get( f"reservedserveraccesscode_server:{given_reservedServerAccessCode}" )
if accessCodePlaceIdOrigin is None:
return "Invalid request", 400
TargetPlaceObj : Place = Place.query.filter_by( placeid = int( accessCodePlaceIdOrigin ) ).first()
if TargetPlaceObj is None:
return "Invalid request", 400
if TargetPlaceObj.parent_universe_id != OriginPlaceObj.parent_universe_id:
return "Invalid request", 400
try:
TargetPlaceServerObj : PlaceServer = CreateNewPlaceServer(
placeId = TargetPlaceObj.placeid,
reserved_server_access_code = given_reservedServerAccessCode
)
except Exception as e:
logging.error(f"TeleportServiceInternal - grant_access_to_reserved - Place {TargetPlaceObj.placeid} - Universe {TargetPlaceObj.parent_universe_id} - {e}")
return "Failed to start new server", 400
redis_controller.set( f"reservedserveraccesscode_server:{given_reservedServerAccessCode}", TargetPlaceObj.placeid, ex = 60 * 60 * 24 )
AllowedUserIds = request.form.getlist( key = "playerIds", type = int )
if len( AllowedUserIds ) == 0:
return "Invalid request", 400
for UserId in AllowedUserIds:
UserObj : User = User.query.filter_by( id = UserId ).first()
if UserObj is None:
return "Invalid request", 400
redis_controller.set( f"reservedserveraccesscode:{TargetPlaceServerObj.serveruuid}:{UserObj.id}", "1", ex = 60 * 8)
return "", 200
+670
View File
@@ -0,0 +1,670 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify
from app.extensions import db, redis_controller, csrf
import uuid
import json
import requests
import hashlib
import logging
import os
import base64
from datetime import datetime
from app.services.gameserver_comm import perform_post
from app.util import websiteFeatures, s3helper
from app.models.gameservers import GameServer
from app.models.asset_version import AssetVersion
from app.models.asset_thumbnail import AssetThumbnail
from app.models.user_thumbnail import UserThumbnail
from app.models.place_icon import PlaceIcon
from app.models.asset import Asset
from app.models.package_asset import PackageAsset
from app.models.place import Place
from app.models.user_avatar import UserAvatar
from app.models.user_avatar_asset import UserAvatarAsset
from app.enums.PlaceYear import PlaceYear
Thumbnailer = Blueprint('thumbnailer', __name__, url_prefix='/internal')
AssetTypetoThumbnailRenderType = {
4 : 4,
8 : 11,
9 : 5,
10 : 3,
11 : 15,
12 : 14,
1 : 6,
13 : 6,
18 : 6,
19 : 12,
2 : 7,
17 : 8,
27 : 9,
28 : 9,
29 : 9,
30 : 9,
31 : 9,
32 : 17,
41 : 11,
42 : 11,
43 : 11,
44 : 11,
45 : 11,
46 : 11,
47 : 11,
40 : 13,
}
"""
omg was i fucking high or something several months ago i gotta rewrite this one day
- something.else 19/2/2024
"""
def GetAvatarHash( userid : int ) -> str:
"""
Returns the avatar hash for the userid provided
"""
UserAvatarObject : UserAvatar = UserAvatar.query.filter_by(user_id=userid).first()
if UserAvatarObject is None:
return None
UserAvatarAssetObject : UserAvatarAsset = UserAvatarAsset.query.filter_by(user_id=userid).all()
HashString = ""
for UserAvatarAssetObject in UserAvatarAssetObject:
HashString += str(UserAvatarAssetObject.asset_id)+"-"
HashString += str(UserAvatarObject.head_color_id)+"-"
HashString += str(UserAvatarObject.torso_color_id)+"-"
HashString += str(UserAvatarObject.left_arm_color_id)+"-"
HashString += str(UserAvatarObject.right_arm_color_id)+"-"
HashString += str(UserAvatarObject.left_leg_color_id)+"-"
HashString += str(UserAvatarObject.right_leg_color_id)+'-'
HashString += str(UserAvatarObject.r15)+'-'
HashString += str(UserAvatarObject.height_scale)+'-'
HashString += str(UserAvatarObject.width_scale)+'-'
HashString += str(UserAvatarObject.head_scale)+'-'
HashString += str(UserAvatarObject.proportion_scale)+'-'
HashString += str(UserAvatarObject.body_type_scale)
AvatarHash = hashlib.sha256(HashString.encode()).hexdigest()
return AvatarHash
def findBestThumbnailer() -> GameServer | None:
weight_ping_time = 3
weight_queue_size = 0.3
AllGameServers : list[GameServer] = GameServer.query.filter_by(allowThumbnailGen=True, isRCCOnline=True).filter( GameServer.thumbnailQueueSize < 40 ).all()
if len(AllGameServers) == 0:
return None
BestGameServer : GameServer = None
for GameServerObject in AllGameServers:
GameServerObject.score = (weight_ping_time * GameServerObject.heartbeatResponseTime) + (weight_queue_size * GameServerObject.thumbnailQueueSize)
if BestGameServer is None:
BestGameServer = GameServerObject
continue
if GameServerObject.score < BestGameServer.score:
BestGameServer = GameServerObject
#logging.info(f"GameserverLoadBalancer: {str(BestGameServer.serverId)} - {str(BestGameServer.score)} - Ping: {str(round(BestGameServer.heartbeatResponseTime, 3))}secs - Queue: {str(BestGameServer.thumbnailQueueSize)}")
return BestGameServer
def TakeUserThumbnail(UserId: int, bypassCooldown=False, bypassCache=False, Is3D=False):
"""
Takes a thumbnail and headshot for the userid provided
bypassCooldown: Bypasses the 5 second cooldown for that user
bypassCache: Bypasses the cache and takes a new thumbnail and headshot
Is3D: Whether to request a 3D thumbnail
"""
if redis_controller.get(f"Thumbnailer:UserId:{UserId}:Taken") is not None and not bypassCooldown:
return "Thumbnail Attempted Recently"
redis_controller.set(f"Thumbnailer:UserId:{UserId}:Taken", "True", 5)
ImageHeadshotCache = None
ImageThumbnailCache = None
Image3DThumbnailCache = None
AvatarHash = GetAvatarHash(UserId)
if not bypassCache:
UserThumbnailObject = UserThumbnail.query.filter_by(userid=UserId).first()
if UserThumbnailObject is None:
UserThumbnailObject = UserThumbnail(userid=UserId, full_contenthash=None, headshot_contenthash=None, full_3dcontenthash=None, updated_at=datetime.utcnow())
db.session.add(UserThumbnailObject)
ImageThumbnailCache = redis_controller.get(f"Thumbnailer:UserImage:{AvatarHash}:Thumbnail")
if ImageThumbnailCache is not None:
UserThumbnailObject.full_contenthash = ImageThumbnailCache
ImageHeadshotCache = redis_controller.get(f"Thumbnailer:UserImage:{AvatarHash}:Headshot")
if ImageHeadshotCache is not None:
UserThumbnailObject.headshot_contenthash = ImageHeadshotCache
Image3DThumbnailCache = redis_controller.get(f"Thumbnailer:UserImage:{AvatarHash}:3DThumbnail")
if Image3DThumbnailCache is not None and Is3D:
UserThumbnailObject.full_3dcontenthash = Image3DThumbnailCache
db.session.commit()
if ImageThumbnailCache is not None and ImageHeadshotCache is not None:
if not Is3D or (Is3D and Image3DThumbnailCache is not None):
return
if redis_controller.get(f"Thumbnailer:AvatarHash:{AvatarHash}:Lock") is not None:
return "Thumbnail Attempted Recently"
redis_controller.set(f"Thumbnailer:AvatarHash:{AvatarHash}:Lock", "True", 5)
if not websiteFeatures.GetWebsiteFeature("ThumbnailRendering"):
return "Thumbnail Rendering is disabled"
GameServerObject: GameServer = findBestThumbnailer()
if GameServerObject is None:
return "No suitable game servers found"
requests_to_send = 0
UserAvatarObj: UserAvatar = UserAvatar.query.filter_by(user_id=UserId).first()
if (not bypassCache and ImageThumbnailCache is None) or bypassCache:
ThumbnailReqId = str(uuid.uuid4())
redis_controller.set(f"Thumbnailer:Request:{ThumbnailReqId}", json.dumps({
"UserId": UserId,
"Type": 0,
"Is3D": False
}), ex=600)
try:
perform_post(
TargetGameserver=GameServerObject,
Endpoint="Thumbnail",
JSONData={
"type": 16 if UserAvatarObj.r15 else 0,
"userid": UserId,
"reqid": ThumbnailReqId,
"image_x": 768,
"image_y": 768,
"Is3D": False
},
RequestTimeout=0.5
)
requests_to_send += 1
except Exception as e:
logging.error(f"Error requesting 2D thumbnail for UserId {UserId}: {str(e)}")
if (not bypassCache and ImageHeadshotCache is None) or bypassCache:
HeadshotReqId = str(uuid.uuid4())
redis_controller.set(f"Thumbnailer:Request:{HeadshotReqId}", json.dumps({
"UserId": UserId,
"Type": 1,
"Is3D": False
}), ex=600)
try:
perform_post(
TargetGameserver=GameServerObject,
Endpoint="Thumbnail",
JSONData={
"type": 1,
"userid": UserId,
"reqid": HeadshotReqId,
"image_x": 768,
"image_y": 768,
"Is3D": False
},
RequestTimeout=0.5
)
requests_to_send += 1
except Exception as e:
logging.error(f"Error requesting headshot for UserId {UserId}: {str(e)}")
if Is3D and ((not bypassCache and Image3DThumbnailCache is None) or bypassCache):
ThreeDReqId = str(uuid.uuid4())
redis_controller.set(f"Thumbnailer:Request:{ThreeDReqId}", json.dumps({
"UserId": UserId,
"Type": 0,
"Is3D": True
}), ex=600)
try:
perform_post(
TargetGameserver=GameServerObject,
Endpoint="Thumbnail",
JSONData={
"type": 16 if UserAvatarObj.r15 else 0,
"userid": UserId,
"reqid": ThreeDReqId,
"image_x": 768,
"image_y": 768,
"Is3D": True
},
RequestTimeout=0.5
)
requests_to_send += 1
except Exception as e:
logging.error(f"Error requesting 3D thumbnail for UserId {UserId}: {str(e)}")
if requests_to_send > 0:
GameServerObject.thumbnailQueueSize += requests_to_send
db.session.commit()
return "Thumbnail request sent"
def ValidatePlaceFileRequest(PlaceId : int, RequestId : str = None, AssetYear : PlaceYear = None ) -> None:
"""
Sends a validate file request to a thumbnailer server
"""
if not websiteFeatures.GetWebsiteFeature("AssetValidationService"):
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": False,
"error": "Asset Validation Service is temporarily disabled, try again later"
}), ex=600)
GameServerObject : GameServer = findBestThumbnailer()
if GameServerObject is None:
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": False,
"error": "Cannot verify file at this time, try again later"
}), ex=600)
return
if AssetYear is None:
PlaceObj : Place = Place.query.filter_by(placeid=PlaceId).first()
AssetYear = PlaceYear.Eighteen
if PlaceObj is not None:
AssetYear = PlaceObj.placeyear
try:
AssetYearToEndpoint = {
PlaceYear.Eighteen: "AssetValidation2018",
PlaceYear.Sixteen: "AssetValidation2016",
PlaceYear.Fourteen: "AssetValidation2016",
PlaceYear.Twenty: "AssetValidation2020",
PlaceYear.TwentyOne: "AssetValidation2021"
}
if AssetYear not in AssetYearToEndpoint:
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": False,
"error": "Invalid place year"
}), ex=600)
return
response = perform_post(
TargetGameserver = GameServerObject,
Endpoint = AssetYearToEndpoint[AssetYear],
JSONData = {
"assetid": PlaceId
},
RequestTimeout = 60
)
response.raise_for_status()
response = response.json()
if response["valid"]:
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": True,
"error": None
}), ex=600)
return
else:
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": False,
"error": response["reason"]
}), ex=600)
return
except Exception as e:
logging.error(f"Error while trying to validate place file: {str(e)}")
redis_controller.set(f"ValidatePlaceFileRequest:{RequestId}", json.dumps({
"valid": False,
"error": "An error occured while trying to validate the place file"
}), ex=600)
return
def TakeThumbnail(AssetId : int, isIcon = False, bypassCooldown = False, bypassCache = False): # isIcon only used for game icons
"""
Takes a thumbnail for the assetid provided
bypassCooldown: Bypasses the 2 minute cooldown for that asset
"""
from app.routes.asset import GenerateTempAuthToken
if redis_controller.get(f"Thumbnailer:AssetId:{AssetId}:{str(isIcon)}:Taken") is not None and not bypassCooldown:
return "Thumbnail Attempted Recently"
redis_controller.set(f"Thumbnailer:AssetId:{AssetId}:{str(isIcon)}:Taken", "True", 120)
AssetObject : Asset = Asset.query.filter_by(id=AssetId).first()
AssetVersionObject : AssetVersion = AssetVersion.query.filter_by(asset_id=AssetId).order_by(AssetVersion.version.desc()).first()
if AssetVersionObject is None and AssetObject.asset_type.value != 32:
return "Asset version not found"
if not bypassCache:
if not isIcon:
ImageThumbnailCache = redis_controller.get(f"Thumbnailer:AssetImage:{AssetVersionObject.content_hash}:Thumbnail")
if ImageThumbnailCache is not None:
ThumbnailObject : AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=AssetId, asset_version_id=AssetVersionObject.version).first()
if ThumbnailObject is None:
AssetModeration = 1
if AssetObject.roblox_asset_id is not None:
AssetModeration = 0
ThumbnailObject = AssetThumbnail(
asset_id=AssetId,
asset_version_id=AssetVersionObject.version,
content_hash=ImageThumbnailCache,
moderation_status=AssetModeration,
created_at=datetime.utcnow()
)
db.session.add(ThumbnailObject)
try:
db.session.commit()
except:
return
return
if ThumbnailObject.content_hash == ImageThumbnailCache:
return
ThumbnailObject.content_hash = ImageThumbnailCache
ThumbnailObject.moderation_status = 1
try:
db.session.commit()
except:
return
return
else:
ImageIconCache = redis_controller.get(f"Thumbnailer:AssetImage:{AssetVersionObject.content_hash}:PlaceIcon")
if ImageIconCache is not None:
PlaceIconObject : PlaceIcon = PlaceIcon.query.filter_by(placeid=AssetId).first()
if PlaceIconObject is None:
PlaceIconObject = PlaceIcon(
placeid=AssetId,
contenthash=ImageIconCache,
updated_at=datetime.utcnow(),
moderation_status=1, # Pending
)
db.session.add(PlaceIconObject)
db.session.commit()
return
if PlaceIconObject.contenthash == ImageIconCache:
return
PlaceIconObject.contenthash = ImageIconCache
PlaceIconObject.moderation_status = 1
PlaceIconObject.updated_at = datetime.utcnow()
db.session.commit()
return
AssetObject : Asset = Asset.query.filter_by(id=AssetId).first()
if AssetObject is None:
return "Asset not found"
AssetType = AssetObject.asset_type.value
ThumbnailType = AssetTypetoThumbnailRenderType.get(AssetType)
if ThumbnailType is None:
if AssetType in[39, 3, 24, 5, 48,49,50,51,52,53,54,55,56]: # SolidModel, Animation, Lua and Audio
if AssetType == 39:
StaticImage = open("./app/files/NoRender.png", "rb").read()
elif AssetType == 3:
StaticImage = open("./app/files/AudioThumbnail.png", "rb").read()
elif AssetType in [24,48,49,50,51,52,53,54,55,56]:
StaticImage = open("./app/files/AnimationThumbnail.png", "rb").read()
elif AssetType == 5:
StaticImage = open("./app/files/LuaThumbnail.png", "rb").read()
ImageHash = hashlib.sha512(StaticImage).hexdigest()
if not s3helper.DoesKeyExist(ImageHash):
s3helper.UploadBytesToS3(StaticImage, ImageHash, contentType="image/png")
try:
NewThumbnailObject : AssetThumbnail = AssetThumbnail(
asset_id=AssetId,
asset_version_id=AssetVersionObject.version,
content_hash=ImageHash,
moderation_status=0,
created_at=datetime.utcnow()
)
db.session.add(NewThumbnailObject)
db.session.commit()
except:
pass
return "Used Static Image"
return "Thumbnail type not found"
if not websiteFeatures.GetWebsiteFeature("ThumbnailRendering"):
return "Thumbnail Rendering is disabled"
GameServerObject : GameServer = findBestThumbnailer()
if GameServerObject is None:
return "No suitable game servers found"
ThumbnailReqId = str(uuid.uuid4())
redis_controller.set(f"Thumbnailer:Request:{ThumbnailReqId}", json.dumps({
"AssetId": AssetId,
"AssetVersionId": AssetVersionObject.version,
"isIcon": isIcon,
"AssetType": AssetType,
}), ex=600)
TargetX = 1024
TargetY = 1024
PlaceAuthorisationToken = None
if AssetType == 9:
PlaceAuthorisationToken = GenerateTempAuthToken( AssetId, Expiration = 600, CreatorIP = None )
if AssetType == 9 and not isIcon:
TargetX = 1280
TargetY = 720
if AssetType == 1: # Image
TargetX = 256
TargetY = 256
if AssetType == 32: # Package
AllPackageAssets : list[PackageAsset] = PackageAsset.query.filter_by(package_asset_id=AssetId).all()
AssetId = ""
for i in range(len(AllPackageAssets)):
AssetId += f"https://www.vortexi.cc/asset/?id={str(AllPackageAssets[i].asset_id)}"
if i != len(AllPackageAssets) - 1:
AssetId += ";"
try:
logging.info(f"thumbnailer : TakeThumbnail : Sent request to thumbnailer for asset {AssetId} with type {ThumbnailType} to {GameServerObject.serverName} [ {GameServerObject.serverId} ]")
perform_post(
TargetGameserver = GameServerObject,
Endpoint = "Thumbnail",
JSONData = {
"type": ThumbnailType,
"asset": AssetId,
"reqid": ThumbnailReqId,
"image_x": TargetX,
"image_y": TargetY,
"placetoken": PlaceAuthorisationToken
}
)
GameServerObject.thumbnailQueueSize += 1
db.session.commit()
except Exception as e:
return str(e)
return "Thumbnail request sent"
def isValidAuthorizationToken( authtoken : str) -> GameServer:
if authtoken is None:
return None
GameServerObject = GameServer.query.filter_by(accessKey=authtoken).first()
return GameServerObject
import base64
def Handle3DObjectData(EncodedData: bytes) -> str:
"""
Process the 3D object data from the thumbnailer and return a hash
"""
try:
Data = json.loads(EncodedData)
TextureHashes = []
OBJBytes = None
MTLBytes = None
OBJHash = None
MTLHash = None
for FileName, FileDict in Data["files"].items():
DecodedData: bytes = base64.b64decode(FileDict["content"])
if FileName.endswith(".obj"):
OBJBytes = DecodedData
OBJHash = f"{hashlib.sha512(DecodedData).hexdigest()}"
s3helper.UploadBytesToS3(DecodedData, OBJHash, contentType="text/plain")
elif FileName.endswith(".mtl"):
MTLBytes = DecodedData
MTLHash = f"{hashlib.sha512(DecodedData).hexdigest()}"
else:
TextureHash = f"{hashlib.sha512(DecodedData).hexdigest()}"
TextureHashes.append(TextureHash)
s3helper.UploadBytesToS3(DecodedData, TextureHash, contentType="image/png")
if MTLBytes:
MTLBytes = MTLBytes.replace(FileName.encode(), f"{TextureHash}".encode())
if MTLBytes:
s3helper.UploadBytesToS3(MTLBytes, MTLHash, contentType="text/plain")
OutputData = {
"camera": Data["camera"],
"aabb": Data["AABB"],
"obj": OBJHash,
"mtl": MTLHash,
"textures": TextureHashes
}
JsonContent = json.dumps(OutputData).encode()
# bruh
ContentHash = f"{hashlib.sha512(JsonContent).hexdigest()}"
s3helper.UploadBytesToS3(JsonContent, ContentHash, contentType="application/json")
return ContentHash
except Exception as e:
logging.error(f"Error in Handle3DObjectData: {str(e)}")
return None
@Thumbnailer.route('/thumbnailreturn', methods=["POST"])
@csrf.exempt
def thumbnailreturn():
AuthorizationToken = request.headers.get("Authorization")
if AuthorizationToken is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}), 400
ThumbnailerOwner: GameServer = isValidAuthorizationToken(AuthorizationToken)
if ThumbnailerOwner is None:
return jsonify({"status": "error", "message": "Invalid authorization token"}), 400
ReqId = request.headers.get("ReturnUUID")
if ReqId is None:
return jsonify({"status": "error", "message": "Invalid request id"}), 400
RequestData = redis_controller.get(f"Thumbnailer:Request:{ReqId}")
if RequestData is None:
return jsonify({"status": "error", "message": "Invalid request id"}), 400
redis_controller.delete(f"Thumbnailer:Request:{ReqId}")
RequestData = json.loads(RequestData)
if "UserId" not in RequestData:
AssetId = RequestData["AssetId"]
AssetVersionId = RequestData["AssetVersionId"]
isIcon = RequestData["isIcon"]
AssetType = RequestData["AssetType"]
ImageData = request.data
ImageHash = hashlib.sha512(ImageData).hexdigest()
s3helper.UploadBytesToS3(ImageData, ImageHash, contentType="image/png")
AssetVersionObject: AssetVersion = AssetVersion.query.filter_by(asset_id=AssetId, version=AssetVersionId).first()
if isIcon and AssetType == 9:
PlaceIconObject: PlaceIcon = PlaceIcon.query.filter_by(placeid=AssetId).first()
if PlaceIconObject is None:
PlaceIconObject = PlaceIcon(placeid=AssetId, contenthash=ImageHash, updated_at=datetime.utcnow())
db.session.add(PlaceIconObject)
else:
PlaceIconObject.contenthash = ImageHash
PlaceIconObject.updated_at = datetime.utcnow()
PlaceIconObject.moderation_status = 1
if AssetVersionObject:
redis_controller.setex(f"Thumbnailer:AssetImage:{AssetVersionObject.content_hash}:PlaceIcon", 60 * 60 * 24 * 3, ImageHash)
try:
ThumbnailerOwner.thumbnailQueueSize -= 1
db.session.commit()
except:
pass
return jsonify({"status": "success", "message": "Thumbnail saved"}), 200
if AssetVersionObject:
redis_controller.setex(f"Thumbnailer:AssetImage:{AssetVersionObject.content_hash}:Thumbnail", 60 * 60 * 24 * 3, ImageHash)
ThumbnailObject: AssetThumbnail = AssetThumbnail.query.filter_by(asset_id=AssetId, asset_version_id=AssetVersionId).first()
if ThumbnailObject is not None:
ThumbnailObject.content_hash = ImageHash
ThumbnailObject.updated_at = datetime.utcnow()
ThumbnailerOwner.thumbnailQueueSize -= 1
db.session.commit()
return jsonify({"status": "success", "message": "Thumbnail saved"}), 200
AssetObject: Asset = Asset.query.filter_by(id=AssetId).first()
AssetModeration = 1
if AssetObject.roblox_asset_id is not None:
AssetModeration = 0
AssetThumbnailObject = AssetThumbnail(asset_id=AssetId, asset_version_id=AssetVersionId, content_hash=ImageHash, created_at=datetime.utcnow(), moderation_status=AssetModeration) # 0 = Approved, 1 = Pending, 2 = Denied
db.session.add(AssetThumbnailObject)
db.session.commit()
return jsonify({"status": "success", "message": "Thumbnail saved"}), 200
else:
UserId = RequestData["UserId"]
ThumbnailType = RequestData["Type"]
ThumbnailIs3D = RequestData.get("Is3D", False)
ImageData = request.data
ImageHash = None
AvatarHash = GetAvatarHash(UserId)
if ThumbnailIs3D:
try:
ContentHash = Handle3DObjectData(ImageData)
if ContentHash is None:
return jsonify({"status": "error", "message": "Failed to process 3D data"}), 500
redis_controller.setex(f"Thumbnailer:UserImage:{AvatarHash}:3DThumbnail", 60 * 60 * 24 * 3, ContentHash)
ImageHash = ContentHash
except Exception as e:
logging.error(f"Error processing 3D avatar data: {str(e)}")
return jsonify({"status": "error", "message": f"Error processing 3D data: {str(e)}"}), 500
else:
ImageHash = hashlib.sha512(ImageData).hexdigest()
s3helper.UploadBytesToS3(ImageData, ImageHash, contentType="image/png")
if ThumbnailType == 0:
redis_controller.setex(f"Thumbnailer:UserImage:{AvatarHash}:Thumbnail", 60 * 60 * 24 * 3, ImageHash)
elif ThumbnailType == 1:
redis_controller.setex(f"Thumbnailer:UserImage:{AvatarHash}:Headshot", 60 * 60 * 24 * 3, ImageHash)
UserThumbnailObject = UserThumbnail.query.filter_by(userid=UserId).first()
if UserThumbnailObject is None:
if ThumbnailType == 0:
if ThumbnailIs3D:
UserThumbnailObject = UserThumbnail(
userid=UserId,
full_contenthash=None,
headshot_contenthash=None,
full_3dcontenthash=ImageHash,
updated_at=datetime.utcnow()
)
else:
UserThumbnailObject = UserThumbnail(
userid=UserId,
full_contenthash=ImageHash,
headshot_contenthash=None,
full_3dcontenthash=None,
updated_at=datetime.utcnow()
)
else:
UserThumbnailObject = UserThumbnail(
userid=UserId,
headshot_contenthash=ImageHash,
full_contenthash=None,
full_3dcontenthash=None,
updated_at=datetime.utcnow()
)
db.session.add(UserThumbnailObject)
else:
if ThumbnailType == 0:
if ThumbnailIs3D:
UserThumbnailObject.full_3dcontenthash = ImageHash
else:
UserThumbnailObject.full_contenthash = ImageHash
else:
UserThumbnailObject.headshot_contenthash = ImageHash
UserThumbnailObject.updated_at = datetime.utcnow()
ThumbnailerOwner.thumbnailQueueSize -= 1
db.session.commit()
return jsonify({"status": "success", "message": "Thumbnail updated"}), 200
+219
View File
@@ -0,0 +1,219 @@
# users.roblox.com
from flask import Blueprint, jsonify, request, make_response
from flask_wtf.csrf import CSRFError, generate_csrf
from app.extensions import db, redis_controller, limiter, csrf
from app.models.user import User
from app.models.userassets import UserAsset
from app.models.past_usernames import PastUsername
from app.models.asset import Asset
from app.util import membership, auth
from app.enums.AssetType import AssetType
from app.enums.MembershipType import MembershipType
from sqlalchemy import func
UsersAPI = Blueprint('UsersAPI', __name__, url_prefix='/')
csrf.exempt(UsersAPI)
@UsersAPI.errorhandler(CSRFError)
def handle_csrf_error(e):
ErrorResponse = make_response(jsonify({
"errors": [
{
"code": 0,
"message": "Token Validation Failed"
}
]
}))
ErrorResponse.status_code = 403
ErrorResponse.headers["x-csrf-token"] = generate_csrf()
return ErrorResponse
@UsersAPI.errorhandler(429)
def handle_ratelimit_reached(e):
return jsonify({
"errors": [
{
"code": 9,
"message": "The flood limit has been exceeded."
}
]
}), 429
@UsersAPI.before_request
def before_request():
if "Roblox/" not in request.user_agent.string:
if request.path == "/v1/usernames/users" or request.path == "/v1/users":
return
csrf.protect()
@UsersAPI.route('/v1/users/<int:userId>', methods=['GET'])
@limiter.limit("60/minute")
def get_user( userId : int ):
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify( { "errors": [ { "code": 3, "message": "The user id is invalid." } ] } ), 404
return jsonify({
"description": userObject.description,
"created": userObject.created.isoformat(),
"isBanned": userObject.accountstatus != 1,
"externalAppDisplayName": userObject.username,
"hasVerifiedBadge": False,
"id": userObject.id,
"name": userObject.username,
"displayName": userObject.username,
})
@UsersAPI.route('/v1/users/authenticated', methods=['GET'])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_authenticated_user():
AuthenticatedUser : User = auth.GetCurrentUser()
return jsonify({
"id": AuthenticatedUser.id,
"name": AuthenticatedUser.username,
"displayName": AuthenticatedUser.username,
})
@UsersAPI.route("/v1/users/authenticated/roles", methods=["GET"])
@limiter.limit("60/minute")
@auth.authenticated_required_api
def get_authenticated_user_roles():
return jsonify({
"roles": []
}), 200
@UsersAPI.route("/v1/usernames/users", methods=["POST"])
@limiter.limit("60/minute")
def multi_username_lookup():
if not request.is_json:
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
if "usernames" not in request.json:
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
if not isinstance(request.json["usernames"], list):
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
usernames = request.json["usernames"]
if len(usernames) > 100:
return jsonify( { "errors": [ { "code": 2, "message": "Too many usernames." } ] } ), 400
alreadySearchedUsernames = []
data_result = []
for username in usernames:
username = str(username)
if username.lower() in alreadySearchedUsernames:
continue
alreadySearchedUsernames.append(username.lower())
userObject : User = User.query.filter(func.lower(User.username) == username.lower()).first()
if userObject is not None:
data_result.append({
"requestedUsername": username,
"hasVerifiedBadge": False,
"id": userObject.id,
"name": userObject.username,
"displayName": userObject.username
})
continue
pastusernameLookup : PastUsername = PastUsername.query.filter(func.lower(PastUsername.username) == username.lower()).first()
if pastusernameLookup is not None:
userObject : User = User.query.filter_by(id=pastusernameLookup.user_id).first()
if userObject is not None:
data_result.append({
"requestedUsername": username,
"hasVerifiedBadge": False,
"id": userObject.id,
"name": userObject.username,
"displayName": userObject.username
})
continue
return jsonify({
"data": data_result
}), 200
@UsersAPI.route("/v1/users", methods=["POST"])
@limiter.limit("60/minute")
def multi_user_lookup():
if not request.is_json:
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
if "userIds" not in request.json:
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
if not isinstance(request.json["userIds"], list):
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
userIds = request.json["userIds"]
if len(userIds) > 100:
return jsonify( { "errors": [ { "code": 2, "message": "Too many usernames." } ] } ), 400
alreadySearchedUserIds = []
data_result = []
for userId in userIds:
if type(userId) != int:
return jsonify( { "errors": [ { "code": 0, "message": "The request is invalid." } ] } ), 400
if userId in alreadySearchedUserIds:
continue
alreadySearchedUserIds.append(userId)
userObject : User = User.query.filter_by(id=userId).first()
if userObject is not None:
data_result.append({
"id": userObject.id,
"name": userObject.username,
"displayName": userObject.username,
"hasVerifiedBadge": False
})
continue
return jsonify({
"data": data_result
}), 200
@UsersAPI.route("/v1/users/<int:userId>/username-history", methods=["GET"])
@limiter.limit("60/minute")
def past_username_history_lookup( userId : int ):
userObject : User = User.query.filter_by(id=userId).first()
if userObject is None:
return jsonify( { "errors": [ { "code": 3, "message": "The user id is invalid." } ] } ), 404
cursorPage : int = request.args.get("cursor", default = 1, type = int)
if cursorPage < 1:
return jsonify( { "errors": [ { "code": 4, "message": "The specified cursor is invalid!" } ] } ), 400
pageLimit : int = request.args.get("limit", default = 10, type = int)
if pageLimit not in [10, 25, 50, 100]:
return jsonify( { "errors": [ { "code": 5, "message": "The specified limit is invalid!" } ] } ), 400
sortOrder : str = request.args.get("sortOrder", default = "Asc", type = str)
if sortOrder.lower() not in ["asc", "desc"]:
return jsonify( { "errors": [ { "code": 6, "message": "The specified sort order is invalid!" } ] } ), 400
pastUsernames = PastUsername.query.filter_by(user_id=userId).order_by(
PastUsername.id.desc() if sortOrder.lower() == "desc" else PastUsername.id.asc()
).paginate(page=cursorPage, per_page=pageLimit, error_out=False)
data_result = []
for pastUsername in pastUsernames.items:
data_result.append({
"name": pastUsername.username,
})
return jsonify({
"previousPageCursor": str(pastUsernames.prev_num) if pastUsernames.has_prev else None,
"nextPageCursor": str(pastUsernames.next_num) if pastUsernames.has_next else None,
"data": data_result
}),200