This commit is contained in:
copyrighttxt
2026-07-06 13:28:00 -04:00
commit 05211f73ef
441 changed files with 94128 additions and 0 deletions
+991
View File
@@ -0,0 +1,991 @@
"""
Python Library for reading and writing Roblox mesh files.
Written by something.else on 21/9/2023
Supported meshes up to version 5.
Mesh Format documentation by MaximumADHD: https://devforum.roblox.com/t/roblox-mesh-format/326114
"""
import struct
from dataclasses import dataclass
import sys
def debug_print( message : str ) -> None:
if __name__ == "__main__":
print(message)
"""
struct FileMeshVertexNormalTexture3d
{
float vx,vy,vz;
float nx,ny,nz;
float tu,tv;
signed char tx, ty, tz, ts;
unsigned char r, g, b, a;
};
"""
@dataclass
class FileMeshVertexNormalTexture3d:
vx: float
vy: float
vz: float
nx: float
ny: float
nz: float
tu: float
tv: float
tx: int
ty: int
tz: int
ts: int
r: int
g: int
b: int
a: int
def read_data( self, data : str ):
if len(data) < 40:
raise Exception(f"FileMeshVertexNormalTexture3d.read_data: data is too short ({len(data)} bytes)")
self.vx = struct.unpack("<f", data[0:4])[0]
self.vy = struct.unpack("<f", data[4:8])[0]
self.vz = struct.unpack("<f", data[8:12])[0]
self.nx = struct.unpack("<f", data[12:16])[0]
self.ny = struct.unpack("<f", data[16:20])[0]
self.nz = struct.unpack("<f", data[20:24])[0]
self.tu = struct.unpack("<f", data[24:28])[0]
self.tv = struct.unpack("<f", data[28:32])[0]
self.tx = int.from_bytes(data[32:33], "little")
self.ty = int.from_bytes(data[33:34], "little")
self.tz = int.from_bytes(data[34:35], "little")
self.ts = int.from_bytes(data[35:36], "little")
self.r = int.from_bytes(data[36:37], "little")
self.g = int.from_bytes(data[37:38], "little")
self.b = int.from_bytes(data[38:39], "little")
self.a = int.from_bytes(data[39:40], "little")
def export_data( self ) -> bytearray:
return bytearray(
struct.pack("<f", self.vx) +
struct.pack("<f", self.vy) +
struct.pack("<f", self.vz) +
struct.pack("<f", self.nx) +
struct.pack("<f", self.ny) +
struct.pack("<f", self.nz) +
struct.pack("<f", self.tu) +
struct.pack("<f", self.tv) +
self.tx.to_bytes(1, "little") +
self.ty.to_bytes(1, "little") +
self.tz.to_bytes(1, "little") +
self.ts.to_bytes(1, "little") +
self.r.to_bytes(1, "little") +
self.g.to_bytes(1, "little") +
self.b.to_bytes(1, "little") +
self.a.to_bytes(1, "little")
)
"""
struct FileMeshVertexNormalTexture3dNoRGBA
{
float vx, vy, vz;
float nx, ny, nz;
float tu, tv;
signed char tx, ty, tz, ts;
};
"""
@dataclass
class FileMeshVertexNormalTexture3dNoRGBA:
vx: float
vy: float
vz: float
nx: float
ny: float
nz: float
tu: float
tv: float
tx: int
ty: int
tz: int
ts: int
def read_data( self, data : str ):
if len(data) < 36:
raise Exception(f"FileMeshVertexNormalTexture3dNoRGBA.read_data: data is too short ({len(data)} bytes)")
self.vx = struct.unpack("<f", data[0:4])[0]
self.vy = struct.unpack("<f", data[4:8])[0]
self.vz = struct.unpack("<f", data[8:12])[0]
self.nx = struct.unpack("<f", data[12:16])[0]
self.ny = struct.unpack("<f", data[16:20])[0]
self.nz = struct.unpack("<f", data[20:24])[0]
self.tu = struct.unpack("<f", data[24:28])[0]
self.tv = struct.unpack("<f", data[28:32])[0]
self.tx = int.from_bytes(data[32:33], "little")
self.ty = int.from_bytes(data[33:34], "little")
self.tz = int.from_bytes(data[34:35], "little")
self.ts = int.from_bytes(data[35:36], "little")
def export_data( self ) -> bytearray:
return bytearray(
struct.pack("<f", self.vx) +
struct.pack("<f", self.vy) +
struct.pack("<f", self.vz) +
struct.pack("<f", self.nx) +
struct.pack("<f", self.ny) +
struct.pack("<f", self.nz) +
struct.pack("<f", self.tu) +
struct.pack("<f", self.tv) +
self.tx.to_bytes(1, "little") +
self.ty.to_bytes(1, "little") +
self.tz.to_bytes(1, "little") +
self.ts.to_bytes(1, "little")
)
"""
struct FileMeshFace
{
unsigned int a;
unsigned int b;
unsigned int c;
};
"""
@dataclass
class FileMeshFace:
a: int
b: int
c: int
def read_data( self, data : str ):
if len(data) < 12:
raise Exception(f"FileMeshFace.read_data: data is too short ({len(data)} bytes)")
self.a = int.from_bytes(data[0:4], "little")
self.b = int.from_bytes(data[4:8], "little")
self.c = int.from_bytes(data[8:12], "little")
def export_data( self ) -> bytearray:
return bytearray(
self.a.to_bytes(4, "little") +
self.b.to_bytes(4, "little") +
self.c.to_bytes(4, "little")
)
"""
struct FileMeshHeader
{
unsigned short cbSize;
unsigned char cbVerticesStride;
unsigned char cbFaceStride;
unsigned int num_vertices;
unsigned int num_faces;
};
"""
@dataclass
class FileMeshHeader:
cbSize: int
cbVerticesStride: int
cbFaceStride: int
num_vertices: int
num_faces: int
def read_data( self, data : str ):
if len(data) < 12:
raise Exception(f"FileMeshHeader.read_data: data is too short ({len(data)} bytes)")
self.cbSize = int.from_bytes(data[0:2], "little")
if self.cbSize != 12:
raise Exception(f"FileMeshHeader.read_data: invalid cbSize ({self.cbSize})")
self.cbVerticesStride = int.from_bytes(data[2:3], "little")
self.cbFaceStride = int.from_bytes(data[3:4], "little")
self.num_vertices = int.from_bytes(data[4:8], "little")
self.num_faces = int.from_bytes(data[8:12], "little")
def export_data(self) -> bytearray:
return bytearray(
self.cbSize.to_bytes(2, "little") +
self.cbVerticesStride.to_bytes(1, "little") +
self.cbFaceStride.to_bytes(1, "little") +
self.num_vertices.to_bytes(4, "little") +
self.num_faces.to_bytes(4, "little")
)
def __str__(self) -> str:
return f"FileMeshHeader(cbSize={self.cbSize}, cbVerticesStride={self.cbVerticesStride}, cbFaceStride={self.cbFaceStride}, num_vertices={self.num_vertices}, num_faces={self.num_faces})"
def __repr__(self) -> str:
return str(self)
"""
struct FileMeshHeaderV3
{
unsigned short cbSize;
unsigned char cbVerticesStride;
unsigned char cbFaceStride;
unsigned short sizeof_LOD;
unsigned short numLODs;
unsigned int num_vertices;
unsigned int num_faces;
};
"""
@dataclass
class FileMeshHeaderV3:
cbSize: int
cbVerticesStride: int
cbFaceStride: int
sizeof_LOD: int
numLODs: int
num_vertices: int
num_faces: int
def read_data( self, data : str ):
if len(data) < 16:
raise Exception(f"FileMeshHeaderV3.read_data: data is too short ({len(data)} bytes)")
self.cbSize = int.from_bytes(data[0:2], "little")
if self.cbSize != 16:
raise Exception(f"FileMeshHeaderV3.read_data: invalid cbSize ({self.cbSize})")
self.cbVerticesStride = int.from_bytes(data[2:3], "little")
self.cbFaceStride = int.from_bytes(data[3:4], "little")
self.sizeof_LOD = int.from_bytes(data[4:6], "little")
self.numLODs = int.from_bytes(data[6:8], "little")
self.num_vertices = int.from_bytes(data[8:12], "little")
self.num_faces = int.from_bytes(data[12:16], "little")
def export_data(self) -> bytearray:
return bytearray(
self.cbSize.to_bytes(2, "little") +
self.cbVerticesStride.to_bytes(1, "little") +
self.cbFaceStride.to_bytes(1, "little") +
self.sizeof_LOD.to_bytes(2, "little") +
self.numLODs.to_bytes(2, "little") +
self.num_vertices.to_bytes(4, "little") +
self.num_faces.to_bytes(4, "little")
)
"""
struct FileMeshHeaderV4
{
unsigned short sizeof_MeshHeader;
unsigned short lodType;
unsigned int numVerts;
unsigned int numFaces;
unsigned short numLODs;
unsigned short numBones;
unsigned int sizeof_boneNamesBuffer;
unsigned short numSubsets;
unsigned char numHighQualityLODs;
unsigned char unused;
};
"""
@dataclass
class FileMeshHeaderV4:
sizeof_MeshHeader: int
lodType: int
numVerts: int
numFaces: int
numLODs: int
numBones: int
sizeof_boneNamesBuffer: int
numSubsets: int
numHighQualityLODs: int
unused: int
def read_data( self, data : str ):
if len(data) < 24:
raise Exception(f"FileMeshHeaderV4.read_data: data is too short ({len(data)} bytes)")
self.sizeof_MeshHeader = int.from_bytes(data[0:2], "little")
if self.sizeof_MeshHeader != 24:
raise Exception(f"FileMeshHeaderV4.read_data: invalid sizeof_MeshHeader ({self.sizeof_MeshHeader})")
self.lodType = int.from_bytes(data[2:4], "little")
self.numVerts = int.from_bytes(data[4:8], "little")
self.numFaces = int.from_bytes(data[8:12], "little")
self.numLODs = int.from_bytes(data[12:14], "little")
self.numBones = int.from_bytes(data[14:16], "little")
self.sizeof_boneNamesBuffer = int.from_bytes(data[16:20], "little")
self.numSubsets = int.from_bytes(data[20:22], "little")
self.numHighQualityLODs = int.from_bytes(data[22:23], "little")
self.unused = int.from_bytes(data[23:24], "little")
def export_data(self) -> bytearray:
return bytearray(
self.sizeof_MeshHeader.to_bytes(2, "little") +
self.lodType.to_bytes(2, "little") +
self.numVerts.to_bytes(4, "little") +
self.numFaces.to_bytes(4, "little") +
self.numLODs.to_bytes(2, "little") +
self.numBones.to_bytes(2, "little") +
self.sizeof_boneNamesBuffer.to_bytes(4, "little") +
self.numSubsets.to_bytes(2, "little") +
self.numHighQualityLODs.to_bytes(1, "little") +
self.unused.to_bytes(1, "little")
)
"""
struct Envelope
{
unsigned char bones[4];
unsigned char weights[4];
};
"""
@dataclass
class Envelope:
bones: list[int]
weights: list[int]
def read_data( self, data : str ):
if len(data) < 8:
raise Exception(f"Envelope.read_data: data is too short ({len(data)} bytes)")
for i in range(0, 4):
self.bones.append(int.from_bytes(data[i:i+1], "little"))
self.weights.append(int.from_bytes(data[i+4:i+5], "little"))
def export_data(self) -> bytearray:
return bytearray(
self.bones[0].to_bytes(1, "little") +
self.bones[1].to_bytes(1, "little") +
self.bones[2].to_bytes(1, "little") +
self.bones[3].to_bytes(1, "little") +
self.weights[0].to_bytes(1, "little") +
self.weights[1].to_bytes(1, "little") +
self.weights[2].to_bytes(1, "little") +
self.weights[3].to_bytes(1, "little")
)
"""
struct Bone
{
unsigned int boneNameIndex;
unsigned short parentIndex;
unsigned short lodParentIndex;
float culling;
float r00, r01, r02;
float r10, r11, r12;
float r20, r21, r22;
float x, y, z;
};
"""
@dataclass
class Bone:
boneNameIndex: int
parentIndex: int
lodParentIndex: int
culling: float
r00: float
r01: float
r02: float
r10: float
r11: float
r12: float
r20: float
r21: float
r22: float
x: float
y: float
z: float
def read_data( self, data : str ):
if len(data) < 60:
raise Exception(f"Bone.read_data: data is too short ({len(data)} bytes)")
self.boneNameIndex = int.from_bytes(data[0:4], "little")
self.parentIndex = int.from_bytes(data[4:6], "little")
self.lodParentIndex = int.from_bytes(data[6:8], "little")
self.culling = struct.unpack("<f", data[8:12])[0]
self.r00 = struct.unpack("<f", data[12:16])[0]
self.r01 = struct.unpack("<f", data[16:20])[0]
self.r02 = struct.unpack("<f", data[20:24])[0]
self.r10 = struct.unpack("<f", data[24:28])[0]
self.r11 = struct.unpack("<f", data[28:32])[0]
self.r12 = struct.unpack("<f", data[32:36])[0]
self.r20 = struct.unpack("<f", data[36:40])[0]
self.r21 = struct.unpack("<f", data[40:44])[0]
self.r22 = struct.unpack("<f", data[44:48])[0]
self.x = struct.unpack("<f", data[48:52])[0]
self.y = struct.unpack("<f", data[52:56])[0]
self.z = struct.unpack("<f", data[56:60])[0]
def export_data(self) -> bytearray:
return bytearray(
self.boneNameIndex.to_bytes(4, "little") +
self.parentIndex.to_bytes(2, "little") +
self.lodParentIndex.to_bytes(2, "little") +
struct.pack("<f", self.culling) +
struct.pack("<f", self.r00) +
struct.pack("<f", self.r01) +
struct.pack("<f", self.r02) +
struct.pack("<f", self.r10) +
struct.pack("<f", self.r11) +
struct.pack("<f", self.r12) +
struct.pack("<f", self.r20) +
struct.pack("<f", self.r21) +
struct.pack("<f", self.r22) +
struct.pack("<f", self.x) +
struct.pack("<f", self.y) +
struct.pack("<f", self.z)
)
"""
struct MeshSubset
{
unsigned int facesBegin;
unsigned int facesLength;
unsigned int vertsBegin;
unsigned int vertsLength;
unsigned int numBoneIndicies;
unsigned short boneIndicies[26];
};
"""
@dataclass
class MeshSubset:
facesBegin: int
facesLength: int
vertsBegin: int
vertsLength: int
numBoneIndicies: int
boneIndicies: list[int]
def read_data( self, data : str ):
if len(data) < 72:
raise Exception(f"MeshSubset.read_data: data is too short ({len(data)} bytes)")
self.facesBegin = int.from_bytes(data[0:4], "little")
self.facesLength = int.from_bytes(data[4:8], "little")
self.vertsBegin = int.from_bytes(data[8:12], "little")
self.vertsLength = int.from_bytes(data[12:16], "little")
self.numBoneIndicies = int.from_bytes(data[16:20], "little")
for i in range(0, 26):
self.boneIndicies.append(int.from_bytes(data[20+i:21+i], "little"))
def export_data(self) -> bytearray:
subsetData : bytearray = bytearray(
self.facesBegin.to_bytes(4, "little") +
self.facesLength.to_bytes(4, "little") +
self.vertsBegin.to_bytes(4, "little") +
self.vertsLength.to_bytes(4, "little") +
self.numBoneIndicies.to_bytes(4, "little")
)
for i in range(0, 26):
subsetData += self.boneIndicies[i].to_bytes(1, "little")
return subsetData
"""
struct FileMeshHeaderV5
{
unsigned short sizeof_MeshHeader;
unsigned short lodType;
unsigned int numVerts;
unsigned int numFaces;
unsigned short numLODs;
unsigned short numBones;
unsigned int sizeof_boneNamesBuffer;
unsigned short numSubsets;
unsigned char numHighQualityLODs;
unsigned char unusedPadding;
unsigned int facsDataFormat;
unsigned int facsDataSize;
};
"""
@dataclass
class FileMeshHeaderV5:
sizeof_MeshHeader: int
lodType: int
numVerts: int
numFaces: int
numLODs: int
numBones: int
sizeof_boneNamesBuffer: int
numSubsets: int
numHighQualityLODs: int
unusedPadding: int
facsDataFormat: int
facsDataSize: int
def read_data( self, data : str ):
if len(data) < 32:
raise Exception(f"FileMeshHeaderV5.read_data: data is too short ({len(data)} bytes)")
self.sizeof_MeshHeader = int.from_bytes(data[0:2], "little")
if self.sizeof_MeshHeader != 32:
raise Exception(f"FileMeshHeaderV5.read_data: invalid sizeof_MeshHeader ({self.sizeof_MeshHeader})")
self.lodType = int.from_bytes(data[2:4], "little")
self.numVerts = int.from_bytes(data[4:8], "little")
self.numFaces = int.from_bytes(data[8:12], "little")
self.numLODs = int.from_bytes(data[12:14], "little")
self.numBones = int.from_bytes(data[14:16], "little")
self.sizeof_boneNamesBuffer = int.from_bytes(data[16:20], "little")
self.numSubsets = int.from_bytes(data[20:22], "little")
self.numHighQualityLODs = int.from_bytes(data[22:23], "little")
self.unusedPadding = int.from_bytes(data[23:24], "little")
self.facsDataFormat = int.from_bytes(data[24:28], "little")
self.facsDataSize = int.from_bytes(data[28:32], "little")
def export_data(self) -> bytearray:
return bytearray(
self.sizeof_MeshHeader.to_bytes(2, "little") +
self.lodType.to_bytes(2, "little") +
self.numVerts.to_bytes(4, "little") +
self.numFaces.to_bytes(4, "little") +
self.numLODs.to_bytes(2, "little") +
self.numBones.to_bytes(2, "little") +
self.sizeof_boneNamesBuffer.to_bytes(4, "little") +
self.numSubsets.to_bytes(2, "little") +
self.numHighQualityLODs.to_bytes(1, "little") +
self.unusedPadding.to_bytes(1, "little") +
self.facsDataFormat.to_bytes(4, "little") +
self.facsDataSize.to_bytes(4, "little")
)
@dataclass
class FileMeshData:
vnts: list[FileMeshVertexNormalTexture3d]
faces: list[FileMeshFace]
header : FileMeshHeader | FileMeshHeaderV3 | FileMeshHeaderV4 | FileMeshHeaderV5
LODs : list[int]
bones : list[Bone]
boneNames : str
meshSubsets : list[MeshSubset]
full_faces : list[FileMeshFace]
envelopes : list[Envelope]
def read_data( data : str, offset : int, size : int ) -> str:
"""Reads a string of data from a given offset and size."""
if len(data) < offset + size:
raise Exception(f"read_data: offset is out of bounds (offset={offset}, size={size})")
return data[offset:offset+size]
def get_mesh_version( data : bytearray ) -> float:
"""Gets the version of the mesh file. Throws an exception if the version is not supported."""
if len(data) < 12:
raise Exception(f"get_mesh_version: data is too short ({len(data)} bytes)")
if not data[0:8] == b"version ":
raise Exception(f"get_mesh_version: invalid mesh header ({data[0:8]})")
if data[0:12] == b"version 1.00":
return 1.0
elif data[0:12] == b"version 1.01":
return 1.1
elif data[0:12] == b"version 2.00":
return 2.0
elif data[0:12] == b"version 3.00":
return 3.0
elif data[0:12] == b"version 3.01":
return 3.1
elif data[0:12] == b"version 4.00":
return 4.0
elif data[0:12] == b"version 4.01":
return 4.1
elif data[0:12] == b"version 5.00":
return 5.0
elif data[0:12] == b"version 5.01":
return 5.1
else:
raise Exception(f"get_mesh_version: unsupported mesh version ({data[0:12]})")
def read_mesh_v1( data : bytearray, offset : int, scale : int = 0.5, invertUV : bool = True) -> FileMeshData:
data = data.decode("ASCII")
meshData : FileMeshData = FileMeshData([], [], FileMeshHeader(0, 0, 0, 0, 0), [], [], "", [], [], [])
numFaces : int = int(data.split("\n")[1])
debug_print(f"read_mesh_v1: numFaces={numFaces}")
# [0.551563,-0.0944613,0.0862401] we need to find every vector3 in the file
# Each vert has 3 vector3 values so we need to find 3 vector3 values for each vert
startingIndex = data.find("[")
allVectors : list[str] = data[startingIndex:].split("]")
for i in range(0, len(allVectors)):
vector : str = allVectors[i].strip()
vector = vector.replace("[", "").replace("]", "")
if vector == "":
del allVectors[i]
continue
vector_floats : list[float] = [float(x) for x in vector.split(",")]
if len(vector_floats) != 3:
raise Exception(f"read_mesh_v1: invalid vector3 ({vector})")
#debug_print(f"read_mesh_v1: allVectors[{i}]={vector_floats}")
allVectors[i] = vector_floats
if len(allVectors) != numFaces * 9:
raise Exception(f"read_mesh_v1: invalid number of verticies ({len(allVectors)}), expected {numFaces * 9}")
for i in range(0, len(allVectors), 3):
vertPos : list[float] = allVectors[ i ]
vertNorm : list[float] = allVectors[ i + 1 ]
vertUV : list[float] = allVectors[ i + 2 ]
meshData.vnts.append(FileMeshVertexNormalTexture3d(
vertPos[0] * scale, vertPos[1] * scale, vertPos[2] * scale,
vertNorm[0], vertNorm[1], vertNorm[2],
# Version 1.0 has the UVs inverted, it was only fixed in 1.1
vertUV[0], float(( 1 - vertUV[1] ) if invertUV else vertUV[1]), int(vertUV[2]),
0, 0, 0, 0, 0, 0, 0
))
debug_print(f"read_mesh_v1: vnts[{i // 3}]={meshData.vnts[i // 3]}")
for i in range(0, numFaces):
meshData.faces.append(FileMeshFace(i * 3, i * 3 + 1, i * 3 + 2))
debug_print(f"read_mesh_v1: faces[{i}]={meshData.faces[i]}")
debug_print(f"read_mesh_v1: read {len(meshData.vnts)} vertices and {len(meshData.faces)} faces successfully")
return meshData
def read_mesh_v2( data : bytearray, offset : int ) -> FileMeshData:
meshData : FileMeshData = FileMeshData([], [], FileMeshHeader(0, 0, 0, 0, 0), [], [], "", [], [], [])
meshHeader : FileMeshHeader = FileMeshHeader(0, 0, 0, 0, 0)
meshHeader.read_data(read_data(data, offset, 12))
offset += 12
debug_print(f"read_mesh_v2: meshHeader={meshHeader}")
if meshHeader.num_vertices == 0 or meshHeader.num_faces == 0:
raise Exception(f"read_mesh_v2: empty mesh")
meshData.header = meshHeader
isRGBAMissing = meshHeader.cbVerticesStride == 36
if isRGBAMissing:
for i in range(0, meshHeader.num_vertices):
meshData.vnts.append(FileMeshVertexNormalTexture3dNoRGBA(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
meshData.vnts[i].read_data(read_data(data, offset + i * 36, 36))
debug_print(f"read_mesh_v2: vnts[{i}]={meshData.vnts[i]}")
else:
for i in range(0, meshHeader.num_vertices):
meshData.vnts.append(FileMeshVertexNormalTexture3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
meshData.vnts[i].read_data(read_data(data, offset + i * 40, 40))
debug_print(f"read_mesh_v2: vnts[{i}]={meshData.vnts[i]}")
offset += meshHeader.num_vertices * meshHeader.cbVerticesStride
for i in range(0, meshHeader.num_faces):
meshData.faces.append(FileMeshFace(0, 0, 0))
meshData.faces[i].read_data(read_data(data, offset + i * 12, 12))
debug_print(f"read_mesh_v2: faces[{i}]={meshData.faces[i]}")
offset += meshHeader.num_faces * meshHeader.cbFaceStride
if offset != len(data):
raise Exception(f"read_mesh_v2: unexpected data at end of file ({len(data) - offset} bytes)")
debug_print(f"read_mesh_v2: read {len(meshData.vnts)} vertices and {len(meshData.faces)} faces successfully")
return meshData
def read_mesh_v3( data : bytearray, offset : int ) -> FileMeshData:
meshData : FileMeshData = FileMeshData([], [], FileMeshHeader(0, 0, 0, 0, 0), [], [], "", [], [], [])
meshHeader : FileMeshHeaderV3 = FileMeshHeaderV3(0, 0, 0, 0, 0, 0, 0)
meshHeader.read_data(read_data(data, offset, 16))
offset += 16
debug_print(f"read_mesh_v3: meshHeader={meshHeader}")
if meshHeader.num_vertices == 0 or meshHeader.num_faces == 0:
raise Exception(f"read_mesh_v3: empty mesh")
meshData.header = meshHeader
for i in range(0, meshHeader.num_vertices):
meshData.vnts.append(FileMeshVertexNormalTexture3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
meshData.vnts[i].read_data(read_data(data, offset + i * 40, 40))
debug_print(f"read_mesh_v3: vnts[{i}]={meshData.vnts[i]}")
offset += meshHeader.num_vertices * meshHeader.cbVerticesStride
for i in range(0, meshHeader.num_faces):
meshData.faces.append(FileMeshFace(0, 0, 0))
meshData.faces[i].read_data(read_data(data, offset + i * 12, 12))
debug_print(f"read_mesh_v3: faces[{i}]={meshData.faces[i]}")
offset += meshHeader.num_faces * meshHeader.cbFaceStride
# LODs ( sizeof_LOD [ unsigned int ] * numLODs )
meshLODs : list[int] = []
for i in range(0, meshHeader.numLODs):
meshLODs.append(int.from_bytes(read_data(data, offset + i * 4, 4), "little"))
debug_print(f"read_mesh_v3: meshLODs[{i}]={meshLODs[i]}")
offset += meshHeader.numLODs * 4
# We only keep the first LOD in the mesh data
if len(meshLODs) > 1:
meshData.full_faces = meshData.faces
meshData.faces = meshData.faces[0:meshLODs[1]]
debug_print(f"read_mesh_v3: only keeping {meshLODs[1]}/{meshHeader.num_faces} faces")
meshData.LODs = meshLODs
if offset != len(data):
raise Exception(f"read_mesh_v3: unexpected data at end of file ({len(data) - offset} bytes)")
debug_print(f"read_mesh_v3: read {len(meshData.vnts)} vertices and {len(meshData.faces)} faces successfully")
return meshData
def read_mesh_v4( data : bytearray, offset : int ) -> FileMeshData:
meshData : FileMeshData = FileMeshData([], [], FileMeshHeader(0, 0, 0, 0, 0), [], [], "", [], [], [])
meshHeader : FileMeshHeaderV4 = FileMeshHeaderV4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
meshHeader.read_data(read_data(data, offset, 24))
offset += 24
debug_print(f"read_mesh_v4: meshHeader={meshHeader}")
if meshHeader.numVerts == 0 or meshHeader.numFaces == 0:
raise Exception(f"read_mesh_v4: empty mesh")
meshData.header = meshHeader
for i in range(0, meshHeader.numVerts):
meshData.vnts.append(FileMeshVertexNormalTexture3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 127, 127, 127, 127, 127))
meshData.vnts[i].read_data(read_data(data, offset + i * 40, 40))
debug_print(f"read_mesh_v4: vnts[{i}]={meshData.vnts[i]}")
offset += meshHeader.numVerts * 40
if meshHeader.numBones > 0:
for i in range(0, meshHeader.numVerts):
meshData.envelopes.append(Envelope([], []))
meshData.envelopes[i].read_data(read_data(data, offset + i * 8, 8))
debug_print(f"read_mesh_v4: envelopes[{i}]={meshData.envelopes[i]}")
offset += meshHeader.numVerts * 8
for i in range(0, meshHeader.numFaces):
meshData.faces.append(FileMeshFace(0, 0, 0))
meshData.faces[i].read_data(read_data(data, offset + i * 12, 12))
debug_print(f"read_mesh_v4: faces[{i}]={meshData.faces[i]}")
offset += meshHeader.numFaces * 12
# LODs ( sizeof_LOD [ unsigned int ] * numLODs )
meshLODs : list[int] = []
for i in range(0, meshHeader.numLODs):
meshLODs.append(int.from_bytes(read_data(data, offset + i * 4, 4), "little"))
debug_print(f"read_mesh_v4: meshLODs[{i}]={meshLODs[i]}")
offset += meshHeader.numLODs * 4
if len(meshLODs) > 1:
meshData.full_faces = meshData.faces
meshData.faces = meshData.faces[0:meshLODs[1]]
debug_print(f"read_mesh_v4: only keeping {meshLODs[1]}/{meshHeader.numFaces} faces")
meshData.LODs = meshLODs
if meshHeader.numBones > 0:
for i in range(0, meshHeader.numBones):
meshData.bones.append(Bone(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -127, -127, -127, 0, 0))
meshData.bones[i].read_data(read_data(data, offset + i * 60, 60))
debug_print(f"read_mesh_v4: bones[{i}]={meshData.bones[i]}")
offset += meshHeader.numBones * 60
boneNames : str = read_data(data, offset, meshHeader.sizeof_boneNamesBuffer).decode("utf-8")
offset += meshHeader.sizeof_boneNamesBuffer
debug_print(f"read_mesh_v4: boneNames={boneNames}")
meshSubsets : list[MeshSubset] = []
for i in range(0, meshHeader.numSubsets):
meshSubsets.append(MeshSubset(0, 0, 0, 0, 0, []))
meshSubsets[i].read_data(read_data(data, offset + i * 72, 72))
debug_print(f"read_mesh_v4: meshSubsets[{i}]={meshSubsets[i]}")
meshData.meshSubsets = meshSubsets
offset += meshHeader.numSubsets * 72
offset += meshHeader.unused
if offset != len(data):
raise Exception(f"read_mesh_v4: unexpected data at end of file ({len(data) - offset} bytes)")
debug_print(f"read_mesh_v4: read {len(meshData.vnts)} vertices and {len(meshData.faces)} faces successfully")
return meshData
def read_mesh_v5( data : bytearray, offset : int ) -> FileMeshData:
meshData : FileMeshData = FileMeshData([], [], FileMeshHeader(0, 0, 0, 0, 0), [], [], "", [], [], [])
meshHeader : FileMeshHeaderV5 = FileMeshHeaderV5(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
meshHeader.read_data(read_data(data, offset, 32))
offset += 32
debug_print(f"read_mesh_v5: meshHeader={meshHeader}")
if meshHeader.numVerts == 0 or meshHeader.numFaces == 0:
raise Exception(f"read_mesh_v5: empty mesh")
meshData.header = meshHeader
for i in range(0, meshHeader.numVerts):
meshData.vnts.append(FileMeshVertexNormalTexture3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 127, 127, 127, 127, 127))
meshData.vnts[i].read_data(read_data(data, offset + i * 40, 40))
debug_print(f"read_mesh_v5: vnts[{i}]={meshData.vnts[i]}")
offset += meshHeader.numVerts * 40
if meshHeader.numBones > 0:
for i in range(0, meshHeader.numVerts):
temp = Envelope([], [])
temp.read_data(read_data(data, offset + i * 8, 8))
offset += meshHeader.numVerts * 8
for i in range(0, meshHeader.numFaces):
meshData.faces.append(FileMeshFace(0, 0, 0))
meshData.faces[i].read_data(read_data(data, offset + i * 12, 12))
debug_print(f"read_mesh_v5: faces[{i}]={meshData.faces[i]}")
offset += meshHeader.numFaces * 12
# LODs ( sizeof_LOD [ unsigned int ] * numLODs )
meshLODs : list[int] = []
for i in range(0, meshHeader.numLODs):
meshLODs.append(int.from_bytes(read_data(data, offset + i * 4, 4), "little"))
debug_print(f"read_mesh_v5: meshLODs[{i}]={meshLODs[i]}")
offset += meshHeader.numLODs * 4
if len(meshLODs) > 1:
meshData.full_faces = meshData.faces
meshData.faces = meshData.faces[0:meshLODs[1]]
debug_print(f"read_mesh_v5: only keeping {meshLODs[1]}/{meshHeader.numFaces} faces")
meshData.LODs = meshLODs
if meshHeader.numBones > 0:
for i in range(0, meshHeader.numBones):
meshData.bones.append(Bone(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -127, -127, -127, 0, 0))
meshData.bones[i].read_data(read_data(data, offset + i * 60, 60))
debug_print(f"read_mesh_v5: bones[{i}]={meshData.bones[i]}")
offset += meshHeader.numBones * 60
boneNames : str = read_data(data, offset, meshHeader.sizeof_boneNamesBuffer).decode("utf-8")
offset += meshHeader.sizeof_boneNamesBuffer
debug_print(f"read_mesh_v5: boneNames={boneNames}")
meshSubsets : list[MeshSubset] = []
for i in range(0, meshHeader.numSubsets):
meshSubsets.append(MeshSubset(0, 0, 0, 0, 0, []))
meshSubsets[i].read_data(read_data(data, offset + i * 72, 72))
debug_print(f"read_mesh_v5: meshSubsets[{i}]={meshSubsets[i]}")
meshData.meshSubsets = meshSubsets
offset += meshHeader.numSubsets * 72
offset += meshHeader.unusedPadding
offset += meshHeader.facsDataSize # No way I am doing allat
if offset != len(data):
raise Exception(f"read_mesh_v5: unexpected data at end of file ({len(data) - offset} bytes)")
debug_print(f"read_mesh_v5: read {len(meshData.vnts)} vertices and {len(meshData.faces)} faces successfully")
return meshData
def export_mesh_v2( meshData : FileMeshData ) -> bytearray:
if meshData is None:
raise Exception(f"export_mesh_v2: meshData is None")
if len(meshData.vnts) == 0 or len(meshData.faces) == 0:
raise Exception(f"export_mesh_v2: meshData is empty")
finalmesh : bytearray = b""
finalmesh += b"version 2.00\n"
finalmesh += FileMeshHeader(
12,
36 if len(meshData.vnts) > 0 and isinstance(meshData.vnts[0], FileMeshVertexNormalTexture3dNoRGBA) else 40,
12,
len(meshData.vnts),
len(meshData.faces)
).export_data()
for i in range(0, len(meshData.vnts)):
finalmesh += meshData.vnts[i].export_data()
for i in range(0, len(meshData.faces)):
finalmesh += meshData.faces[i].export_data()
return finalmesh
def export_mesh_v3( meshData : FileMeshData ) -> bytearray:
if meshData is None:
raise Exception(f"export_mesh_v3: meshData is None")
if len(meshData.vnts) == 0 or len(meshData.faces) == 0:
raise Exception(f"export_mesh_v3: meshData is empty")
finalmesh : bytearray = b""
finalmesh += b"version 3.00\n"
finalmesh += FileMeshHeaderV3(
16,
40,
12,
4,
len(meshData.LODs),
len(meshData.vnts),
len(meshData.full_faces) if len(meshData.LODs) > 1 and len(meshData.full_faces) > len(meshData.faces) else len(meshData.faces),
).export_data()
for i in range(0, len(meshData.vnts)):
finalmesh += meshData.vnts[i].export_data()
if len(meshData.LODs) > 1 and len(meshData.full_faces) > len(meshData.faces):
for i in range(0, len(meshData.full_faces)):
finalmesh += meshData.full_faces[i].export_data()
else:
for i in range(0, len(meshData.faces)):
finalmesh += meshData.faces[i].export_data()
for i in range(0, len(meshData.LODs)):
finalmesh += meshData.LODs[i].to_bytes(4, "little")
return finalmesh
def read_mesh_data( data : bytearray ) -> FileMeshData:
meshVersion = get_mesh_version(data)
startingOffset = data.find(b"\n")
debug_print(f"meshVersion={meshVersion}, startingOffset={startingOffset}")
if meshVersion == 1.0:
meshData : FileMeshData = read_mesh_v1(data, startingOffset + 1, 0.5)
elif meshVersion == 1.1:
meshData : FileMeshData = read_mesh_v1(data, startingOffset + 1, 1.0, False)
elif meshVersion == 2.0:
meshData : FileMeshData = read_mesh_v2(data, startingOffset + 1)
elif meshVersion == 3.0 or meshVersion == 3.1:
meshData : FileMeshData = read_mesh_v3(data, startingOffset + 1)
elif meshVersion == 4.0 or meshVersion == 4.1:
meshData : FileMeshData = read_mesh_v4(data, startingOffset + 1)
elif meshVersion == 5.0 or meshVersion == 5.1:
meshData : FileMeshData = read_mesh_v5(data, startingOffset + 1)
else:
raise Exception(f"read_mesh_data: unsupported mesh version ({meshVersion})")
return meshData
if __name__ == "__main__":
arguments = sys.argv[1:]
if len(arguments) < 1:
debug_print("Usage: RBXMesh.py <mesh file location>")
exit(1)
meshFile = open(arguments[0], "rb")
meshData = meshFile.read()
meshFile.close()
meshData = read_mesh_data(meshData)
if len(arguments) > 1:
if arguments[1] == "2.0":
with open(f"{arguments[0]}.v2", "wb") as f:
f.write(export_mesh_v2(meshData))
elif arguments[1] == "3.0":
with open(f"{arguments[0]}.v3", "wb") as f:
f.write(export_mesh_v3(meshData))
+146
View File
@@ -0,0 +1,146 @@
import hashlib
from PIL import Image
from app.routes.asset import CreateFakeAsset
from app.routes.thumbnailer import ValidatePlaceFileRequest
from app.enums.PlaceYear import PlaceYear
from app.util import s3helper
import uuid
import time
import json
import os
import logging
import eyed3
import tempfile
import ffmpeg
# TODO: Make this less shit
def ValidateClothingImage( file, verifyResolution = True, validateFileSize = True, returnImage = False ):
try:
file.seek(0, os.SEEK_END)
FileSize = file.tell()
if FileSize > 1024 * 1024 and validateFileSize:
raise Exception("File is larger than 1MB")
ImageObj = Image.open(file)
ImageObj.verify()
if ImageObj.format != "PNG":
raise Exception("File is not a PNG file")
if ImageObj.size != (585, 559) and verifyResolution:
raise Exception("File is not 585 x 559")
if returnImage:
file.seek(0, os.SEEK_END)
ImageObj = Image.open(file).convert("RGBA")
ImageData = list(ImageObj.getdata())
SanitizedImage = Image.new(ImageObj.mode, ImageObj.size)
SanitizedImage.putdata(ImageData)
return SanitizedImage
except Exception as e:
return False
return True
def ValidatePlaceFile( file, keepFileWhenInvalid = False, TestPlaceYear : PlaceYear = PlaceYear.Eighteen, bypassCache : bool = False ):
try:
from app.extensions import redis_controller
file.seek(0, os.SEEK_END)
FileSize = file.tell()
if FileSize > 1024 * 1024 * 30:
raise Exception("File is larger than 30MB")
file.seek(0)
FileContents = file.read()
FileHash = hashlib.sha512(FileContents).hexdigest()
if not bypassCache:
validationResults : int | None = redis_controller.get(f"ValidatePlaceFile:{FileHash}:{TestPlaceYear.value}")
if validationResults is not None:
return validationResults == 1
s3helper.UploadBytesToS3(FileContents, FileHash)
TemporaryAssetId = CreateFakeAsset(AssetName = "AssetValidationService", Expiration = 60 * 10, AssetFileHash = FileHash)
PlaceValidationReqUUID = str(uuid.uuid4())
ValidatePlaceFileRequest( TemporaryAssetId, PlaceValidationReqUUID, TestPlaceYear)
logging.info(f"Sent validation request for '{FileHash}' with UUID {PlaceValidationReqUUID}")
StartTime = time.time()
ResponseInfo = None
while True:
if time.time() - StartTime > 45:
raise Exception("Timed out while waiting for a response from AssetValidation Service")
ResponseInfo = redis_controller.get(f"ValidatePlaceFileRequest:{PlaceValidationReqUUID}")
if ResponseInfo is None:
time.sleep(0.2)
continue
else:
break
if ResponseInfo is not None:
ResponseInfo = json.loads(ResponseInfo)
if ResponseInfo["valid"]:
redis_controller.set(f"ValidatePlaceFile:{FileHash}:{TestPlaceYear.value}", 1, 60 * 60 * 24 * 2)
return True
else:
redis_controller.set(f"ValidatePlaceFile:{FileHash}:{TestPlaceYear.value}", 0, 60 * 60 * 24 * 2)
raise Exception(ResponseInfo["error"])
except Exception as e:
if not keepFileWhenInvalid:
try:
s3helper.DeleteFileFromR2(FileHash)
except:
pass
return str(e)
return True
def ValidateMP3File( file ) -> int | None:
"""
Validates an MP3 file and returns duration in seconds
"""
TemporaryFile = tempfile.NamedTemporaryFile(suffix=".mp3", delete=True)
file.seek(0)
TemporaryFile.write(file.read())
try:
AudioFile = eyed3.load(TemporaryFile.name)
if AudioFile is None:
return None
TemporaryFile.close()
return AudioFile.info.time_secs
except Exception as e:
logging.error(f"Failed to validate MP3 file: {e}")
return None
def ValidateMP3AndConvertToOGG( file ) -> (bytes, int):
"""
Validates given file is a MP3 File and then converts it into the OGG format and returns the bytes and length of the sound in seconds
"""
TemporaryFile = tempfile.NamedTemporaryFile(suffix=".mp3", delete=True)
OutputTemporaryFile = tempfile.NamedTemporaryFile(suffix=".ogg", delete=True)
try:
file.seek(0)
TemporaryFile.write(file.read())
AudioFile = eyed3.load(TemporaryFile.name)
if AudioFile is None:
TemporaryFile.close()
raise Exception("Failed to load MP3 file")
FFmpegStream = ffmpeg.input(TemporaryFile.name, vn=None)
FFmpegStream = ffmpeg.output(FFmpegStream, OutputTemporaryFile.name, acodec="libvorbis", f="ogg")
ffmpeg.run(FFmpegStream, overwrite_output = True, quiet = True, capture_stdout = False, capture_stderr = False)
OutputTemporaryFile.seek(0)
OutputFileBytes = OutputTemporaryFile.read()
ProbeResult = ffmpeg.probe(OutputTemporaryFile.name, select_streams="a")
AudioDuration = float(ProbeResult["streams"][0]["duration"])
OutputTemporaryFile.close()
TemporaryFile.close()
return OutputFileBytes, AudioDuration
except Exception as e:
logging.error(f"Failed to validate MP3 file: {e}")
TemporaryFile.close()
OutputTemporaryFile.close()
raise e
+62
View File
@@ -0,0 +1,62 @@
from app.models.asset import Asset
from app.models.asset_version import AssetVersion
from app.models.user import User
from datetime import datetime
from app.extensions import db
from app.util import auth, redislock
def CreateNewAssetVersion( Asset : Asset, FileHash : str, ForceNewVersion : bool = False, UploadedBy : User | None = None ) -> AssetVersion:
"""
Creates a new asset version for the given asset
Returns the new asset version
"""
AssetLockName = f"createassetversion_{Asset.id}"
CreateLock = redislock.acquire_lock(AssetLockName, acquire_timeout=20, lock_timeout=1)
if CreateLock is None:
return None
ExistingAssetVersion : AssetVersion = AssetVersion.query.filter_by( asset_id=Asset.id ).first()
if ExistingAssetVersion is None:
NewAssetVersion : AssetVersion = AssetVersion(
asset_id = Asset.id,
version = 1,
content_hash = FileHash,
created_at = datetime.utcnow(),
uploaded_by = UploadedBy.id if UploadedBy is not None else None
)
db.session.add(NewAssetVersion)
db.session.commit()
redislock.release_lock(AssetLockName, CreateLock)
return NewAssetVersion
ExistingAssetVersionWithSameHash : AssetVersion = AssetVersion.query.filter_by( asset_id=Asset.id, content_hash=FileHash ).first()
if ExistingAssetVersionWithSameHash is not None and not ForceNewVersion:
redislock.release_lock(AssetLockName, CreateLock)
return ExistingAssetVersionWithSameHash
CurrentVersion : int = AssetVersion.query.filter_by( asset_id=Asset.id ).order_by(AssetVersion.version.desc()).first().version
NewAssetVersion : AssetVersion = AssetVersion(
asset_id = Asset.id,
version = CurrentVersion + 1,
content_hash = FileHash,
created_at = datetime.utcnow(),
uploaded_by = UploadedBy.id if UploadedBy is not None else None
)
db.session.add(NewAssetVersion)
db.session.commit()
redislock.release_lock(AssetLockName, CreateLock)
return NewAssetVersion
def GetLatestAssetVersion( Asset : Asset ) -> AssetVersion:
"""
Gets the latest asset version for the given asset
Returns the latest asset version
"""
return AssetVersion.query.filter_by( asset_id=Asset.id ).order_by(AssetVersion.version.desc()).first()
def GetAssetVersion( Asset : Asset, Version : int ) -> AssetVersion:
"""
Gets the asset version for the given asset and version
Returns the asset version
"""
return AssetVersion.query.filter_by( asset_id=Asset.id, version=Version ).first()
+271
View File
@@ -0,0 +1,271 @@
import hashlib
import logging
import time
import string
import secrets
import pyotp
from argon2 import PasswordHasher
from config import Config
from flask import request, redirect, jsonify, make_response, abort, g
from functools import wraps
from app.models.user import User
from app.models.placeservers import PlaceServer
from app.models.gameservers import GameServer
from app.extensions import db, redis_controller, get_remote_address
config = Config()
def ValidateToken( token : str ) -> bool:
TokenInfo = redis_controller.get("authtoken_" + token)
if TokenInfo is None:
return False
# Token Info Format
# userid|created|expiry|ip
TokenInfo = TokenInfo.split("|")
if len(TokenInfo) != 4:
redis_controller.delete("authtoken_" + token)
return False
if int(TokenInfo[2]) < int(time.time()):
redis_controller.delete("authtoken_" + token)
return False
return True
def GetAuthenticatedUser( token : str ) -> User:
AuthTokenInfo = GetTokenInfo(token)
if AuthTokenInfo is None:
return None
return User.query.filter_by(id=int(AuthTokenInfo[0])).first()
def GetTokenInfo( token : str ) -> list:
TokenInfo = redis_controller.get("authtoken_" + token)
if TokenInfo is None:
return None
# Token Info Format
# userid|created|expiry|ip
TokenInfo = TokenInfo.split("|")
if len(TokenInfo) != 4:
redis_controller.delete("authtoken_" + token)
return None
if int(TokenInfo[2]) < int(time.time()):
redis_controller.delete("authtoken_" + token)
return None
return TokenInfo
def CreateToken( userid : int , ip, expireIn : int = (60 * 60 * 24 * 31)) -> str:
Token = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(512))
# Token Info Format
# userid|created|expiry|ip
TokenInfo = str(userid) + "|" + str(int(time.time())) + "|" + str(int(time.time()) + expireIn) + "|" + ip
redis_controller.set("authtoken_" + Token, TokenInfo, ex = expireIn)
return Token
def isAuthenticated():
if ".ROBLOSECURITY" not in request.cookies:
return False
if not ValidateToken(request.cookies[".ROBLOSECURITY"]):
return False
return True
def authenticated_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if ".ROBLOSECURITY" not in request.cookies:
return redirect("/login")
if not ValidateToken(request.cookies[".ROBLOSECURITY"]):
RedirectResponse = make_response(redirect("/login"))
RedirectResponse.set_cookie(".ROBLOSECURITY", expires=0)
return RedirectResponse
return f(*args, **kwargs)
return decorated_function
def authenticated_required_api(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if ".ROBLOSECURITY" not in request.cookies:
return jsonify({"success": False, "message": "You are not logged in"}), 401
if not ValidateToken(request.cookies[".ROBLOSECURITY"]):
ErrorResponse = make_response(jsonify({"success": False, "message": "You are not logged in"}), 401)
ErrorResponse.set_cookie(".ROBLOSECURITY", expires=0)
return ErrorResponse
return f(*args, **kwargs)
return decorated_function
def authenticated_client_endpoint(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if request.cookies.get("Syntax-Session-Id", type = str ) is not None:
DataSections = request.cookies.get("Syntax-Session-Id", type = str )
else:
return jsonify({"success": False, 'error': 'Missing Headers'}), 401
if len(DataSections) != 9:
return jsonify({"success": False, 'error': 'Invalid Headers'}), 401
AuthToken = DataSections[8]
if not ValidateToken(AuthToken):
return jsonify({"success": False, 'error': 'Invalid Token'}), 401
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=str(DataSections[1])).first()
if PlaceServerObj is None:
invalidateToken(AuthToken)
return jsonify({"success": False, 'error': 'Invalid Token'}), 401
return f(*args, **kwargs)
return decorated_function
def gameserver_authenticated_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if "Roblox/" not in request.user_agent.string:
abort(404)
RemoteAddress = get_remote_address()
GameServerObj : GameServer = GameServer.query.filter_by( serverIP = RemoteAddress ).first()
if GameServerObj is None:
abort(404)
requesterAccessKey = request.headers.get( key = "AccessKey", type = str, default = "")
if "UserRequest" in requesterAccessKey:
logging.warning(f"GameServer {RemoteAddress} - UserRequest access key used for {request.url}")
abort(404)
return f(*args, **kwargs)
return decorated_function
def gameserver_accesskey_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if "AccessKey" not in request.headers:
abort(404)
RequesterAccessKey = request.headers.get( key = "AccessKey", type = str, default = "")
RemoteAddress = get_remote_address()
GameServerObj : GameServer = GameServer.query.filter_by( serverIP = RemoteAddress, accessKey = RequesterAccessKey ).first()
if GameServerObj is None:
abort(404)
return f(*args, **kwargs)
return decorated_function
def invalidateToken(token : str):
if token is not None:
redis_controller.delete("authtoken_" + token)
def Validate2FACode( userid : int, code : str ) -> bool:
from app.pages.settings.settings import generate_secret_key_from_string
"""
Validates a 2FA code for a user
:param userid: The user id to validate the code for
:param code: The code to validate
:returns: bool (True if correct, False if not)
"""
user : User = User.query.filter_by(id=userid).first()
if user is None:
return False
if user.TOTPEnabled == False:
return False
totp = pyotp.TOTP(generate_secret_key_from_string(str(user.id) + config.FLASK_SESSION_KEY))
return totp.verify(code)
def GetCurrentUser() -> User:
"""
Gets the current user from the request
:returns: User (User object if logged in, None if not)
"""
def _get_user() -> User:
if ".ROBLOSECURITY" not in request.cookies:
if "Syntax-Session-Id" not in request.cookies:
return None
else:
DataSections = request.cookies["Syntax-Session-Id"].split("|")
if len(DataSections) != 9:
return None
AuthToken = DataSections[8]
if not ValidateToken(AuthToken):
return None
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=str(DataSections[1])).first()
if PlaceServerObj is None:
invalidateToken(AuthToken)
return None
AuthTokenInfo = GetTokenInfo(AuthToken)
return User.query.filter_by(id=int(AuthTokenInfo[0])).first()
if not ValidateToken(request.cookies[".ROBLOSECURITY"]):
return None
AuthTokenInfo = GetTokenInfo(request.cookies[".ROBLOSECURITY"])
if AuthTokenInfo is None:
return None
return User.query.filter_by(id=int(AuthTokenInfo[0])).first()
if not hasattr(g, 'current_authenticated_user'):
g.current_authenticated_user = _get_user()
return g.current_authenticated_user
def _GetArgonSalt( UserObj : User ) -> bytes:
return (config.FLASK_SESSION_KEY + str(UserObj.id)).encode('utf-8')
def _GetPasswordHasher() -> PasswordHasher:
return PasswordHasher(
time_cost=16,
memory_cost=2**14,
parallelism=2,
hash_len=32,
salt_len=16
)
def VerifyPassword( UserObj : User, password : str ) -> bool:
"""
Verifies the password of the given user
:param UserObj: The user object to verify the password of
:param password: The password to verify
:returns: bool (True if correct, False if not)
"""
if config.SWITCH_TO_ARGON_PASSWORD_HASH: # Switch this when we migrate to argon
argon_ph = _GetPasswordHasher()
if not UserObj.password.startswith("$argon2id"): # Handle old passwords in sha512
isCorrect = hashlib.sha512(password.encode('utf-8')).hexdigest() == UserObj.password
if isCorrect:
UserObj.password = PasswordHasher().hash(password, salt = _GetArgonSalt(UserObj) )
logging.info(f"User {UserObj.username} [{UserObj.id}] migrated to argon2id password hash")
db.session.commit()
return isCorrect
try:
return argon_ph.verify(UserObj.password, password)
except:
return False
else:
return hashlib.sha512(password.encode('utf-8')).hexdigest() == UserObj.password
def SetPassword( UserObj : User, password : str ):
"""
Sets the password of the given user
:param UserObj: The user object to set the password of
:param password: The password to set
:returns: bool (True if successful, False if not)
"""
if config.SWITCH_TO_ARGON_PASSWORD_HASH: # Switch this when we migrate to argon
argon_ph = _GetPasswordHasher()
UserObj.password = argon_ph.hash(password, salt = _GetArgonSalt(UserObj) )
else:
UserObj.password = hashlib.sha512(password.encode('utf-8')).hexdigest()
db.session.commit()
return True
+90
View File
@@ -0,0 +1,90 @@
BadWords = [
"nigger",
"nigga",
"niggers",
"niggas",
"niggs",
"niger",
"faggot",
"fag",
"nigg",
"nig",
"sex",
"cp",
"child",
"porn",
"dick",
"penis",
"nig/ger",
"nig/ga",
"rape",
"retard",
"retarded",
"retards",
"isis",
"allahu",
"akbar",
"beastiality",
"buttfuck",
"circlejerk",
"cum",
"cummer",
"cumming",
"cumshot",
"dicks",
"dildo",
"ejaculating",
"ejaculation",
"ejaculat",
"fuk",
"gangbang",
"gay",
"horny",
"pussy",
"vagina",
"jackoff",
"kkk",
"jizz",
"kum",
"molest",
"molester",
"pedo",
"pedophile",
"pissing",
"piss",
"pornography",
"sperm",
"cock",
"69420",
"6969",
"69 69",
"420",
"hentai",
"yaoi",
"boobs",
"boob",
"nipples",
"nipple",
"negro",
"n1gg3r",
".ct8",
"ct8",
".pl",
"hitler",
"borderhopper",
"border hopper",
"nigar"
]
ExtendedBadWords = [
"fuck",
"fucking",
"fucks",
"shit",
"horny",
" fu.ck ",
" fuc.k ",
" f.u.c.k ",
" f.uck "
]
+122
View File
@@ -0,0 +1,122 @@
import requests
from config import Config
import urllib
config = Config()
class DiscordUserInfo:
UserId : int = None
Username : str = None
Discriminator : str = None
AvatarHash : str = None
GlobalName : str = None
def __init__(self, UserId : int, Username : str, AvatarHash : str, GlobalName : str = None, Discriminator : str = "0000") -> None:
self.UserId = UserId
self.Username = Username
self.Discriminator = Discriminator
self.AvatarHash = AvatarHash
self.GlobalName = GlobalName
def GetAvatarURL(self) -> str:
"""
GetAvatarURL returns the URL to the user's avatar.
"""
if self.AvatarHash is None:
return f"https://cdn.discordapp.com/embed/avatars/{str(int(self.Discriminator) % 5)}.png"
return f"https://cdn.discordapp.com/avatars/{str(self.UserId)}/{self.AvatarHash}.png"
def __repr__(self) -> str:
return f"<DiscordUserInfo {self.UserId} {self.Username}#{self.Discriminator}>"
class UnexpectedStatusCode(Exception):
pass
class MissingScope(Exception):
pass
def ExchangeCodeForToken( AccessCode : str ) -> dict:
"""
ExchangeCodeForToken exchanges a Discord OAuth2 access code for a Discord OAuth2 token.
"""
DiscordOAuth2TokenExchangeURL = "https://discord.com/api/oauth2/token"
DiscordOAuth2TokenExchangeHeaders = {
"Content-Type": "application/x-www-form-urlencoded"
}
DiscordOAuth2TokenExchangeData = {
"client_id": config.DISCORD_CLIENT_ID,
"client_secret": config.DISCORD_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": AccessCode,
"redirect_uri": config.DISCORD_REDIRECT_URI,
"scope": "identify"
}
DiscordOAuth2TokenExchangeResponse = requests.post(DiscordOAuth2TokenExchangeURL, headers=DiscordOAuth2TokenExchangeHeaders, data=DiscordOAuth2TokenExchangeData)
DiscordOAuth2TokenExchangeResponseJSON = DiscordOAuth2TokenExchangeResponse.json()
if DiscordOAuth2TokenExchangeResponse.status_code != 200:
raise UnexpectedStatusCode(f"Unexpected status code {DiscordOAuth2TokenExchangeResponse.status_code} when exchanging code for token.")
# Make sure "identify" is in the scope
if "identify" not in DiscordOAuth2TokenExchangeResponseJSON["scope"]:
raise MissingScope("Missing scope 'identify' when exchanging code for token.")
if "guilds.join" not in DiscordOAuth2TokenExchangeResponseJSON["scope"]:
raise MissingScope("Missing scope 'guilds.join' when refreshing token.")
return DiscordOAuth2TokenExchangeResponseJSON
def GetUserInfoFromToken( AccessToken : str ) -> DiscordUserInfo:
"""
GetUserInfoFromToken gets a user's Discord user info from their access token.
"""
DiscordOAuth2GetUserInfoURL = "https://discord.com/api/users/@me"
DiscordOAuth2GetUserInfoHeaders = {
"Authorization": f"Bearer {AccessToken}"
}
DiscordOAuth2GetUserInfoResponse = requests.get(DiscordOAuth2GetUserInfoURL, headers=DiscordOAuth2GetUserInfoHeaders)
DiscordOAuth2GetUserInfoResponseJSON = DiscordOAuth2GetUserInfoResponse.json()
if DiscordOAuth2GetUserInfoResponse.status_code != 200:
raise UnexpectedStatusCode(f"Unexpected status code {DiscordOAuth2GetUserInfoResponse.status_code} when getting user info from token.")
return DiscordUserInfo(
UserId = DiscordOAuth2GetUserInfoResponseJSON["id"],
Username = DiscordOAuth2GetUserInfoResponseJSON["username"],
Discriminator = DiscordOAuth2GetUserInfoResponseJSON["discriminator"],
AvatarHash = DiscordOAuth2GetUserInfoResponseJSON["avatar"],
GlobalName = DiscordOAuth2GetUserInfoResponseJSON["global_name"]
)
def GenerateAuthorizationURL( State : str ) -> str:
"""
GenerateAuthorizationURL generates a Discord OAuth2 authorization URL.
"""
DiscordOAuth2AuthorizationURL = config.DISCORD_AUTHORIZATION_BASE_URL
DiscordOAuth2AuthorizationURLParams = {
"client_id": config.DISCORD_CLIENT_ID,
"redirect_uri": config.DISCORD_REDIRECT_URI,
"response_type": "code",
"scope": "identify guilds.join",
"state": State
}
# Format the URL
DiscordOAuth2AuthorizationURL = f"{DiscordOAuth2AuthorizationURL}?{urllib.parse.urlencode(DiscordOAuth2AuthorizationURLParams)}"
return DiscordOAuth2AuthorizationURL
def RefreshAccessToken( RefreshToken : str ) -> dict:
"""
RefreshAccessToken refreshes a Discord OAuth2 access token from a refresh token.
"""
DiscordOAuth2TokenRefreshData = {
"client_id": config.DISCORD_CLIENT_ID,
"client_secret": config.DISCORD_CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": RefreshToken
}
DiscordOAuth2TokenRefreshURL = "https://discord.com/api/oauth2/token"
DiscordOAuth2TokenRefreshHeaders = {
"Content-Type": "application/x-www-form-urlencoded"
}
DiscordOAuth2TokenRefreshResponse = requests.post(DiscordOAuth2TokenRefreshURL, headers=DiscordOAuth2TokenRefreshHeaders, data=DiscordOAuth2TokenRefreshData)
DiscordOAuth2TokenRefreshResponseJSON = DiscordOAuth2TokenRefreshResponse.json()
if DiscordOAuth2TokenRefreshResponse.status_code != 200:
raise UnexpectedStatusCode(f"Unexpected status code {DiscordOAuth2TokenRefreshResponse.status_code} when refreshing token.")
if "identify" not in DiscordOAuth2TokenRefreshResponseJSON["scope"]:
raise MissingScope("Missing scope 'identify' when refreshing token.")
if "guilds.join" not in DiscordOAuth2TokenRefreshResponseJSON["scope"]:
raise MissingScope("Missing scope 'guilds.join' when refreshing token.")
return DiscordOAuth2TokenRefreshResponseJSON
+5
View File
@@ -0,0 +1,5 @@
ReplaceWith = "#"
TranslateEnabled = False
TranslateTo = "Chinese"
BlockEnglish = False
FilterEnabled = True
+80
View File
@@ -0,0 +1,80 @@
from app.models.friend_relationship import FriendRelationship
from app.models.friend_request import FriendRequest
from app.models.user import User
from app.extensions import db
from app.util import auth
"""
DEPRECATED
Everything should be moved to app/services/user_relationships/friends.py
"""
def GetFriends( userId : int ) -> list[User]:
"""
Returns a list of friends for the user
:param userId: The user's id
:return: A list of friends for the user
"""
friends : list[FriendRelationship] = FriendRelationship.query.filter((FriendRelationship.user_id == userId) | (FriendRelationship.friend_id == userId)).all()
if friends is None:
return []
friendList = []
for friend in friends:
OtherUserId = friend.user_id if friend.user_id != userId else friend.friend_id
OtherUser = User.query.filter_by(id=OtherUserId).first()
if OtherUser is None:
continue
if OtherUser in friendList:
# This should never happen, but just in case
db.session.delete(friend)
db.session.commit()
continue
friendList.append(OtherUser)
return friendList
def GetFriendRelationship(userId, otherUserId):
friends = FriendRelationship.query.filter((FriendRelationship.user_id == userId) | (FriendRelationship.friend_id == userId)).all()
if friends is None:
return None
for friend in friends:
OtherUserId = friend.user_id if friend.user_id != userId else friend.friend_id
if OtherUserId == otherUserId:
return friend
return None
def IsFriends(userId, otherUserId):
friends = FriendRelationship.query.filter((FriendRelationship.user_id == userId) | (FriendRelationship.friend_id == userId)).all()
if friends is None:
return False
for friend in friends:
OtherUserId = friend.user_id if friend.user_id != userId else friend.friend_id
if OtherUserId == otherUserId:
return True
return False
def GetFriendRequests(userId):
friendRequests = FriendRequest.query.filter_by(requestee_id=userId).all()
if friendRequests is None:
return []
friendRequestList = []
for friendRequest in friendRequests:
OtherUser = User.query.filter_by(id=friendRequest.requester_id).first()
if OtherUser is None:
continue
friendRequestList.append(OtherUser)
return friendRequestList
def GetFriendCount(userId):
return FriendRelationship.query.filter((FriendRelationship.user_id == userId) | (FriendRelationship.friend_id == userId)).count()
+167
View File
@@ -0,0 +1,167 @@
from app.models.user_membership import UserMembership
from app.enums.MembershipType import MembershipType
from app.extensions import db
from app.models.user import User
from datetime import datetime, timedelta
class UserHasHigherMembershipException(Exception):
"""
Raised when a user has a higher membership than the one they are trying to be given.
"""
pass
class UserDoesNotExistException(Exception):
"""
Raised when a user does not exist.
"""
pass
class UserDoesNotHaveMembershipException(Exception):
"""
Raised when a user does not have a membership.
"""
pass
def GetUserFromId( TargetUser : User | int ) -> User | None:
"""
Gets a user from their ID.
:param TargetUser: The user to get.
:returns: User | None (The user, None if the user does not exist)
"""
if type(TargetUser) is int:
TargetUser = User.query.filter_by(id=TargetUser).first()
return TargetUser
def GetUserMembership( TargetUser : User | int, changeToString : bool = False) -> MembershipType | None | str:
"""
Gets the membership of a user.
:param TargetUser: The user to get the membership of.
:param changeToString: Whether to change the membership enum to a string or not.
:returns: MembershipType | None | str (The membership of the user, None if the user does not exist, "None" if the user has no membership, or a string if changeToString is True)
"""
TargetUser = GetUserFromId(TargetUser)
if TargetUser is None:
return None
MembershipObj : UserMembership = UserMembership.query.filter_by(user_id=TargetUser.id).first()
if MembershipObj is None:
MembershipObj : UserMembership = UserMembership(
TargetUser.id,
MembershipType.NonBuildersClub,
None,
None
)
db.session.add(MembershipObj)
db.session.commit()
if MembershipObj.expiration is not None and MembershipObj.expiration < datetime.utcnow():
MembershipObj.membership_type = MembershipType.NonBuildersClub
MembershipObj.expiration = None
MembershipObj.next_stipend = None
MembershipObj.created = None
db.session.commit()
if changeToString:
if MembershipObj.membership_type == MembershipType.NonBuildersClub:
return "None"
return MembershipObj.membership_type.name
return MembershipObj.membership_type
def GiveUserMembership( TargetUser : User | int, Membership : MembershipType, expiration : timedelta = timedelta(days=31),ForceMembership : bool = False, throwException : bool = True, incrementExpirationIfSame : bool = True) -> None:
"""
Gives a user a membership.
:param TargetUser: The user to give the membership to.
:param Membership: The membership to give the user.
:param expiration: The expiration of the membership.
:param ForceMembership: Whether to force the membership or not.
:param throwException: Whether to throw an exception or not.
:param incrementExpirationIfSame: Whether to increment the expiration if the user already has the membership.
:returns: None
"""
TargetUser = GetUserFromId(TargetUser)
if TargetUser is None:
return None
CurrentMembership : MembershipType = GetUserMembership(TargetUser)
if CurrentMembership is None:
if throwException:
raise UserDoesNotExistException("User does not exist.")
return
if CurrentMembership == Membership:
if incrementExpirationIfSame:
MembershipObj : UserMembership = UserMembership.query.filter_by(user_id=TargetUser.id).first()
if MembershipObj is None:
raise Exception("Membership object does not exist, this should never happen.")
if MembershipObj.expiration is None:
return
MembershipObj.expiration = MembershipObj.expiration + expiration
db.session.commit()
return
if CurrentMembership.value > Membership.value and not ForceMembership:
if throwException:
raise UserHasHigherMembershipException("User has a higher membership than the one they are trying to be given.")
return
MembershipObj : UserMembership = UserMembership.query.filter_by(user_id=TargetUser.id).first()
if MembershipObj is None:
raise Exception("Membership object does not exist, this should never happen.")
MembershipObj.membership_type = Membership
MembershipObj.created = datetime.utcnow()
MembershipObj.expiration = datetime.utcnow() + expiration
MembershipObj.next_stipend = datetime.utcnow()
db.session.commit()
return
def RemoveUserMembership( TargetUser : User | int ) -> None:
"""
Removes a user's membership.
:param TargetUser: The user to remove the membership of.
:returns: None
"""
TargetUser = GetUserFromId(TargetUser)
if TargetUser is None:
return None
CurrentMembership : MembershipType = GetUserMembership(TargetUser)
if CurrentMembership is MembershipType.NonBuildersClub:
return
MembershipObj : UserMembership = UserMembership.query.filter_by(user_id=TargetUser.id).first()
if MembershipObj is None:
raise Exception("Membership object does not exist, this should never happen.")
MembershipObj.membership_type = MembershipType.NonBuildersClub
MembershipObj.created = None
MembershipObj.expiration = None
MembershipObj.next_stipend = None
db.session.commit()
return
def IncrementExpirationLength( TargetUser : User | int, Length : timedelta = timedelta(days=31) ) -> None:
"""
Increments the expiration length of a user's membership.
:param TargetUser: The user to increment the expiration length of.
:param Length: The length to increment the expiration by.
:returns: None
"""
TargetUser : User = GetUserFromId(TargetUser)
if TargetUser is None:
return None
CurrentMembership : MembershipType = GetUserMembership(TargetUser)
if CurrentMembership is None:
raise UserDoesNotExistException("User does not exist.")
if CurrentMembership == MembershipType.NonBuildersClub:
raise UserDoesNotHaveMembershipException("User does not have a membership.")
MembershipObj : UserMembership = UserMembership.query.filter_by(user_id=TargetUser.id).first()
if MembershipObj is None:
raise Exception("Membership object does not exist, this should never happen.")
if MembershipObj.expiration is None:
raise Exception("Membership object does not have an expiration, this should never happen.")
MembershipObj.expiration = MembershipObj.expiration + Length
db.session.commit()
return
+74
View File
@@ -0,0 +1,74 @@
from app.extensions import redis_controller, db
from app.models.place import Place
from app.models.placeservers import PlaceServer
from app.models.placeserver_players import PlaceServerPlayer
from app.models.universe import Universe
from sqlalchemy import func
def ClearPlayingCountCache( PlaceObj : Place ):
"""
Clears the cache for the amount of players playing a place
:param PlaceObj: The place to clear the cache for
:returns: None
"""
redis_controller.delete(f"place:{str(PlaceObj.placeid)}:playercount:placeinfo")
redis_controller.delete(f"universe:{str(PlaceObj.parent_universe_id)}:playercount:placeinfo")
def ClearUniversePlayingCountCache( UniverseObj : Universe ):
"""
Clears the cache for the amount of players playing in a universe
:param UniverseObj: The universe to clear the cache for
:returns: None
"""
redis_controller.delete(f"universe:{str(UniverseObj.id)}:playercount:placeinfo")
def GetPlayingCount( PlaceObj : Place, IgnoreCache = False ) -> int:
"""
Returns the amount of players playing a place
:param PlaceObj: The place to get the amount of players playing
:param IgnoreCache: Whether to ignore the cache or not
:returns: int (The amount of players playing a place)
"""
if not IgnoreCache:
PlayingCount = redis_controller.get(f"place:{str(PlaceObj.placeid)}:playercount:placeinfo")
if PlayingCount is not None:
return int(PlayingCount)
PlayingCount = 0
# Check if there even is a place server
PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serverPlaceId=PlaceObj.placeid).first()
if PlaceServerObj is not None:
# Get all place servers with the same placeid
PlaceServerObjs = PlaceServer.query.filter_by(serverPlaceId=PlaceObj.placeid).all()
for PlaceServerObj in PlaceServerObjs:
PlayingCount += PlaceServerObj.playerCount
redis_controller.set(f"place:{str(PlaceObj.placeid)}:playercount:placeinfo", PlayingCount, ex=60)
return PlayingCount
def GetUniversePlayingCount( UniverseObj : Universe, IgnoreCache : bool = False ) -> int:
"""
Returns the amount of players playing in a universe
:param UniverseObj: The universe to get the amount of players playing
:param IgnoreCache: Whether to ignore the cache or not
:returns: int (The amount of players playing in a universe)
"""
if not IgnoreCache:
PlayingCount = redis_controller.get(f"universe:{str(UniverseObj.id)}:playercount:placeinfo")
if PlayingCount is not None:
return int(PlayingCount)
PlayingCount = PlaceServer.query.join(Place, PlaceServer.serverPlaceId == Place.placeid ).filter(Place.parent_universe_id == UniverseObj.id).all()
PlayingCount = sum([PlaceServerObj.playerCount for PlaceServerObj in PlayingCount])
redis_controller.set(f"universe:{str(UniverseObj.id)}:playercount:placeinfo", PlayingCount, ex=60)
return PlayingCount
+24
View File
@@ -0,0 +1,24 @@
from app.extensions import redis_controller
import time
import redis
# DEPRECATED - DO NOT USE ANYMORE - USE REDIS_LOCKS INSTEAD
def acquire_lock(lock_name, acquire_timeout=10, lock_timeout=60):
"""Acquires a Redis lock with a specific name."""
identifier = str(time.time())
end_time = time.time() + acquire_timeout
while time.time() < end_time:
if redis_controller.set(lock_name, identifier, nx=True, ex=lock_timeout):
return identifier
time.sleep(0.001)
return None
def release_lock(lock_name, identifier):
"""Releases a Redis lock with a specific name and identifier."""
if redis_controller.get(lock_name) == identifier:
redis_controller.delete(lock_name)
return True
return False
+175
View File
@@ -0,0 +1,175 @@
import boto3
import hashlib
import os
import logging
from app.extensions import redis_controller
from config import Config
Config = Config()
def getS3Client():
"""
Returns a boto3 S3 client object.
"""
return boto3.client(
"s3",
aws_access_key_id = Config.AWS_ACCESS_KEY,
aws_secret_access_key = Config.AWS_SECRET_KEY,
region_name = Config.AWS_REGION_NAME
)
def UploadBytesToS3( fileContent : bytes, nameOverwrite : str | None = None, bucketOverwrite : str | None = Config.AWS_S3_BUCKET_NAME, contentType : str = "application/octet-stream", bypassExistCheck : bool = False) -> str:
"""
Uploads a file to S3, returning the URL of the file.
:param fileContent: The bytes of the file to upload
:param nameOverwrite: The name of the file to overwrite
:param bucketOverwrite: The bucket to overwrite
:param contentType: The content type of the file
:param bypassExistCheck: Whether to bypass the redis cache or not
:returns: str (The URL of the file)
"""
if nameOverwrite is None:
nameOverwrite = hashlib.sha512( fileContent ).hexdigest()
if Config.USE_LOCAL_STORAGE:
if not os.path.exists(Config.AWS_S3_DOWNLOAD_CACHE_DIR + "/" + nameOverwrite):
with open(Config.AWS_S3_DOWNLOAD_CACHE_DIR + "/" + nameOverwrite, "wb") as f:
f.write(fileContent)
return f"{Config.CDN_URL}/{nameOverwrite}"
if not bypassExistCheck:
if redis_controller.exists( f"DoesKeyExist:{bucketOverwrite}:{nameOverwrite}" ):
return f"{Config.CDN_URL}/{nameOverwrite}"
s3Client = getS3Client()
s3Client.put_object(
Body = fileContent,
Bucket = bucketOverwrite,
Key = nameOverwrite,
ContentType = contentType
)
redis_controller.set( f"DoesKeyExist:{bucketOverwrite}:{nameOverwrite}", "1", 60 * 60 * 24 * 7 )
return f"{Config.CDN_URL}/{nameOverwrite}"
def UploadFileToS3( filePath : str, nameOverwrite : str | None = None, bucketOverwrite : str | None = Config.AWS_S3_BUCKET_NAME ) -> str:
"""
Uploads a file to S3, returning the URL of the file.
:param filePath: The path of the file to upload
:param nameOverwrite: The name of the file to overwrite
:param bucketOverwrite: The bucket to overwrite
:returns: str (The URL of the file)
"""
if nameOverwrite is None:
nameOverwrite = hashlib.sha512( open( filePath, "rb" ).read() ).hexdigest()
if Config.USE_LOCAL_STORAGE:
FileData = open(filePath, "rb").read()
return UploadBytesToS3(FileData, nameOverwrite, bucketOverwrite, bypassExistCheck=True)
s3Client = getS3Client()
s3Client.upload_file(
filePath,
bucketOverwrite,
nameOverwrite
)
redis_controller.set( f"DoesKeyExist:{bucketOverwrite}:{nameOverwrite}", "1", 60 * 60 * 24 * 7 )
return f"{Config.CDN_URL}/{nameOverwrite}"
def DeleteFileFromS3( fileName : str, bucketOverwrite : str | None = Config.AWS_S3_BUCKET_NAME ) -> bool:
"""
Deletes a file from S3.
:param fileName: The name of the file to delete
:param bucketOverwrite: The bucket to overwrite
:returns: bool (Whether the file was deleted or not)
"""
if Config.USE_LOCAL_STORAGE:
if os.path.exists(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{fileName}"):
os.remove(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{fileName}")
return True
s3Client = getS3Client()
s3Client.delete_object(
Bucket = bucketOverwrite,
Key = fileName
)
redis_controller.delete( f"DoesKeyExist:{bucketOverwrite}:{fileName}" )
return True
def GetFileFromS3( fileName : str, bucketOverwrite : str | None = Config.AWS_S3_BUCKET_NAME, skipDownloadCache : bool = False ) -> bytes | None:
"""
Gets a file from S3.
:param fileName: The name of the file to get
:param bucketOverwrite: The bucket to overwrite
:param skipDownloadCache: Whether to skip the download cache or not
:returns: bytes (The bytes of the file)
"""
if not skipDownloadCache or Config.USE_LOCAL_STORAGE:
if redis_controller.exists(f"DownloadCache:{bucketOverwrite}:{fileName}") and os.path.exists(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{fileName}"):
return open(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{fileName}", "rb").read()
if Config.USE_LOCAL_STORAGE:
return None
s3Client = getS3Client()
try:
FileBytes = s3Client.get_object(
Bucket = bucketOverwrite,
Key = fileName
)['Body'].read()
except Exception as e:
logging.error(f"S3Helper.GetFileFromS3 / Exception raised when fetching file from S3 Bucket [ {bucketOverwrite} ] with name [ {fileName} ] / {e}]")
return None
if not os.path.exists(Config.AWS_S3_DOWNLOAD_CACHE_DIR):
os.mkdir(Config.AWS_S3_DOWNLOAD_CACHE_DIR)
with open(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{fileName}", "wb") as f:
f.write(FileBytes)
redis_controller.set(f"DownloadCache:{bucketOverwrite}:{fileName}", "1", Config.AWS_S3_CACHE_LIFETIME)
return FileBytes
def DoesKeyExist( key : str, bucketOverwrite : str | None = Config.AWS_S3_BUCKET_NAME, bypassCache : bool = False ) -> bool:
"""
Returns whether or not a key exists in S3.
:param key: The key to check
:param bucketOverwrite: The bucket to overwrite
:param bypassCache: Whether to bypass the redis cache or not
:returns: bool (Whether the key exists or not)
"""
if Config.USE_LOCAL_STORAGE:
return os.path.exists(f"{Config.AWS_S3_DOWNLOAD_CACHE_DIR}/{key}")
if not bypassCache:
if redis_controller.exists( f"DoesKeyExist:{bucketOverwrite}:{key}" ):
return True
s3Client = getS3Client()
try:
s3Client.head_object(
Bucket = bucketOverwrite,
Key = key
)
except:
redis_controller.delete( f"DoesKeyExist:{bucketOverwrite}:{key}" )
return False
redis_controller.set( f"DoesKeyExist:{bucketOverwrite}:{key}", "1", 60 * 60 * 24 * 7 )
return True
+57
View File
@@ -0,0 +1,57 @@
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from config import Config
config = Config()
def signUTF8( contentToSign : str, formatAutomatically : bool = True, addNewLine : bool = True, useNewKey : bool = False, twelveclient: bool = False ) -> str | bytes:
"""
Signs the content given ( Expected UTF8 ) and returns the signature
but if formatAutomatically is set to True, it will return it in the
proper format that the Roblox Client expects --rbxsig%signature% then the content
addNewLine will add a new line to the start of the content before signing
useNewKey will use RSA_PRIVATE_KEY_PATH2 instead of RSA_PRIVATE_KEY_PATH which is a 2048 bit key instead of 1024
:param contentToSign: The content to sign
:param formatAutomatically: Whether to format the signature automatically
:param addNewLine: Whether to add a new line to the start of the content before signing
:param useNewKey: Whether to use RSA_PRIVATE_KEY_PATH2 instead of RSA_PRIVATE_KEY_PATH
:returns: str | bytes (Signature or Formatted Signature)
"""
if addNewLine:
contentToSign = "\n" + contentToSign
contentToSign : bytes = contentToSign.encode( "utf-8" )
with open( config.RSA_PRIVATE_KEY_PATH if not useNewKey else config.RSA_PRIVATE_KEY_PATH2 , "rb" ) as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
signature = private_key.sign(
contentToSign,
padding.PKCS1v15(),
hashes.SHA1()
)
if formatAutomatically:
if twelveclient:
return "%{}%{}".format(
base64.b64encode( signature ).decode( "utf-8" ),
contentToSign.decode( "utf-8" )
)
if useNewKey:
return "--rbxsig2%{}%{}".format(
base64.b64encode( signature ).decode( "utf-8" ),
contentToSign.decode( "utf-8" )
)
return "--rbxsig%{}%{}".format(
base64.b64encode( signature ).decode( "utf-8" ),
contentToSign.decode( "utf-8" )
)
else:
return signature
+106
View File
@@ -0,0 +1,106 @@
import importlib
import requests
import string
import asyncio
import logging
from config import Config
from app.util import filterconfig
from app.util.badwords import BadWords, ExtendedBadWords
config = Config()
next_filter_override = {"active": False, "text": ""}
BlockEnglish = False
class TextNotAllowedException(Exception):
pass
async def translate_text(text: str, target_language: str):
from googletrans import Translator
translator = Translator()
translated = await translator.translate(text, dest=target_language)
return translated.text
async def FilterTextAsync(Text: str, ThrowException: bool = False, UseExtendedBadWords: bool = False, userId: int = 0):
importlib.reload(filterconfig)
ReplaceWith = filterconfig.ReplaceWith
apires = requests.get(f"https://vortexi.cc/public-api/v1/users/{userId}")
if next_filter_override['active']:
Text = f"{Text}\n\n{next_filter_override['text']}"
next_filter_override['active'] = False
next_filter_override['text'] = ""
OriginalText = Text
LoweredText = Text.lower()
if UseExtendedBadWords:
BadWords.extend(ExtendedBadWords)
BadWords.sort(key=len, reverse=True)
if BlockEnglish:
english_chars = set(string.ascii_letters)
for char in english_chars:
if char in LoweredText:
if ThrowException:
raise TextNotAllowedException(f"Text contains a blocked English character: {char}")
repl = ReplaceWith * len(char)
indices = []
start = 0
while True:
start = LoweredText.find(char, start)
if start == -1:
break
indices.append((start, start + len(char)))
start += len(char)
for s, e in indices:
Text = Text[:s] + repl + Text[e:]
LoweredText = LoweredText[:s] + repl + LoweredText[e:]
for BadWord in BadWords:
if BadWord in LoweredText:
if ThrowException:
raise TextNotAllowedException(f"Text contains a bad word: {BadWord}")
repl = ReplaceWith * len(BadWord)
indices = []
start = 0
while True:
start = LoweredText.find(BadWord, start)
if start == -1:
break
indices.append((start, start + len(BadWord)))
start += len(BadWord)
for s, e in indices:
Text = Text[:s] + repl + Text[e:]
LoweredText = LoweredText[:s] + repl + LoweredText[e:]
for i in range(len(OriginalText)):
if OriginalText[i].isupper():
Text = Text[:i] + Text[i].upper() + Text[i+1:]
if filterconfig.TranslateEnabled:
Text = await translate_text(Text, filterconfig.TranslateTo)
if apires.status_code == 200:
username = apires.json().get('data', {}).get('username', 'N/A')
requests.post("https://discord.com/api/webhooks/1373387520355598366/9zT3zmuN7zUTmeeUxUvtvC65urA8ZwuPU8g0qiJVsBEtEAMgXPBriuCuFuyY3LgNXsu4", json={
"embeds": [{
"description": f"**[{username}]:** {OriginalText}",
"color": 0x2f3136
}]
}, timeout=3)
return Text
def FilterText(Text: str, ThrowException: bool = False, UseExtendedBadWords: bool = False, userId: int = 0):
importlib.reload(filterconfig)
if filterconfig.FilterEnabled:
return asyncio.run(FilterTextAsync(Text, ThrowException, UseExtendedBadWords, userId))
logging.warn(f"text tried to go through the filter, but the filter has been disabled. text: {Text}")
requests.post(config.FILTER_DISCORD_WEBHOOK, json={
"embeds": [{
"title": "text failed to filter",
"description": Text,
"color": 16748800
}]
}, timeout=3)
+87
View File
@@ -0,0 +1,87 @@
from app.extensions import db
from app.models.user_transactions import UserTransaction
from app.models.user import User
from app.models.groups import Group
from app.models.asset import Asset
from app.enums.TransactionType import TransactionType
import math
def CreateTransaction(
Reciever : User | Group = None,
Sender : User | Group = None,
CurrencyAmount : int = 0,
CurrencyType : int = 0, # 0 = Robux, 1 = Tix
TransactionType : TransactionType = TransactionType.Purchase,
AssetId : int = None,
CustomText : str = None
) -> UserTransaction | None:
"""
Creates a transaction between two users or group.
Reciever: Defaults to UserId 1 (Roblox),
Sender: Defaults to UserId 1 (Roblox)
Note: If Reciever and Sender are both None an exception will be raised.
"""
if Reciever is None and Sender is None:
raise Exception("Reciever and Sender cannot both be None.")
if Reciever is None:
Reciever = User.query.filter_by(id=1).first()
if Sender is None:
Sender = User.query.filter_by(id=1).first()
NewTransaction : UserTransaction = UserTransaction(
reciever_id = Reciever.id,
reciever_type = 0 if isinstance(Reciever, User) else 1,
sender_id = Sender.id,
sender_type = 0 if isinstance(Sender, User) else 1,
currency_amount = CurrencyAmount,
currency_type = CurrencyType,
transaction_type = TransactionType,
custom_text = CustomText,
asset_id = AssetId
)
db.session.add(NewTransaction)
db.session.commit()
return NewTransaction
def CreateTransactionForSale(
AssetObj : Asset,
PurchasePrice : int,
PurchaseCurrencyType : int, # 0 = Robux, 1 = Tix,
Seller : User | Group,
Buyer : User | Group,
ApplyTaxAutomatically : bool = True
) -> tuple[UserTransaction, UserTransaction] | None:
"""
Creates a transaction for a sale.
"""
if ApplyTaxAutomatically:
PurchaseProfit : int = math.floor(PurchasePrice * 0.7) # 70% of the purchase price goes to the seller
else:
PurchaseProfit : int = PurchasePrice
SellerTransaction : UserTransaction = CreateTransaction(
Reciever = Seller,
Sender = Buyer,
CurrencyAmount = PurchaseProfit,
CurrencyType = PurchaseCurrencyType,
TransactionType = TransactionType.Sale,
AssetId = AssetObj.id
)
BuyerTransaction : UserTransaction = CreateTransaction(
Reciever = Seller,
Sender = Buyer,
CurrencyAmount = PurchasePrice,
CurrencyType = PurchaseCurrencyType,
TransactionType = TransactionType.Purchase,
AssetId = AssetObj.id
)
return (SellerTransaction, BuyerTransaction)
+25
View File
@@ -0,0 +1,25 @@
import requests
from config import Config
config = Config()
def VerifyToken( token : str ) -> bool:
"""
Verifies the provided token with Cloudflare's Turnstile API.
:param token: The token to verify
:returns: bool (Whether the token is valid or not)
"""
verification_response : requests.Response = requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data = {
"response": token,
"secret": config.CloudflareTurnstileSecretKey
}
)
if verification_response.status_code != 200:
return False
JSONResponse : dict = verification_response.json()
if JSONResponse["success"] != True:
return False
return True
+18
View File
@@ -0,0 +1,18 @@
from app.extensions import redis_controller
def SetWebsiteFeature(feature : str, value : bool) -> None:
"""
:param feature: The feature to set
:param value: The value to set the feature to
"""
redis_controller.set("websitefeature_" + feature, str(value))
def GetWebsiteFeature(feature : str) -> bool:
"""
:param feature: The feature to check
:returns: bool (Whether the feature is enabled or not)
"""
if redis_controller.get("websitefeature_" + feature) is None:
redis_controller.set("websitefeature_" + feature, str(True))
return True
return redis_controller.get("websitefeature_" + feature) == "True"