add web
This commit is contained in:
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 "
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
ReplaceWith = "#"
|
||||
TranslateEnabled = False
|
||||
TranslateTo = "Chinese"
|
||||
BlockEnglish = False
|
||||
FilterEnabled = True
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user