add web
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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 """
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user