syntaxwebsite/app/routes/publicapi.py

525 lines
22 KiB
Python

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 SYNTAX").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 SYNTAX 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 SYNTAX 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
})