From bfbdefce3339e68e6dd0b25a0cd1641cae3c9c63 Mon Sep 17 00:00:00 2001 From: Y0SH1M4S73R Date: Tue, 1 Apr 2025 16:12:03 -0400 Subject: [PATCH] Tgui payload chunking (#90295) ## About The Pull Request Dream Seeker will not send topic calls greater than 2kb in size. There are cases where tgui will attempt to send `ui_act` payloads larger than this, such as writing on paper. This PR takes payloads that would be larger than 2kb, splits them into payloads that would be roughly 1kb (after URL encoding), and sends them to the server in sequence. To prevent abuse and/or topic spam, a config option has been added to put a limit on the number of chunks for which the server will accept a payload, defaulting to 10. ## Why It's Good For The Game Fixes #90056, along with several other things that were affected by the change to WebView2 in 516. ## Changelog :cl: code: Any tgui message that would be too big to send to the server is now split into chunks and sent in sequence. This fixes several issues, such as... fix: It is once again possible to save large amounts of text on paper at once. /:cl: --------- Co-authored-by: Lucy --- .../configuration/entries/general.dm | 7 + code/modules/tgui/tgui_window.dm | 42 +++++ config/config.txt | 4 + tgui/packages/tgui/backend.ts | 155 +++++++++++++++++- tools/build/build.js | 1 + 5 files changed, 208 insertions(+), 1 deletion(-) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 1c90e9ccc0c..a5f9e33341e 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -775,3 +775,10 @@ /// If admins with +DEBUG can queue byond-tracy to run the next round. /datum/config_entry/flag/allow_tracy_queue protection = CONFIG_ENTRY_LOCKED + +/** + * Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size. + * This flag represents the maximum chunk count the server is willing to receive. + */ +/datum/config_entry/number/tgui_max_chunk_count + default = 32 diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index 7fa6faecc68..38c49548b64 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -26,6 +26,8 @@ var/initial_inline_js var/initial_inline_css + var/list/oversized_payloads = list() + /** * public * @@ -380,6 +382,46 @@ reinitialize() if("chat/resend") SSchat.handle_resend(client, payload) + if("oversizedPayloadRequest") + var/payload_id = payload["id"] + var/chunk_count = payload["chunkCount"] + var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count) + if(permit_payload) + create_oversized_payload(payload_id, payload["type"], chunk_count) + send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id)) + if("payloadChunk") + var/payload_id = payload["id"] + append_payload_chunk(payload_id, payload["chunk"]) + send_message("acknowlegePayloadChunk", list("id" = payload_id)) /datum/tgui_window/vv_edit_var(var_name, var_value) return var_name != NAMEOF(src, id) && ..() + +/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count) + if(oversized_payloads[payload_id]) + stack_trace("Attempted to create oversized tgui payload with duplicate ID.") + return + oversized_payloads[payload_id] = list( + "type" = message_type, + "count" = chunk_count, + "chunks" = list(), + "timeout" = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + ) + +/datum/tgui_window/proc/append_payload_chunk(payload_id, chunk) + var/list/payload = oversized_payloads[payload_id] + if(!payload) + return + var/list/chunks = payload["chunks"] + chunks += chunk + if(length(chunks) >= payload["count"]) + deltimer(payload["timeout"]) + var/message_type = payload["type"] + var/final_payload = chunks.Join() + remove_oversized_payload(payload_id) + on_message(message_type, json_decode(final_payload), list("type" = message_type, "payload" = final_payload, "tgui" = TRUE, "window_id" = id)) + else + payload["timeout"] = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + +/datum/tgui_window/proc/remove_oversized_payload(payload_id) + oversized_payloads -= payload_id diff --git a/config/config.txt b/config/config.txt index f0aa37f8047..daf5a0c1b28 100644 --- a/config/config.txt +++ b/config/config.txt @@ -638,3 +638,7 @@ UPLOAD_LIMIT_ADMIN 5242880 ## Uncomment to allow admins with +DEBUG to queue the next round to run the byond-tracy profiler. #ALLOW_TRACY_QUEUE + +## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence. +## This config option limits the maximum chunk count for which the server will accept a payload, default is 32 +TGUI_MAX_CHUNK_COUNT 32 diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index 1a0529374b4..d786d7635cc 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -31,6 +31,16 @@ export const setGlobalStore = (store) => { export const backendUpdate = createAction('backend/update'); export const backendSetSharedState = createAction('backend/setSharedState'); export const backendSuspendStart = createAction('backend/suspendStart'); +export const backendCreatePayloadQueue = createAction( + 'backend/createPayloadQueue', +); +export const backendDequeuePayloadQueue = createAction( + 'backend/dequeuePayloadQueue', +); +export const backendRemovePayloadQueue = createAction( + 'backend/removePayloadQueue', +); +export const nextPayloadChunk = createAction('nextPayloadChunk'); export const backendSuspendSuccess = () => ({ type: 'backend/suspendSuccess', @@ -43,6 +53,7 @@ const initialState = { config: {}, data: {}, shared: {}, + outgoingPayloadQueues: {} as Record, // Start as suspended suspended: Date.now(), suspending: false, @@ -119,6 +130,44 @@ export const backendReducer = (state = initialState, action) => { }; } + if (type === 'backend/createPayloadQueue') { + const { id, chunks } = payload; + const { outgoingPayloadQueues } = state; + return { + ...state, + outgoingPayloadQueues: { + ...outgoingPayloadQueues, + [id]: chunks, + }, + }; + } + + if (type === 'backend/dequeuePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: targetQueue, ...otherQueues } = outgoingPayloadQueues; + const [_, ...rest] = targetQueue; + return { + ...state, + outgoingPayloadQueues: rest.length + ? { + ...otherQueues, + [id]: rest, + } + : otherQueues, + }; + } + + if (type === 'backend/removePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: _, ...otherQueues } = outgoingPayloadQueues; + return { + ...state, + outgoingPayloadQueues: otherQueues, + }; + } + return state; }; @@ -127,7 +176,9 @@ export const backendMiddleware = (store) => { let suspendInterval; return (next) => (action) => { - const { suspended } = selectBackend(store.getState()); + const { suspended, outgoingPayloadQueues } = selectBackend( + store.getState(), + ); const { type, payload } = action; if (type === 'update') { @@ -212,10 +263,86 @@ export const backendMiddleware = (store) => { }); } + if (type === 'oversizePayloadResponse') { + const { allow } = payload; + if (allow) { + store.dispatch(nextPayloadChunk(payload)); + } else { + store.dispatch(backendRemovePayloadQueue(payload)); + } + } + + if (type === 'acknowlegePayloadChunk') { + store.dispatch(backendDequeuePayloadQueue(payload)); + store.dispatch(nextPayloadChunk(payload)); + } + + if (type === 'nextPayloadChunk') { + const { id } = payload; + const chunk = outgoingPayloadQueues[id][0]; + Byond.sendMessage('payloadChunk', { + id, + chunk, + }); + } + return next(action); }; }; +const encodedLengthBinarySearch = (haystack: string[], length: number) => { + const haystackLength = haystack.length; + let high = haystackLength - 1; + let low = 0; + let mid = 0; + while (low < high) { + mid = Math.round((low + high) / 2); + const substringLength = encodeURIComponent( + haystack.slice(0, mid).join(''), + ).length; + if (substringLength === length) { + break; + } + if (substringLength < length) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return mid; +}; + +const chunkSplitter = { + [Symbol.split]: (string: string) => { + const charSeq = string[Symbol.iterator]().toArray(); + const length = charSeq.length; + let chunks: string[] = []; + let startIndex = 0; + let endIndex = 1024; + while (startIndex < length) { + const cut = charSeq.slice( + startIndex, + endIndex < length ? endIndex : undefined, + ); + const cutString = cut.join(''); + if (encodeURIComponent(cutString).length > 1024) { + const splitIndex = startIndex + encodedLengthBinarySearch(cut, 1024); + chunks.push( + charSeq + .slice(startIndex, splitIndex < length ? splitIndex : undefined) + .join(''), + ); + startIndex = splitIndex; + } else { + chunks.push(cutString); + startIndex = endIndex; + } + endIndex = startIndex + 1024; + } + return chunks; + }, +}; + /** * Sends an action to `ui_act` on `src_object` that this tgui window * is associated with. @@ -230,6 +357,31 @@ export const sendAct = (action: string, payload: object = {}) => { logger.error(`Payload for act() must be an object, got this:`, payload); return; } + if (!Byond.TRIDENT) { + const stringifiedPayload = JSON.stringify(payload); + const urlSize = Object.entries({ + type: 'act/' + action, + payload: stringifiedPayload, + tgui: 1, + windowId: Byond.windowId, + }).reduce( + (url, [key, value], i) => + url + + `${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + '', + ).length; + if (urlSize > 2048) { + let chunks: string[] = stringifiedPayload.split(chunkSplitter); + const id = `${Date.now()}`; + globalStore?.dispatch(backendCreatePayloadQueue({ id, chunks })); + Byond.sendMessage('oversizedPayloadRequest', { + type: 'act/' + action, + id, + chunkCount: chunks.length, + }); + return; + } + } Byond.sendMessage('act/' + action, payload); }; @@ -260,6 +412,7 @@ type BackendState = { }; data: TData; shared: Record; + outgoingPayloadQueues: Record; suspending: boolean; suspended: boolean; }; diff --git a/tools/build/build.js b/tools/build/build.js index 915c24b3344..6c3fbee4b4b 100644 --- a/tools/build/build.js +++ b/tools/build/build.js @@ -222,6 +222,7 @@ export const DmTarget = new Juke.Target({ 'icons/**', 'interface/**', 'sound/**', + 'tgui/public/tgui.html', `${DME_NAME}.dme`, NamedVersionFile, ],