diff --git a/docs/modules/remodel.luau b/docs/modules/remodel.luau new file mode 100644 index 0000000..a4482a6 --- /dev/null +++ b/docs/modules/remodel.luau @@ -0,0 +1,277 @@ +local fs = require("@lune/fs") +local net = require("@lune/net") +local serde = require("@lune/serde") +local process = require("@lune/process") +local roblox = require("@lune/roblox") + +export type LuneDataModel = roblox.Instance +export type LuneInstance = roblox.Instance + +local function getAuthCookieWithFallbacks() + local cookie = roblox.getAuthCookie() + if cookie then + return cookie + end + + local cookieFromEnv = process.env.REMODEL_AUTH + if cookieFromEnv and #cookieFromEnv > 0 then + return `.ROBLOSECURITY={cookieFromEnv}` + end + + for index, arg in process.args do + if arg == "--auth" then + local cookieFromArgs = process.args[index + 1] + if cookieFromArgs and #cookieFromArgs > 0 then + return `.ROBLOSECURITY={cookieFromArgs}` + end + break + end + end + + error([[ + Failed to find ROBLOSECURITY cookie for authentication! + Make sure you have logged into studio, or set the ROBLOSECURITY environment variable. + ]]) +end + +local function downloadAssetId(assetId: number) + -- 1. Try to find the auth cookie for the current user + local cookie = getAuthCookieWithFallbacks() + + -- 2. Send a request to the asset delivery API, + -- which will respond with cdn download link(s) + local assetApiResponse = net.request({ + url = `https://assetdelivery.roblox.com/v2/assetId/{assetId}`, + headers = { + Accept = "application/json", + Cookie = cookie, + }, + }) + if not assetApiResponse.ok then + error( + string.format( + "Failed to fetch asset download link for asset id %s!\n%s (%s)\n%s", + tostring(assetId), + tostring(assetApiResponse.statusCode), + tostring(assetApiResponse.statusMessage), + tostring(assetApiResponse.body) + ) + ) + end + + -- 3. Make sure we got a valid response body + local assetApiBody = serde.decode("json", assetApiResponse.body) + if type(assetApiBody) ~= "table" then + error( + string.format( + "Asset delivery API returned an invalid response body!\n%s", + assetApiResponse.body + ) + ) + elseif type(assetApiBody.locations) ~= "table" then + error( + string.format( + "Asset delivery API returned an invalid response body!\n%s", + assetApiResponse.body + ) + ) + end + + -- 4. Grab the first asset download location - we only + -- requested one in our query, so this will be correct + local firstLocation = assetApiBody.locations[1] + if type(firstLocation) ~= "table" then + error( + string.format( + "Asset delivery API returned no download locations!\n%s", + assetApiResponse.body + ) + ) + elseif type(firstLocation.location) ~= "string" then + error( + string.format( + "Asset delivery API returned no valid download locations!\n%s", + assetApiResponse.body + ) + ) + end + + -- 5. Fetch the place contents from the cdn + local cdnResponse = net.request({ + url = firstLocation.location, + headers = { + Cookie = cookie, + }, + }) + if not cdnResponse.ok then + error( + string.format( + "Failed to download asset with id %s from the Roblox cdn!\n%s (%s)\n%s", + tostring(assetId), + tostring(cdnResponse.statusCode), + tostring(cdnResponse.statusMessage), + tostring(cdnResponse.body) + ) + ) + end + + -- 6. The response body should now be the contents of the asset file + return cdnResponse.body +end + +local function uploadAssetId(assetId: number, contents: string) + -- 1. Try to find the auth cookie for the current user + local cookie = getAuthCookieWithFallbacks() + + -- 2. Create request headers in advance, we might re-use them for CSRF challenges + local headers = { + ["User-Agent"] = "Roblox/WinInet", + ["Content-Type"] = "application/octet-stream", + Accept = "application/json", + Cookie = cookie, + } + + -- 3. Create and send a request to the upload url + local uploadResponse = net.request({ + url = `https://data.roblox.com/Data/Upload.ashx?assetid={assetId}`, + body = contents, + method = "POST", + headers = headers, + }) + + -- 4. Check if we got a valid response, we might have gotten a CSRF + -- challenge and need to send the request with a token included + if + not uploadResponse.ok + and uploadResponse.statusCode == 403 + and uploadResponse.headers["x-csrf-token"] ~= nil + then + headers["X-CSRF-Token"] = uploadResponse.headers["x-csrf-token"] + uploadResponse = net.request({ + url = `https://data.roblox.com/Data/Upload.ashx?assetid={assetId}`, + body = contents, + method = "POST", + headers = headers, + }) + end + + if not uploadResponse.ok then + error( + string.format( + "Failed to upload asset with id %s to Roblox!\n%s (%s)\n%s", + tostring(assetId), + tostring(uploadResponse.statusCode), + tostring(uploadResponse.statusMessage), + tostring(uploadResponse.body) + ) + ) + end +end + +local remodel = {} + +--[=[ + Load an `rbxl` or `rbxlx` file from the filesystem. + + Returns a `DataModel` instance, equivalent to `game` from within Roblox. +]=] +function remodel.readPlaceFile(filePath: string) + local placeFile = fs.readFile(filePath) + local place = roblox.deserializePlace(placeFile) + return place +end + +--[=[ + Load an `rbxm` or `rbxmx` file from the filesystem. + + Note that this function returns a **list of instances** instead of a single instance! + This is because models can contain mutliple top-level instances. +]=] +function remodel.readModelFile(filePath: string) + local modelFile = fs.readFile(filePath) + local model = roblox.deserializeModel(modelFile) + return model +end + +--[=[ + Reads a place asset from Roblox.com, equivalent to `remodel.readPlaceFile`. + + This method requires web authentication! +]=] +function remodel.readPlaceAsset(assetId: number) + local contents = downloadAssetId(assetId) + local place = roblox.deserializePlace(contents) + return place +end + +--[=[ + Reads a model asset from Roblox.com, equivalent to `remodel.readModelFile`. + + This method requires web authentication! +]=] +function remodel.readModelAsset(assetId: number) + local contents = downloadAssetId(assetId) + local place = roblox.deserializeModel(contents) + return place +end + +--[=[ + Saves an `rbxl` or `rbxlx` file out of the given `DataModel` instance. + + If the instance is not a `DataModel`, this function will throw. + Models should be saved with `writeModelFile` instead. +]=] +function remodel.writePlaceFile(filePath: string, dataModel: LuneDataModel) + local asXml = string.sub(filePath, -6) == ".rbxlx" + local placeFile = roblox.serializePlace(dataModel, asXml) + fs.writeFile(filePath, placeFile) +end + +--[=[ + Saves an `rbxm` or `rbxmx` file out of the given `Instance`. + + If the instance is a `DataModel`, this function will throw. + Places should be saved with `writePlaceFile` instead. +]=] +function remodel.writeModelFile(filePath: string, instance: LuneInstance) + local asXml = string.sub(filePath, -6) == ".rbxmx" + local placeFile = roblox.serializeModel({ instance }, asXml) + fs.writeFile(filePath, placeFile) +end + +--[=[ + Uploads the given `DataModel` instance to Roblox.com, overwriting an existing place. + + If the instance is not a `DataModel`, this function will throw. + Models should be uploaded with `writeExistingModelAsset` instead. + + This method requires web authentication! +]=] +function remodel.writeExistingPlaceAsset(dataModel: LuneDataModel, assetId: number) + local placeFile = roblox.serializePlace(dataModel) + uploadAssetId(assetId, placeFile) +end + +--[=[ + Uploads the given instance to Roblox.com, overwriting an existing model. + + If the instance is a `DataModel`, this function will throw. + Places should be uploaded with `writeExistingPlaceAsset` instead. + + This method requires web authentication! +]=] +function remodel.writeExistingModelAsset(instance: LuneInstance, assetId: number) + local modelFile = roblox.serializeModel({ instance }) + uploadAssetId(assetId, modelFile) +end + +remodel.readFile = fs.readFile +remodel.readDir = fs.readDir +remodel.writeFile = fs.writeFile +remodel.createDirAll = fs.writeDir +remodel.removeFile = fs.removeFile +remodel.removeDir = fs.removeDir +remodel.isFile = fs.isFile +remodel.isDir = fs.isDir + +return remodel diff --git a/docs/typedefs/Roblox.luau b/docs/typedefs/Roblox.luau index 7e0bc9c..8ffdb39 100644 --- a/docs/typedefs/Roblox.luau +++ b/docs/typedefs/Roblox.luau @@ -1,5 +1,54 @@ --- TODO: Autogenerate this somehow ... -export type Instance = {} +type InstanceProperties = { + Parent: Instance?, + ClassName: string, + Name: string, + [string]: any, +} + +type InstanceMetatable = { + Clone: (self: Instance) -> Instance, + Destroy: (self: Instance) -> (), + ClearAllChildren: (self: Instance) -> (), + + GetChildren: (self: Instance) -> { Instance }, + GetDescendants: (self: Instance) -> { Instance }, + GetFullName: (self: Instance) -> string, + + FindFirstAncestor: (self: Instance, name: string) -> Instance?, + FindFirstAncestorOfClass: (self: Instance, className: string) -> Instance?, + FindFirstAncestorWhichIsA: (self: Instance, className: string) -> Instance?, + FindFirstChild: (self: Instance, name: string, recursive: boolean?) -> Instance?, + FindFirstChildOfClass: (self: Instance, className: string, recursive: boolean?) -> Instance?, + FindFirstChildWhichIsA: (self: Instance, className: string, recursive: boolean?) -> Instance?, + + IsA: (self: Instance, className: string) -> boolean, + IsAncestorOf: (self: Instance, descendant: Instance) -> boolean, + IsDescendantOf: (self: Instance, ancestor: Instance) -> boolean, + + GetAttribute: (self: Instance, name: string) -> any, + GetAttributes: (self: Instance) -> { [string]: any }, + SetAttribute: (self: Instance, name: string, value: any) -> (), + + GetTags: (self: Instance) -> { string }, + HasTag: (self: Instance, name: string) -> boolean, + AddTag: (self: Instance, name: string) -> (), + RemoveTag: (self: Instance, name: string) -> (), +} + +export type Instance = typeof(setmetatable( + (nil :: any) :: InstanceProperties, + (nil :: any) :: { __index: InstanceMetatable } +)) + +type DataModelMetatable = { + GetService: (self: DataModel, name: string) -> Instance, + FindService: (self: DataModel, name: string) -> Instance?, +} + +export type DataModel = typeof(setmetatable( + (nil :: any) :: InstanceProperties, + (nil :: any) :: { __index: InstanceMetatable & DataModelMetatable } +)) --[=[ @class Roblox @@ -50,7 +99,7 @@ return { @param contents The contents of the place to read ]=] - deserializePlace = function(contents: string): Instance + deserializePlace = function(contents: string): DataModel return nil :: any end, --[=[ @@ -99,7 +148,7 @@ return { @param dataModel The DataModel for the place to serialize @param xml If the place should be serialized as xml or not. Defaults to `false`, meaning the place gets serialized using the binary format and not xml. ]=] - serializePlace = function(dataModel: Instance, xml: boolean?): string + serializePlace = function(dataModel: DataModel, xml: boolean?): string return nil :: any end, --[=[