diff --git a/Orbit-activity.rbxmx b/Orbit-activity.rbxmx index a1cbed44..3d4107cf 100644 --- a/Orbit-activity.rbxmx +++ b/Orbit-activity.rbxmx @@ -1,546 +1,803 @@ - - true - null - nil - - - ", - auth = "", - - privateEnabled = false, -- Track private servers? - studioEnabled = false, -- Track Studio sessions? - - -- Feature flags - bansEnabled = false, -- Let Orbit handle bans? - rankChecking = false, -- Only track members of the group? - groupId = 0, -- Group ID for rank checks - minTrackedRank = 0, -- Minimum rank to be tracked (fetched from Orbit on launch) - - -- Timing - cooldownPeriod = 30, -- Seconds between HTTP retries - minutesTillAFK = 2, -- Idle minutes before AFK flag - scanInterval = 300, -- Seconds between full player scans (5 min) - heartbeatInterval = 60, -- Seconds between lightweight heartbeats - - -- Chat capture - maxStoredChatLines = 200, - maxChatCharsPerLine = 512, -} - - -if not Configuration.studioEnabled and RunService:IsStudio() then - return warn("Orbit: Tracking disabled in Studio. (studioEnabled = false)") -end - -if not Configuration.privateEnabled then - if game.PrivateServerId ~= "" and game.PrivateServerOwnerId ~= 0 then - return warn("Orbit: Tracking disabled in private servers. (privateEnabled = false)") - end -end - -warn("Orbit: Module loaded — activity tracking active.") - -local chatLogTable = {} -local rankTable = {} -local afkTable = {} -local sessionActive = {} - -local lastActivityTime: { [number]: number } = {} -local afkStartTime: { [number]: number } = {} - -local function log(level: string, msg: string) - local prefix = string.format("[Orbit][%s] %s", level, msg) - if level == "WARN" or level == "ERROR" then - warn(prefix) - else - print(prefix) - end -end - -local function safeEncode(data: unknown): string? - local ok, result = pcall(HttpService.JSONEncode, HttpService, data) - return ok and result or nil -end - -local function safeDecode(raw: string): unknown? - local ok, result = pcall(HttpService.JSONDecode, HttpService, raw) - return ok and result or nil -end - -local function isPlayerOnline(Player: Player): boolean - return Players:GetPlayerByUserId(Player.UserId) ~= nil -end - -local function stripChat(text: string): string - return (text:gsub("^%s+", ""):gsub("%s+$", "")) -end - -local function isAFK(Player: Player): boolean - return table.find(afkTable, Player) ~= nil -end - -local function setAFK(Player: Player, state: boolean) - local idx = table.find(afkTable, Player) - if state and not idx then - table.insert(afkTable, Player) - afkStartTime[Player.UserId] = os.clock() - elseif not state and idx then - table.remove(afkTable, idx) - afkStartTime[Player.UserId] = nil - end -end - -local function bumpActivity(Player: Player) - lastActivityTime[Player.UserId] = os.clock() - setAFK(Player, false) -end - -local function appendChatLine(userId: number, raw: unknown) - local log_t = chatLogTable[userId] - if not log_t then return end - - local text = type(raw) == "string" and raw or "" - text = stripChat(text) - if text == "" then return end - - local maxChars = Configuration.maxChatCharsPerLine - if maxChars > 0 and #text > maxChars then - text = text:sub(1, maxChars) - end - - table.insert(log_t, text) - - local cap = Configuration.maxStoredChatLines - while cap > 0 and #log_t > cap do - table.remove(log_t, 1) - end -end - -local function collectSessionEndRow(Player: Player): { [string]: unknown } - local afkTimer = Player:FindFirstChild("Orbit AFK Timer") - local log_t = chatLogTable[Player.UserId] or {} - - local row: { [string]: unknown } = { - userid = Player.UserId, - username = Player.Name, - placeid = game.GameId, - idleTime = afkTimer and afkTimer.Value or 0, - messages = #log_t, - } - if #log_t > 0 then - row.chatBodies = log_t - end - return row -end - -local function fetchRank(Player: Player): number - if not Configuration.rankChecking then return math.huge end - local ok, rank = pcall(Player.GetRankInGroupAsync, Player, Configuration.groupId) - return ok and rank or 0 -end - -local function isUserTracked(Player: Player): boolean - if not Configuration.rankChecking then return true end - local entry = rankTable[Player.UserId] - if not entry then return false end - return entry.Rank >= Configuration.minTrackedRank -end - -local function httpGet(endpoint: string): (boolean, unknown?) - local ok, result = pcall(HttpService.RequestAsync, HttpService, { - Url = Configuration.url .. endpoint, - Method = "GET", - Headers = { ["authorization"] = Configuration.auth }, - }) - if not ok then return false, nil end - local decoded = safeDecode(result.Body) - return decoded ~= nil, decoded -end - -local function httpPost(endpoint: string, body: unknown): boolean - local encoded = safeEncode(body) - if not encoded then - log("ERROR", "Failed to encode body for " .. endpoint) - return false - end - local request = HttpQueue.HttpRequest.new(Configuration.url .. endpoint, "POST", encoded, nil, - { - ["Content-Type"] = "application/json", - ["authorization"] = Configuration.auth, - } - ) - request:Send() - return true -end - -local function checkRemoteSessionActive(Player: Player): boolean - local ok, response = httpGet("/api/activity/session?id=" .. Player.UserId) - if ok and type(response) == "table" and response.success then - return true - end - return false -end - -local function CreateSession(Player: Player) - if sessionActive[Player.UserId] then - log("INFO", "Session already cached as active for " .. Player.Name .. ", skipping.") - return - end - - if checkRemoteSessionActive(Player) then - log("WARN", "Remote session already exists for " .. Player.Name .. ", syncing local cache.") - sessionActive[Player.UserId] = true - return - end - - local success = httpPost("/api/activity/session?type=create", { - userid = Player.UserId, - username = Player.Name, - placeid = game.GameId, - }) - - if success then - sessionActive[Player.UserId] = true - log("INFO", "Session created for " .. Player.Name) - else - log("ERROR", "Failed to create session for " .. Player.Name) - end -end - -local function EndSession(Player: Player, reason: string?) - if not sessionActive[Player.UserId] then - if not checkRemoteSessionActive(Player) then - log("INFO", "No session to end for " .. Player.Name) - return - end - end - - local row = collectSessionEndRow(Player) - row.endReason = reason or "natural" - - local success = httpPost("/api/activity/session?type=end", row) - if success then - sessionActive[Player.UserId] = false - log("INFO", string.format("Session ended for %s (reason: %s)", Player.Name, row.endReason)) - else - log("ERROR", "Failed to end session for " .. Player.Name) - end -end - -local function MovementDetection(Player: Player) - local Character = Player.Character or Player.CharacterAdded:Wait() - local Humanoid = Character:WaitForChild("Humanoid") - local userId = Player.UserId - - if not lastActivityTime[userId] then - lastActivityTime[userId] = os.clock() - end - - local connections: { RBXScriptConnection } = {} - - local function onActivity(speed: number) - if speed > 0 then - bumpActivity(Player) - else - local snapshot = os.clock() - lastActivityTime[userId] = snapshot - - FastWait(60 * Configuration.minutesTillAFK) - if isPlayerOnline(Player) and lastActivityTime[userId] == snapshot then - setAFK(Player, true) - log("INFO", Player.Name .. " marked AFK (no movement for " - .. Configuration.minutesTillAFK .. " min).") - end - end - end - - table.insert(connections, Humanoid.Running:Connect(onActivity)) - table.insert(connections, Humanoid.Swimming:Connect(onActivity)) - table.insert(connections, Humanoid.Climbing:Connect(onActivity)) - table.insert(connections, Humanoid.Jumping:Connect(function(isJumping) - if isJumping then - bumpActivity(Player) - end - end)) - local charRemovedConn - charRemovedConn = Character.AncestryChanged:Connect(function() - if not Character:IsDescendantOf(game) then - for _, c in ipairs(connections) do - c:Disconnect() - end - charRemovedConn:Disconnect() - end - end) -end - -local function InputChange(Player: Player, isIdle: boolean) - if not isIdle then - bumpActivity(Player) - else - local lastActive = lastActivityTime[Player.UserId] - local idleSeconds = lastActive and (os.clock() - lastActive) or math.huge - local threshold = 60 * Configuration.minutesTillAFK -- now matches MovementDetection's threshold - - if idleSeconds >= threshold then - setAFK(Player, true) - end - end -end - -TovyEvent.OnServerEvent:Connect(InputChange) - -local function InitiatePlayer(Player: Player) - local rank = fetchRank(Player) - rankTable[Player.UserId] = { Rank = rank } - chatLogTable[Player.UserId] = {} - lastActivityTime[Player.UserId] = os.clock() - - if not isUserTracked(Player) then - log("INFO", Player.Name .. " is not at a tracked rank — skipping session.") - return - end - - local afkTimer = Instance.new("NumberValue") - afkTimer.Name = "Orbit AFK Timer" - afkTimer.Parent = Player - - local clientClone = TovyClient:Clone() - clientClone.Parent = Player:WaitForChild("PlayerGui") - - CreateSession(Player) - MovementDetection(Player) -end - -local function CleanupPlayer(Player: Player, reason: string?) - EndSession(Player, reason) - setAFK(Player, false) - rankTable[Player.UserId] = nil - chatLogTable[Player.UserId] = nil - sessionActive[Player.UserId] = nil - lastActivityTime[Player.UserId] = nil - afkStartTime[Player.UserId] = nil -end - -Players.PlayerAdded:Connect(function(Player) - InitiatePlayer(Player) - - Player.CharacterAdded:Connect(function() - lastActivityTime[Player.UserId] = os.clock() - MovementDetection(Player) - end) - - Player.Chatted:Connect(function(Message) - if isUserTracked(Player) then - appendChatLine(Player.UserId, Message) - bumpActivity(Player) - end - end) -end) - -Players.PlayerRemoving:Connect(function(Player) - CleanupPlayer(Player, "player_left") -end) - -game:BindToClose(function() - local sessions = {} - for _, Player in ipairs(Players:GetPlayers()) do - if isUserTracked(Player) and (sessionActive[Player.UserId] or checkRemoteSessionActive(Player)) then - local row = collectSessionEndRow(Player) - row.endReason = "server_shutdown" - table.insert(sessions, row) - end - end - - if #sessions > 0 then - local encoded = safeEncode({ sessions = sessions }) - if encoded then - local ok, response = pcall(HttpService.RequestAsync, HttpService, { - Url = Configuration.url .. "/api/activity/bulk-end", - Method = "POST", - Headers = { - ["Content-Type"] = "application/json", - ["authorization"] = Configuration.auth, - }, - Body = encoded, - }) - if ok then - log("INFO", string.format("Shutdown: bulk-ended %d session(s).", #sessions)) - else - log("ERROR", "Shutdown: bulk-end request failed — " .. tostring(response)) - end - end - else - log("INFO", "Shutdown: no active sessions to end.") - end - - task.wait(5) -end) - -task.spawn(function() - while true do - task.wait(Configuration.heartbeatInterval) - for idx, Player in ipairs(afkTable) do - local online = isPlayerOnline(Player) - if online then - local afkTimer = Player:FindFirstChild("Orbit AFK Timer") - local start = afkStartTime[Player.UserId] - if afkTimer and start then - afkTimer.Value = math.floor((os.clock() - start) / 60) - end - else - log("WARN", "AFK table contained offline player — removing ghost entry.") - table.remove(afkTable, idx) - afkStartTime[Player.UserId] = nil - end - end - end -end) - -task.spawn(function() - while true do - task.wait(Configuration.scanInterval) - log("INFO", "Running full player scan...") - - local onlinePlayers = Players:GetPlayers() - local onlineSet = {} - for _, p in ipairs(onlinePlayers) do - onlineSet[p.UserId] = true - end - for userId, active in pairs(sessionActive) do - if active and not onlineSet[userId] then - log("WARN", string.format( - "Ghost session detected for userId %d — ending immediately.", userId - )) - local ghostRow = { - userid = userId, - idleTime = 0, - messages = #(chatLogTable[userId] or {}), - endReason = "ghost_cleanup", - } - if chatLogTable[userId] and #chatLogTable[userId] > 0 then - ghostRow.chatBodies = chatLogTable[userId] - end - httpPost("/api/activity/session?type=end", ghostRow) - sessionActive[userId] = false - chatLogTable[userId] = nil - rankTable[userId] = nil - lastActivityTime[userId] = nil - afkStartTime[userId] = nil - end - end - for _, Player in ipairs(onlinePlayers) do - if not isUserTracked(Player) then continue end - - local hasSession = sessionActive[Player.UserId] - if not hasSession then - local remoteActive = checkRemoteSessionActive(Player) - if remoteActive then - log("WARN", Player.Name .. " has remote session but no local cache — syncing.") - sessionActive[Player.UserId] = true - else - log("WARN", Player.Name .. " has no active session — recreating.") - chatLogTable[Player.UserId] = chatLogTable[Player.UserId] or {} - local afkTimer = Player:FindFirstChild("Orbit AFK Timer") - if afkTimer then afkTimer.Value = 0 end - CreateSession(Player) - end - end - end - if Configuration.rankChecking then - for _, Player in ipairs(onlinePlayers) do - local oldRank = rankTable[Player.UserId] and rankTable[Player.UserId].Rank or 0 - local newRank = fetchRank(Player) - rankTable[Player.UserId] = { Rank = newRank } - - if newRank ~= oldRank then - log("INFO", string.format("%s rank changed: %d → %d", Player.Name, oldRank, newRank)) - - if oldRank >= Configuration.minTrackedRank - and newRank < Configuration.minTrackedRank then - log("WARN", Player.Name .. " fell below min rank, ending session.") - EndSession(Player, "rank_below_minimum") - - elseif oldRank < Configuration.minTrackedRank - and newRank >= Configuration.minTrackedRank then - log("INFO", Player.Name .. " now meets min rank, starting session.") - chatLogTable[Player.UserId] = {} - lastActivityTime[Player.UserId] = os.clock() - local afkTimer = Instance.new("NumberValue") - afkTimer.Name = "Orbit AFK Timer" - afkTimer.Parent = Player - CreateSession(Player) - end - end - end - end - - for idx = #afkTable, 1, -1 do - local Player = afkTable[idx] - if not isPlayerOnline(Player) then - log("WARN", "Scan: purging offline player from AFK table: " .. tostring(Player)) - table.remove(afkTable, idx) - afkStartTime[Player.UserId] = nil - end - end - - for userId in pairs(chatLogTable) do - if not onlineSet[userId] then - log("WARN", string.format( - "Orphan chat log found for offline userId %d — purging.", userId - )) - chatLogTable[userId] = nil - end - end - end -end) - -for _, Player in ipairs(Players:GetPlayers()) do - task.spawn(InitiatePlayer, Player) -end]]> - false - - 0 - {81740BB3-280D-4363-BA2A-0CE80C16A785} - - 0 - false - OrbitActivity - -1 - - - - - - 0 - false - Components - -1 - - - - - + + true + null + nil + + + ", + auth = "", + + privateEnabled = , -- Track private servers? + studioEnabled = , -- Track Studio sessions? + + -- Feature flags + bansEnabled = false, -- Let Orbit handle bans? + rankChecking = true, -- Only track members of the group? + groupId = , -- Group ID for rank checks + minTrackedRank = , -- Minimum rank to be tracked (overridable via remote config) + + -- Timing + cooldownPeriod = 30, -- Seconds between HTTP retries + minutesTillAFK = 2, -- Idle minutes before AFK flag + scanInterval = 300, -- Seconds between full player scans (5 min) + heartbeatInterval = 60, -- Seconds between lightweight heartbeats + rankRefreshInterval = 600, -- Seconds between per-player rank re-checks (throttles GroupService) + serverHeartbeatEvery = 120, -- Seconds between backend "server alive" pings + remoteEventCooldown = 3, -- Min seconds between accepted client Activity events per player + + -- Chat capture + maxStoredChatLines = 200, + maxChatCharsPerLine = 512, + + -- Reliability + deadLetterStoreName = "OrbitFailedRequests", + deadLetterFlushEvery = 60, -- Seconds between dead-letter retry sweeps + deadLetterMaxAttempts = 10, -- Give up (but keep for manual review) after this many failed retries +} + +if not Configuration.studioEnabled and RunService:IsStudio() then + return warn("Orbit: Tracking disabled in Studio. (studioEnabled = false)") +end + +if not Configuration.privateEnabled then + if game.PrivateServerId ~= "" and game.PrivateServerOwnerId ~= 0 then + return warn("Orbit: Tracking disabled in private servers. (privateEnabled = false)") + end +end + +warn("Orbit: Module loaded — activity tracking active.") + +local function log(level: string, msg: string) + local prefix = string.format("[Orbit][%s] %s", level, msg) + if level == "WARN" or level == "ERROR" then + warn(prefix) + else + print(prefix) + end +end + +local function safeEncode(data: unknown): string? + local ok, result = pcall(HttpService.JSONEncode, HttpService, data) + return ok and result or nil +end + +local function safeDecode(raw: string): unknown? + local ok, result = pcall(HttpService.JSONDecode, HttpService, raw) + return ok and result or nil +end + +type PlayerState = { + rank: number, + chatLog: { string }, + sessionActive: boolean, + lastActivity: number, + afkStart: number?, + isAFK: boolean, + afkTimerInstance: NumberValue?, + pendingAFKCheck: boolean, + lastRankCheck: number, + lastRemoteEventAccepted: number, +} + +local PlayerData: { [number]: PlayerState } = {} + +local function getState(Player: Player): PlayerState? + return PlayerData[Player.UserId] +end + +local function isPlayerOnline(Player: Player): boolean + return Players:GetPlayerByUserId(Player.UserId) ~= nil +end + +local function stripChat(text: string): string + return (text:gsub("^%s+", ""):gsub("%s+$", "")) +end + + +local function setAFK(Player: Player, state: boolean) + local st = getState(Player) + if not st then return end + + if state and not st.isAFK then + st.isAFK = true + st.afkStart = os.clock() + elseif not state and st.isAFK then + st.isAFK = false + st.afkStart = nil + if st.afkTimerInstance then + st.afkTimerInstance.Value = 0 + end + end +end + +local function bumpActivity(Player: Player) + local st = getState(Player) + if not st then return end + st.lastActivity = os.clock() + setAFK(Player, false) +end + +local function appendChatLine(userId: number, raw: unknown) + local st = PlayerData[userId] + if not st then return end + + local text = type(raw) == "string" and raw or "" + text = stripChat(text) + if text == "" then return end + + local maxChars = Configuration.maxChatCharsPerLine + if maxChars > 0 and #text > maxChars then + text = text:sub(1, maxChars) + end + + table.insert(st.chatLog, text) + + local cap = Configuration.maxStoredChatLines + while cap > 0 and #st.chatLog > cap do + table.remove(st.chatLog, 1) + end +end + +local function collectSessionEndRow(Player: Player): { [string]: unknown } + local st = getState(Player) + local log_t = st and st.chatLog or {} + local idleMinutes = st and st.afkTimerInstance and st.afkTimerInstance.Value or 0 + + local row: { [string]: unknown } = { + userid = Player.UserId, + username = Player.Name, + placeid = game.GameId, + idleTime = idleMinutes, + messages = #log_t, + } + if #log_t > 0 then + row.chatBodies = log_t + end + return row +end + +local function fetchRank(Player: Player): number + if not Configuration.rankChecking then return math.huge end + local success, rankInfo = pcall(function() + return GroupService:GetRolesInGroupAsync(Player.UserId, Configuration.groupId) + end) + if not success then return math.huge end + if not rankInfo.IsMember then return math.huge end + + return rankInfo.Roles[1] and rankInfo.Roles[1].Rank or 0 +end + +local function isUserTracked(Player: Player): boolean + if not Configuration.rankChecking then return true end + local st = getState(Player) + if not st then return false end + return st.rank >= Configuration.minTrackedRank +end + +local DeadLetterStore = DataStoreService:GetDataStore(Configuration.deadLetterStoreName) +local deadLetterCounter = 0 + +local function deadLetterKey(): string + deadLetterCounter += 1 + return string.format("%d-%d-%d", game.JobId and #game.JobId or 0, os.time(), deadLetterCounter) +end + +local function enqueueDeadLetter(endpoint: string, body: unknown) + local key = deadLetterKey() + local ok, err = pcall(function() + DeadLetterStore:SetAsync(key, { + endpoint = endpoint, + body = body, + attempts = 0, + queuedAt = os.time(), + }) + end) + if ok then + log("WARN", "Queued failed request to dead-letter store: " .. endpoint) + else + log("ERROR", "Failed to enqueue dead-letter entry (" .. endpoint .. "): " .. tostring(err)) + end +end + +local function rawPost(endpoint: string, encodedBody: string): (boolean, unknown?) + local ok, result = pcall(HttpService.RequestAsync, HttpService, { + Url = Configuration.url .. endpoint, + Method = "POST", + Headers = { + ["Content-Type"] = "application/json", + ["authorization"] = Configuration.auth, + }, + Body = encodedBody, + }) + if not ok or not result.Success then return false, nil end + return true, safeDecode(result.Body) +end + +local function httpGet(endpoint: string, maxAttempts: number?): (boolean, unknown?) + maxAttempts = maxAttempts or 2 + for attempt = 1, maxAttempts do + local ok, result = pcall(HttpService.RequestAsync, HttpService, { + Url = Configuration.url .. endpoint, + Method = "GET", + Headers = { ["authorization"] = Configuration.auth }, + }) + if ok and result.Success then + local decoded = safeDecode(result.Body) + if decoded ~= nil then + return true, decoded + end + end + if attempt < maxAttempts then + task.wait(1) + end + end + return false, nil +end + +local function httpPost(endpoint: string, body: unknown, allowDeadLetter: boolean?): boolean + local encoded = safeEncode(body) + if not encoded then + log("ERROR", "Failed to encode body for " .. endpoint) + return false + end + + local ok = rawPost(endpoint, encoded) + if ok then + return true + end + + local queuedOk = pcall(function() + HttpQueue.HttpRequest.new(Configuration.url .. endpoint, "POST", encoded, nil, { + ["Content-Type"] = "application/json", + ["authorization"] = Configuration.auth, + }):Send() + end) + + if not queuedOk and allowDeadLetter ~= false then + enqueueDeadLetter(endpoint, body) + end + + return queuedOk +end + +local function handlePossibleBan(Player: Player, response: unknown) + if not Configuration.bansEnabled then return end + if type(response) ~= "table" then return end + + if response.banned then + local reason = type(response.reason) == "string" and response.reason or "Banned" + log("WARN", string.format("%s is banned via Orbit (%s) — kicking.", Player.Name, reason)) + Player:Kick("You have been banned: " .. reason) + end +end + +local function checkRemoteSessionActive(Player: Player): boolean + local ok, response = httpGet("/api/activity/session?id=" .. Player.UserId) + if ok and type(response) == "table" and response.success then + return true + end + return false +end + +local function CreateSession(Player: Player) + local st = getState(Player) + if not st then return end + + if st.sessionActive then + log("INFO", "Session already cached as active for " .. Player.Name .. ", skipping.") + return + end + + if checkRemoteSessionActive(Player) then + log("WARN", "Remote session already exists for " .. Player.Name .. ", syncing local cache.") + st.sessionActive = true + return + end + + local encoded = safeEncode({ + userid = Player.UserId, + username = Player.Name, + placeid = game.GameId, + }) + local ok, response = false, nil + if encoded then + ok, response = rawPost("/api/activity/session?type=create", encoded) + end + + if ok then + st.sessionActive = true + log("INFO", "Session created for " .. Player.Name) + handlePossibleBan(Player, response) + else + local fallbackOk = httpPost("/api/activity/session?type=create", { + userid = Player.UserId, + username = Player.Name, + placeid = game.GameId, + }) + if fallbackOk then + st.sessionActive = true + log("INFO", "Session created for " .. Player.Name .. " (via retry path)") + else + log("ERROR", "Failed to create session for " .. Player.Name) + end + end +end + +local function BulkCreateSessions(playersList: { Player }) + if #playersList == 0 then return end + + local rows = {} + for _, Player in ipairs(playersList) do + table.insert(rows, { + userid = Player.UserId, + username = Player.Name, + placeid = game.GameId, + }) + end + + local encoded = safeEncode({ sessions = rows }) + local bulkSucceeded: { [number]: boolean } = {} + + if encoded then + local ok, response = rawPost("/api/activity/bulk-start", encoded) + if ok and type(response) == "table" and type(response.started) == "table" then + for _, userId in ipairs(response.started) do + bulkSucceeded[userId] = true + end + log("INFO", string.format("Bulk-started %d/%d session(s).", #response.started, #playersList)) + end + end + + for _, Player in ipairs(playersList) do + local st = getState(Player) + if st then + if bulkSucceeded[Player.UserId] then + st.sessionActive = true + else + CreateSession(Player) + end + end + end +end + +local function EndSession(Player: Player, reason: string?) + local st = getState(Player) + if not st then return end + + if not st.sessionActive then + if not checkRemoteSessionActive(Player) then + log("INFO", "No session to end for " .. Player.Name) + return + end + end + + local row = collectSessionEndRow(Player) + row.endReason = reason or "natural" + + local success = httpPost("/api/activity/session?type=end", row) + if success then + st.sessionActive = false + log("INFO", string.format("Session ended for %s (reason: %s)", Player.Name, row.endReason)) + else + log("ERROR", "Failed to end session for " .. Player.Name) + end +end + +local function MovementDetection(Player: Player) + local Character = Player.Character or Player.CharacterAdded:Wait() + local Humanoid = Character:WaitForChild("Humanoid") + + local st = getState(Player) + if not st then return end + if not st.lastActivity then + st.lastActivity = os.clock() + end + + local connections: { RBXScriptConnection } = {} + + local function onActivity(speed: number) + if speed > 0 then + bumpActivity(Player) + return + end + + local myState = getState(Player) + if not myState or myState.pendingAFKCheck then + return + end + + local snapshot = os.clock() + myState.lastActivity = snapshot + myState.pendingAFKCheck = true + + task.spawn(function() + FastWait(60 * Configuration.minutesTillAFK) + + local currentState = getState(Player) + if currentState then + currentState.pendingAFKCheck = false + end + + if isPlayerOnline(Player) and currentState and currentState.lastActivity == snapshot then + setAFK(Player, true) + log("INFO", Player.Name .. " marked AFK (no movement for " + .. Configuration.minutesTillAFK .. " min).") + end + end) + end + + table.insert(connections, Humanoid.Running:Connect(onActivity)) + table.insert(connections, Humanoid.Swimming:Connect(onActivity)) + table.insert(connections, Humanoid.Climbing:Connect(onActivity)) + table.insert(connections, Humanoid.Jumping:Connect(function(isJumping) + if isJumping then + bumpActivity(Player) + end + end)) + + local charRemovedConn + charRemovedConn = Character.AncestryChanged:Connect(function() + if not Character:IsDescendantOf(game) then + for _, c in ipairs(connections) do + c:Disconnect() + end + charRemovedConn:Disconnect() + end + end) +end + +local function InputChange(Player: Player, isIdle: unknown) + if typeof(isIdle) ~= "boolean" then + log("WARN", "Rejected malformed Activity event from " .. Player.Name) + return + end + + local st = getState(Player) + if not st then return end + + local now = os.clock() + if now - (st.lastRemoteEventAccepted or 0) < Configuration.remoteEventCooldown then + return + end + st.lastRemoteEventAccepted = now + + if not isIdle then + bumpActivity(Player) + else + local idleSeconds = st.lastActivity and (now - st.lastActivity) or math.huge + local threshold = 60 * Configuration.minutesTillAFK -- matches MovementDetection's threshold + + if idleSeconds >= threshold then + setAFK(Player, true) + end + end +end + +TovyEvent.OnServerEvent:Connect(InputChange) + + +local function InitiatePlayer(Player: Player): boolean + local rank = fetchRank(Player) + + PlayerData[Player.UserId] = { + rank = rank, + chatLog = {}, + sessionActive = false, + lastActivity = os.clock(), + afkStart = nil, + isAFK = false, + afkTimerInstance = nil, + pendingAFKCheck = false, + lastRankCheck = os.clock(), + lastRemoteEventAccepted = 0, + } + + if not isUserTracked(Player) then + log("INFO", Player.Name .. " is not at a tracked rank — skipping session.") + return false + end + + local afkTimer = Instance.new("NumberValue") + afkTimer.Name = "Orbit AFK Timer" + afkTimer.Parent = Player + PlayerData[Player.UserId].afkTimerInstance = afkTimer + + local clientClone = TovyClient:Clone() + clientClone.Parent = Player:WaitForChild("PlayerGui") + + task.spawn(MovementDetection, Player) + return true +end + +local function CleanupPlayer(Player: Player, reason: string?) + EndSession(Player, reason) + PlayerData[Player.UserId] = nil +end + +Players.PlayerAdded:Connect(function(Player) + local tracked = InitiatePlayer(Player) + if tracked then + CreateSession(Player) + end + + Player.CharacterAdded:Connect(function() + local st = getState(Player) + if st then + st.lastActivity = os.clock() + end + MovementDetection(Player) + end) + + Player.Chatted:Connect(function(Message) + if isUserTracked(Player) then + appendChatLine(Player.UserId, Message) + bumpActivity(Player) + end + end) +end) + +Players.PlayerRemoving:Connect(function(Player) + CleanupPlayer(Player, "player_left") +end) + +game:BindToClose(function() + local sessions = {} + for _, Player in ipairs(Players:GetPlayers()) do + local st = getState(Player) + if st and isUserTracked(Player) and (st.sessionActive or checkRemoteSessionActive(Player)) then + local row = collectSessionEndRow(Player) + row.endReason = "server_shutdown" + table.insert(sessions, row) + end + end + + if #sessions > 0 then + local encoded = safeEncode({ sessions = sessions }) + if encoded then + local ok = rawPost("/api/activity/bulk-end", encoded) + if ok then + log("INFO", string.format("Shutdown: bulk-ended %d session(s).", #sessions)) + else + log("ERROR", "Shutdown: bulk-end request failed — queueing for dead-letter retry.") + enqueueDeadLetter("/api/activity/bulk-end", { sessions = sessions }) + end + end + else + log("INFO", "Shutdown: no active sessions to end.") + end + + task.wait(5) +end) + +local function fetchRemoteConfig() + local ok, response = httpGet(`/api/activity/config?id={Configuration.groupId}`) + if not ok or type(response.data) ~= "table" then + log("WARN", "Could not fetch remote config — using local defaults.") + return + end + + local overridable = {"minTrackedRank", "privateEnabled", "studioEnabled"} + local changed = {} + for _, key in ipairs(overridable) do + if response[key] ~= nil and response[key] ~= Configuration[key] then + Configuration[key] = response[key] + table.insert(changed, key) + end + end + + if #changed > 0 then + log("INFO", "Applied remote config overrides: " .. table.concat(changed, ", ")) + end +end + +task.spawn(function() + while true do + task.wait(Configuration.heartbeatInterval) + for userId, st in pairs(PlayerData) do + if st.isAFK and st.afkStart and st.afkTimerInstance then + local Player = Players:GetPlayerByUserId(userId) + if Player and isPlayerOnline(Player) then + st.afkTimerInstance.Value = math.floor((os.clock() - st.afkStart) / 60) + end + end + end + end +end) + +task.spawn(function() + while true do + task.wait(Configuration.deadLetterFlushEvery) + + local ok, keysPage = pcall(function() + return DeadLetterStore:ListKeysAsync() + end) + if not ok then continue end + + local currentPage = keysPage:GetCurrentPage() + for _, keyInfo in ipairs(currentPage) do + local key = keyInfo.KeyName + local getOk, entry = pcall(function() + local value = DeadLetterStore:GetAsync(key) + return value + end) + + if getOk and type(entry) == "table" then + local encoded = safeEncode(entry.body) + if encoded then + local postOk = rawPost(entry.endpoint, encoded) + if postOk then + pcall(function() DeadLetterStore:RemoveAsync(key) end) + log("INFO", "Dead-letter retry succeeded for " .. entry.endpoint) + else + entry.attempts = (entry.attempts or 0) + 1 + if entry.attempts >= Configuration.deadLetterMaxAttempts then + log("ERROR", string.format( + "Dead-letter entry %s exceeded max attempts (%d) — leaving in store for manual review.", + key, entry.attempts)) + else + pcall(function() DeadLetterStore:SetAsync(key, entry) end) + end + end + end + end + end + end +end) + +task.spawn(function() + while true do + task.wait(Configuration.scanInterval) + log("INFO", "Running full player scan...") + + local onlinePlayers = Players:GetPlayers() + local onlineSet: { [number]: boolean } = {} + for _, p in ipairs(onlinePlayers) do + onlineSet[p.UserId] = true + end + + for userId, st in pairs(PlayerData) do + if st.sessionActive and not onlineSet[userId] then + log("WARN", string.format( + "Ghost session detected for userId %d — ending immediately.", userId)) + local ghostRow = { + userid = userId, + idleTime = 0, + messages = #st.chatLog, + endReason = "ghost_cleanup", + } + if #st.chatLog > 0 then + ghostRow.chatBodies = st.chatLog + end + httpPost("/api/activity/session?type=end", ghostRow) + PlayerData[userId] = nil + end + end + + for _, Player in ipairs(onlinePlayers) do + if isUserTracked(Player) then + local st = getState(Player) + if st and not st.sessionActive then + local remoteActive = checkRemoteSessionActive(Player) + if remoteActive then + log("WARN", Player.Name .. " has remote session but no local cache — syncing.") + st.sessionActive = true + else + log("WARN", Player.Name .. " has no active session — recreating.") + if st.afkTimerInstance then st.afkTimerInstance.Value = 0 end + CreateSession(Player) + end + end + end + end + + if Configuration.rankChecking then + for i, Player in ipairs(onlinePlayers) do + local st = getState(Player) + if st then + local dueForRecheck = (os.clock() - (st.lastRankCheck or 0)) >= Configuration.rankRefreshInterval + if dueForRecheck then + if i > 1 then task.wait(0.25) end + + local oldRank = st.rank + local newRank = fetchRank(Player) + st.rank = newRank + st.lastRankCheck = os.clock() + + if newRank ~= oldRank then + log("INFO", string.format("%s rank changed: %d → %d", Player.Name, oldRank, newRank)) + + if oldRank >= Configuration.minTrackedRank + and newRank < Configuration.minTrackedRank then + log("WARN", Player.Name .. " fell below min rank, ending session.") + EndSession(Player, "rank_below_minimum") + + elseif oldRank < Configuration.minTrackedRank + and newRank >= Configuration.minTrackedRank then + log("INFO", Player.Name .. " now meets min rank, starting session.") + st.chatLog = {} + st.lastActivity = os.clock() + if not st.afkTimerInstance then + local afkTimer = Instance.new("NumberValue") + afkTimer.Name = "Orbit AFK Timer" + afkTimer.Parent = Player + st.afkTimerInstance = afkTimer + end + CreateSession(Player) + end + end + end + end + end + end + for userId, st in pairs(PlayerData) do + if st.isAFK and not onlineSet[userId] then + st.isAFK = false + st.afkStart = nil + end + end + end +end) + +fetchRemoteConfig() + +do + local startupPlayers = Players:GetPlayers() + local trackedForBulkStart = {} + + for _, Player in ipairs(startupPlayers) do + local tracked = InitiatePlayer(Player) + if tracked then + table.insert(trackedForBulkStart, Player) + end + end + + BulkCreateSessions(trackedForBulkStart) +end]]> + false + + 0 + {81740BB3-280D-4363-BA2A-0CE80C16A785} + + 0 + false + OrbitActivity + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + 0 + false + Components + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + - {FE2A771C-DD2C-45EB-B0B2-BA2830DA58B6} - - 0 - false - HttpQueue - -1 - - - - - +]]> + {FE2A771C-DD2C-45EB-B0B2-BA2830DA58B6} + + 0 + false + HttpQueue + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + - {023794A1-6658-4BCB-92A9-5B9086A5DD23} - - 0 - false - DataUtils - -1 - - - - - - +]]> + {023794A1-6658-4BCB-92A9-5B9086A5DD23} + + 0 + false + DataUtils + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {9C00AA1B-2A14-48E5-998E-9BE6EC807541} - - 0 - false - HttpQueue - -1 - - - - - - +]]> + {9C00AA1B-2A14-48E5-998E-9BE6EC807541} + + 0 + false + HttpQueue + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {FB8696F6-C420-4A0B-A3F1-67434876F8E3} - - 0 - false - HttpRequest - -1 - - - - - - +]]> + {FB8696F6-C420-4A0B-A3F1-67434876F8E3} + + 0 + false + HttpRequest + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {D32001E5-379D-461F-8EE8-561C788CD8A0} - - 0 - false - HttpRequestPriority - -1 - - - - - - +]]> + {D32001E5-379D-461F-8EE8-561C788CD8A0} + + 0 + false + HttpRequestPriority + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {6852466C-23CA-4C7A-B4A1-D361FCD8FBA7} - - 0 - false - HttpResponse - -1 - - - - - - +]]> + {6852466C-23CA-4C7A-B4A1-D361FCD8FBA7} + + 0 + false + HttpResponse + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {1D92F075-DDEE-4908-82EB-2D4C5B267F5F} - - 0 - false - TypeGuards - -1 - - - - - - +]]> + {1D92F075-DDEE-4908-82EB-2D4C5B267F5F} + + 0 + false + TypeGuards + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {79511612-AC4E-4893-BDD8-E1AC2BCA9E0D} - - 0 - false - DependencyLoader - -1 - - - - - +]]> + {79511612-AC4E-4893-BDD8-E1AC2BCA9E0D} + + 0 + false + DependencyLoader + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + - {B5558958-ED24-4D82-A4C2-8823C8DEDFA4} - - 0 - false - Promise - -1 - - - - - - +]]> + {B5558958-ED24-4D82-A4C2-8823C8DEDFA4} + + 0 + false + Promise + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + - {7425151D-C0F2-43DF-BB72-48FFEAA57BA2} - - 0 - false - t - -1 - - - - - - - +]]> + {7425151D-C0F2-43DF-BB72-48FFEAA57BA2} + + 0 + false + t + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + + - false - - 0 - {BC7BD4DB-5FE1-4C9C-B8A4-1CE5E63FCAD8} - - 0 - false - OrbitClient - -1 - - - - - null - - 0 - false - Event - -1 - - - - - - - +end)]]> + false + + 0 + {BC7BD4DB-5FE1-4C9C-B8A4-1CE5E63FCAD8} + + 0 + false + OrbitClient + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + null + + 0 + false + Event + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + + - {84CDACAC-F18A-46DA-B9DF-C1258CA2406A} - - 0 - false - FastWait - -1 - - - - - +return fastWait]]> + {84CDACAC-F18A-46DA-B9DF-C1258CA2406A} + + 0 + false + FastWait + -1 + yuZpQdnvvUBOTYh1jqZ2cA== + + + + + + + \ No newline at end of file diff --git a/components/settings/general/activity.tsx b/components/settings/general/activity.tsx index fbb1d926..ba0600aa 100644 --- a/components/settings/general/activity.tsx +++ b/components/settings/general/activity.tsx @@ -51,6 +51,44 @@ function SettingField({ ) } +function ToggleRow({ + title, + hint, + checked, + disabled, + onChange, + badge, +}: { + title: string + hint: string + checked: boolean + disabled?: boolean + onChange?: () => void + badge?: string +}) { + return ( +
+
+

+ {title} + {badge ? ( + + {badge} + + ) : null} +

+

{hint}

+
+
+ + {disabled ? "Off" : checked ? "On" : "Off"} + + +
+
+ ) +} + type props = { triggerToast: typeof toast; hasResetActivityOnly?: boolean; @@ -67,11 +105,10 @@ const Activity: FC = (props) => { const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); const [leaderboardEnabled, setLeaderboardEnabled] = useState(false); - const [idleTimeEnabled, setIdleTimeEnabled] = useState(true); - const [leaderboardStyle, setLeaderboardStyle] = useState<"list" | "podium">( - "list" - ); + const [leaderboardStyle, setLeaderboardStyle] = useState<"list" | "podium">("list"); const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [privateServerEnabled, setPrivateServerEnabled] = useState(false); + const [studioEnabled, setStudioEnabled] = useState(false); const [scheduleDay, setScheduleDay] = useState("monday"); const [scheduleFrequency, setScheduleFrequency] = useState("weekly"); const router = useRouter(); @@ -85,7 +122,8 @@ const Activity: FC = (props) => { setRoles(res.data.roles); setSelectedRole(res.data.currentRole); setSelectedLRole(res.data.leaderboardRole); - setIdleTimeEnabled(res.data.idleTimeEnabled ?? true); + setPrivateServerEnabled(res.data.privateServerEnabled); + setStudioEnabled(res.data.studioEnabled); } })(); }, []); @@ -204,18 +242,38 @@ const Activity: FC = (props) => { } }; - const updateIdleTimeEnabled = async (enabled: boolean) => { + const updatePrivateServer = async (enabled: boolean) => { + const previous = privateServerEnabled; + setPrivateServerEnabled(enabled); try { const req = await axios.post( - `/api/workspace/${workspace.groupId}/settings/activity/setIdleTime`, + `/api/workspace/${workspace.groupId}/settings/activity/setprivateserver`, { enabled: enabled } ); if (req.status === 200) { - setIdleTimeEnabled(enabled); - triggerToast.success("Updated idle time tracking!"); + setPrivateServerEnabled(req.data.enabled); + triggerToast.success("Updated Private Server tracking!"); } } catch (error: any) { - triggerToast.error("Failed to update idle time tracking."); + setPrivateServerEnabled(previous); + triggerToast.error("Failed to update private server tracking."); + } + }; + const updateStudio = async (enabled: boolean) => { + const previous = studioEnabled; + setStudioEnabled(enabled); + try { + const req = await axios.post( + `/api/workspace/${workspace.groupId}/settings/activity/setstudio`, + { enabled: enabled } + ); + if (req.status === 200) { + setStudioEnabled(req.data.enabled); + triggerToast.success("Updated Studio tracking!"); + } + } catch (error: any) { + setStudioEnabled(previous); + triggerToast.error("Failed to update studio tracking."); } }; @@ -295,7 +353,7 @@ const Activity: FC = (props) => {

Activity

- Who is tracked, idle detection, and the desktop loader for in-game time. + Who is tracked, settings, and the desktop loader for in-game time.

@@ -308,7 +366,7 @@ const Activity: FC = (props) => { > updateRole(value)} as="div" className="relative"> - {(roles.find((r: any) => r.rank === selectedRole) as any)?.name || "Select a role"} + {(roles.find((r: any) => r.rank === selectedRole) as any)?.name || 'Select a role'} @@ -316,7 +374,7 @@ const Activity: FC = (props) => { listboxOptionClass(active)}> {({ selected }) => ( <> - {role.name} (Group rank: {role.rank}) + {role.name} (Group rank: {role.rank}) {selected && } )} @@ -326,25 +384,37 @@ const Activity: FC = (props) => { -
-
-
-

Idle time tracking

-

Count time when staff are away from keyboard.

-
-
- {idleTimeEnabled ? "On" : "Off"} - updateIdleTimeEnabled(!idleTimeEnabled)} label="" /> -
-
+ +
+ updatePrivateServer(!privateServerEnabled)} + /> + updateStudio(!studioEnabled)} + /> +
+

Activity loader

Install this on your machine to report session time to the workspace.

+ {label ? ( +

{label}

+ ) : null} +
+ ); }; -export default SwitchComponenet; +export default SwitchComponenet; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9ca56f1e..49a51440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "orbit", - "version": "2.1.10beta20", + "version": "2.1.10beta21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orbit", - "version": "2.1.10beta20", + "version": "2.1.10beta21", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/pages/api/activity/bulk-start.ts b/pages/api/activity/bulk-start.ts new file mode 100644 index 00000000..6120c845 --- /dev/null +++ b/pages/api/activity/bulk-start.ts @@ -0,0 +1,159 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@/utils/database"; +import * as noblox from "noblox.js"; +import { getUsername, getThumbnail } from "@/utils/userinfoEngine"; +import { checkSpecificUser } from "@/utils/permissionsManager"; +import { generateSessionTimeMessage } from "@/utils/sessionMessage"; + +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; + +type Data = { + success: boolean; + error?: string; + started?: number[]; + processed?: number; + failed?: number; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); + } + + const { authorization } = req.headers; + const { sessions } = req.body; + + if (!authorization) + return res + .status(400) + .json({ success: false, error: "Authorization key missing" }); + + if (!sessions || !Array.isArray(sessions)) + return res + .status(400) + .json({ success: false, error: "Sessions array is required" }); + + try { + const config = await prisma.config.findFirst({ + where: { + value: { + path: ["key"], + equals: authorization, + }, + }, + }); + + if (!config) { + return res.status(401).json({ success: false, error: "Unauthorized" }); + } + + const groupId = config.workspaceGroupId; + const parsedConfig = JSON.parse(JSON.stringify(config.value)); + + let processed = 0; + let failed = 0; + const started: number[] = []; + + const creations = sessions.map(async (sessionData: any) => { + try { + const { userid, placeid } = sessionData; + + if (!userid || isNaN(userid)) { + failed++; + return; + } + + const existing = await prisma.activitySession.findFirst({ + where: { + userId: BigInt(userid), + active: true, + workspaceGroupId: groupId, + }, + }); + + if (existing) { + started.push(Number(userid)); + processed++; + return; + } + + const userRank = await noblox + .getRankInGroup(groupId, userid) + .catch(() => null); + + if (parsedConfig.role && (!userRank || userRank <= parsedConfig.role)) { + failed++; // not eligible + return; + } + + const username = await getUsername(userid); + const picture = getThumbnail(userid); + + try { + await prisma.user.upsert({ + where: { userid: BigInt(userid) }, + update: { username, picture }, + create: { userid: BigInt(userid), username, picture }, + }); + } catch (error) { + console.error("[ERROR] Failed to upsert user %s:", userid, error); + failed++; + return; + } + + await checkSpecificUser(userid); + + let gameName = null; + if (placeid) { + try { + const universeInfo: any = await noblox.getUniverseInfo(Number(placeid)); + if (universeInfo && universeInfo[0] && universeInfo[0].name) { + gameName = universeInfo[0].name; + } + } catch (error) { + console.log(`[WARNING] Could not fetch universe info for place ${placeid}`); + } + } + + const sessionStartTime = new Date(); + const sessionMessage = generateSessionTimeMessage(gameName, sessionStartTime); + + await prisma.activitySession.create({ + data: { + id: crypto.randomUUID(), + userId: BigInt(userid), + active: true, + startTime: sessionStartTime, + universeId: placeid ? BigInt(placeid) : null, + sessionMessage: sessionMessage, + workspaceGroupId: groupId, + }, + }); + + started.push(Number(userid)); + processed++; + console.log(`[BULK SESSION STARTED] User ${userid} for group ${groupId} - ${sessionMessage}`); + } catch (error) { + console.error("Failed to start session for user %s:", String(sessionData?.userid), error); + failed++; + } + }); + + await Promise.all(creations); + + console.log( + `[BULK START] Processed ${processed} sessions, ${failed} failed for group ${groupId}` + ); + + return res.status(200).json({ success: true, started, processed, failed }); + } catch (error: any) { + console.error("Unexpected error in /api/activity/bulk-start:", error); + return res + .status(500) + .json({ success: false, error: "Internal server error" }); + } +} \ No newline at end of file diff --git a/pages/api/activity/config.ts b/pages/api/activity/config.ts new file mode 100644 index 00000000..f8a1f946 --- /dev/null +++ b/pages/api/activity/config.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getConfig } from "@/utils/configEngine"; + +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; + +type Data = { + success: boolean; + error?: string; + data?: any, +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method != "GET") { + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); + } + const { id } = req.query + + try { + const activityconfig = await getConfig('activity', parseInt(id as string)); + return res.status(200).send({ + success: true, + data: { + minTrackedRank: activityconfig?.role, + privateServerEnabled: activityconfig?.privateServerEnabled, + studioEnabled: activityconfig?.studioEnabled, + } + }); + } catch (err) { + console.error("Unexpected error in /api/activity/config:", err); + return res + .status(500) + .json({ success: false, error: "Internal server error" }); + } +} \ No newline at end of file diff --git a/pages/api/workspace/[id]/settings/activity/download.ts b/pages/api/workspace/[id]/settings/activity/download.ts index f9bf81bc..b79739c5 100644 --- a/pages/api/workspace/[id]/settings/activity/download.ts +++ b/pages/api/workspace/[id]/settings/activity/download.ts @@ -26,12 +26,10 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { let activityconfig = await getConfig("activity", workspaceId); if (!activityconfig?.key) { - activityconfig = { - key: crypto.randomBytes(16).toString("hex"), - }; + const newKey = crypto.randomBytes(16).toString("hex"); + activityconfig = { ...activityconfig, key: newKey }; await setConfig("activity", activityconfig, workspaceId); } - let xml_string: string; const isVercel = !!process.env.VERCEL_URL; @@ -91,7 +89,12 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { let result = xml_string .replace(//g, activityconfig.key) - .replace(//g, currentUrl.origin); + .replace(//g, currentUrl.origin) + .replace(//g, workspaceId.toString()) + .replace(//g, activityconfig?.role ?? '10') + .replace(//g, activityconfig?.privateServerEnabled ?? false) + .replace(//g, activityconfig?.studioEnabled ?? false); + res.setHeader( "Content-Disposition", diff --git a/pages/api/workspace/[id]/settings/activity/getConfig.ts b/pages/api/workspace/[id]/settings/activity/getConfig.ts index 6ecc1ca5..7a52667c 100644 --- a/pages/api/workspace/[id]/settings/activity/getConfig.ts +++ b/pages/api/workspace/[id]/settings/activity/getConfig.ts @@ -10,6 +10,8 @@ type Data = { roles?: any currentRole?: any leaderboardRole?: any + privateServerEnabled?:boolean + studioEnabled?: boolean idleTimeEnabled?: boolean } @@ -38,7 +40,8 @@ export async function handler( roles, currentRole: activityconfig?.role, leaderboardRole: activityconfig?.leaderboardRole, - idleTimeEnabled: activityconfig?.idleTimeEnabled ?? true, + privateServerEnabled: activityconfig?.privateServerEnabled, + studioEnabled: activityconfig?.studioEnabled, success: true, }); } diff --git a/pages/api/workspace/[id]/settings/activity/setIdleTime.ts b/pages/api/workspace/[id]/settings/activity/setIdleTime.ts deleted file mode 100644 index be8540f5..00000000 --- a/pages/api/workspace/[id]/settings/activity/setIdleTime.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' -import { getConfig, setConfig } from '@/utils/configEngine' -import { logAudit } from '@/utils/logs' -import prisma from '@/utils/database'; -import { withPermissionCheck } from '@/utils/permissionsManager' - -type Data = { - success: boolean - error?: string -} - -export default withPermissionCheck(handler, 'admin'); - -export async function handler( - req: NextApiRequest, - res: NextApiResponse -) { - if (req.method !== 'POST') return res.status(405).json({ success: false, error: 'Method not allowed' }) - const workspace = await prisma.workspace.findFirst({ - where: { - groupId: parseInt(req.query.id as string), - } - }); - if (!workspace) return res.status(404).json({ success: false, error: 'Workspace not found' }); - - const activityconfig = await getConfig('activity', parseInt(req.query.id as string)); - const newconfig = { - ...activityconfig, - idleTimeEnabled: req.body.enabled - }; - await setConfig('activity', newconfig, parseInt(req.query.id as string)); - - try { await logAudit(parseInt(req.query.id as string), (req as any).auth?.userId || null, 'settings.activity.idleTime.update', 'activity.idleTime', { before: activityconfig, after: newconfig }); } catch (e) {} - - res.status(200).send({ - success: true, - }); -} diff --git a/pages/api/workspace/[id]/settings/activity/setprivateserver.ts b/pages/api/workspace/[id]/settings/activity/setprivateserver.ts new file mode 100644 index 00000000..acc8c079 --- /dev/null +++ b/pages/api/workspace/[id]/settings/activity/setprivateserver.ts @@ -0,0 +1,41 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from 'next' +import { getConfig, setConfig } from '@/utils/configEngine' +import { logAudit } from '@/utils/logs' +import prisma from '@/utils/database'; +import { withPermissionCheck } from '@/utils/permissionsManager' + +type Data = { + success: boolean + enabled?: boolean + error?: string +} + +export default withPermissionCheck(handler, 'admin'); + +export async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') return res.status(405).json({ success: false, error: 'Method not allowed' }) + const workspace = await prisma.workspace.findFirst({ + where: { + groupId: parseInt(req.query.id as string), + } + }); + if (!workspace) return res.status(404).json({ success: false, error: 'Workspace not found' }); + + const activityconfig = await getConfig('activity', parseInt(req.query.id as string)); + const newconfig = { + ...activityconfig, + privateServerEnabled: Boolean(req.body.enabled) + }; + await setConfig('activity', newconfig, parseInt(req.query.id as string)); + + try { await logAudit(parseInt(req.query.id as string), (req as any).auth?.userId || null, 'settings.activity.privateserver.update', 'activity.privateserver', { before: activityconfig, after: newconfig }); } catch (e) {} + + res.status(200).send({ + success: true, + enabled: req.body.enabled + }); +} diff --git a/pages/api/workspace/[id]/settings/activity/setstudio.ts b/pages/api/workspace/[id]/settings/activity/setstudio.ts new file mode 100644 index 00000000..d14f0a26 --- /dev/null +++ b/pages/api/workspace/[id]/settings/activity/setstudio.ts @@ -0,0 +1,41 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from 'next' +import { getConfig, setConfig } from '@/utils/configEngine' +import { logAudit } from '@/utils/logs' +import prisma from '@/utils/database'; +import { withPermissionCheck } from '@/utils/permissionsManager' + +type Data = { + success: boolean, + enabled?: boolean, + error?: string +} + +export default withPermissionCheck(handler, 'admin'); + +export async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') return res.status(405).json({ success: false, error: 'Method not allowed' }) + const workspace = await prisma.workspace.findFirst({ + where: { + groupId: parseInt(req.query.id as string), + } + }); + if (!workspace) return res.status(404).json({ success: false, error: 'Workspace not found' }); + + const activityconfig = await getConfig('activity', parseInt(req.query.id as string)); + const newconfig = { + ...activityconfig, + studioEnabled: Boolean(req.body.enabled) + }; + await setConfig('activity', newconfig, parseInt(req.query.id as string)); + + try { await logAudit(parseInt(req.query.id as string), (req as any).auth?.userId || null, 'settings.activity.studio.update', 'activity.studio', { before: activityconfig, after: newconfig }); } catch (e) {} + + res.status(200).send({ + success: true, + enabled: req.body.enabled + }); +}