diff --git a/code/controllers/configuration/sections/system_configuration.dm b/code/controllers/configuration/sections/system_configuration.dm index 32a8ce6dd2b..4e9393ed734 100644 --- a/code/controllers/configuration/sections/system_configuration.dm +++ b/code/controllers/configuration/sections/system_configuration.dm @@ -14,6 +14,8 @@ var/api_host = null /// Internal API key var/api_key = null + /// Github API token + var/github_api_token = null /// List of IP addresses which bypass world topic rate limiting var/list/topic_ip_ratelimit_bypass = list() /// Server instance ID @@ -45,6 +47,7 @@ CONFIG_LOAD_STR(shutdown_shell_command, data["shutdown_shell_command"]) CONFIG_LOAD_STR(api_host, data["api_host"]) CONFIG_LOAD_STR(api_key, data["api_key"]) + CONFIG_LOAD_STR(github_api_token, data["github_api_token"]) CONFIG_LOAD_LIST(topic_ip_ratelimit_bypass, data["topic_ip_ratelimit_bypass"]) @@ -54,6 +57,7 @@ CONFIG_LOAD_STR(override_map, data["override_map"]) CONFIG_LOAD_STR(ytdlp_url, data["ytdlp_url"]) + // Load region overrides if(islist(data["regional_servers"])) region_map.Cut() diff --git a/code/datums/bug_report.dm b/code/datums/bug_report.dm new file mode 100644 index 00000000000..c918d70cbdf --- /dev/null +++ b/code/datums/bug_report.dm @@ -0,0 +1,222 @@ +GLOBAL_LIST_EMPTY(bug_reports) +GLOBAL_LIST_EMPTY(bug_report_time) + +// Datum for handling bug reports +#define STATUS_SUCCESS 201 + +/datum/tgui_bug_report_form + /// contains all the body text for the bug report. + var/list/bug_report_data = null + + /// client of the bug report author, needed to create the ticket + var/initial_user_uid = null + // ckey of the author + var/initial_key = null // just incase they leave after creating the bug report + + /// client of the admin/dev who is accessing the report, we don't want multiple people unknowingly making changes at the same time. + var/client/approving_user = null + + /// value to determine if the bug report is submitted and awaiting admin/dev approval, used for state purposes in tgui. + var/awaiting_approval = FALSE + + /// for garbage collection purposes. + var/selected_confirm = FALSE + + /// byond version of the user, so we still have the byond version if the user logs out + var/user_byond_version + + /// Current Server commit + var/local_commit + + /// Current test merges formatted for the bug report + var/test_merges + +/datum/tgui_bug_report_form/New(mob/user) + local_commit = GLOB.revision_info.commit_hash + initial_user_uid = user.client.UID() + initial_key = user.client.key + user_byond_version = "[user.client.byond_version].[user.client.byond_build]" + if(length(GLOB.revision_info.origin_commit)) + local_commit = GLOB.revision_info.origin_commit + for(var/datum/tgs_revision_information/test_merge/tm in GLOB.revision_info.testmerges) + test_merges += "#[tm.number] at [tm.head_commit]\n" + + +/datum/tgui_bug_report_form/proc/external_link_prompt(client/user) + tgui_alert(user, "Unable to create a bug report at this time, please create the issue directly through our GitHub repository instead") + var/url = "https://github.com/ParadiseSS13/Paradise" + + if(tgui_alert(user, "This will open the GitHub in your browser. Are you sure?", "Confirm", list("Yes", "No")) == "Yes") + user << link(url) + +/datum/tgui_bug_report_form/ui_state() + return GLOB.always_state + +/datum/tgui_bug_report_form/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "BugReportForm") + ui.open() + +/datum/tgui_bug_report_form/ui_close(mob/user) + . = ..() + var/client/initial_user = locateUID(initial_user_uid) + if(!approving_user && user.client == initial_user && !selected_confirm) // user closes the ui without selecting confirm or approve. + qdel(src) + return + approving_user = null + selected_confirm = FALSE + +/datum/tgui_bug_report_form/Destroy() + GLOB.bug_reports -= src + return ..() + +/datum/tgui_bug_report_form/proc/sanitize_payload(list/params) + for(var/param in params) + params[param] = html_decode(sanitize(params[param], list("\t"=" ","�"=" ","<"=" ",">"=" ","&"=" "))) + + return params + +// whether or not an admin/dev can access the record at a given time. +/datum/tgui_bug_report_form/proc/assign_approver(mob/user) + if(!initial_key) + to_chat(user, "Unable to identify the author of the bug report.") + return FALSE + if(approving_user) + if(user.client == approving_user) + to_chat(user, "This bug report review is already opened and accessed by you.") + else + to_chat(user, "Another staff member is currently accessing this report, please wait for them to finish before making any changes.") + return FALSE + if(!check_rights(R_VIEWRUNTIMES|R_ADMIN|R_DEBUG, user = user)) + message_admins("[user.ckey] has attempted to review [initial_key]'s bug report titled [bug_report_data["title"]] without proper authorization at [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss")].") + return FALSE + + approving_user = user.client + return TRUE + +// returns the body payload +/datum/tgui_bug_report_form/proc/create_form() + var/desc = {" +## What did you expect to happen? +[bug_report_data["expected_behavior"]] + +## What happened instead? +[bug_report_data["description"]] + +## Why is this bad/What are the consequences? +[bug_report_data["consequences"]] + +## Steps to reproduce the issue: +[bug_report_data["steps"]] + +## Attached logs +``` +[bug_report_data["log"] ? bug_report_data["log"] : "N/A"] +``` + +## Additional details +- Author: [initial_key] +- Approved By: [approving_user] +- Round ID: [GLOB.round_id ? GLOB.round_id : "N/A"] +- Client BYOND Version: [user_byond_version] +- Server BYOND Version: [world.byond_version].[world.byond_build] +- Server commit: [local_commit] +- Active Test Merges: [test_merges ? test_merges : "None"] +- Note: [bug_report_data["approver_note"] ? bug_report_data["approver_note"] : "None"] + "} + + return desc + +// the real deal, we are sending the request through the api. +/datum/tgui_bug_report_form/proc/send_request(payload_body, client/user) + // for any future changes see https://docs.github.com/en/rest/issues/issues + var/repo_name = "Paradise" + var/org = "ParadiseSS13" + var/token = GLOB.configuration.system.github_api_token + + if(token == null) + tgui_alert(user, "The configuration is not set for the external API.", "Issue not reported!") + external_link_prompt(user) + qdel(src) + return + + var/url = "https://api.github.com/repos/[org]/[repo_name]/issues" + var/list/headers = list() + headers["Authorization"] = "Bearer [token]" + headers["Content-Type"] = "text/markdown; charset=utf-8" + headers["Accept"] = "application/vnd.github+json" + + var/datum/http_request/request = new() + var/list/payload = list( + "title" = bug_report_data["title"], + "body" = payload_body, + "labels" = list("Bug") + ) + + request.prepare(RUSTLIBS_HTTP_METHOD_POST, url, json_encode(payload), headers) + request.begin_async() + var/start_time = world.time + UNTIL(request.is_complete() || (world.time > start_time + 5 SECONDS)) + if(!request.is_complete() && world.time > start_time + 5 SECONDS) + CRASH("bug report HTML request hit timeout limit of 5 seconds"); + + var/datum/http_response/response = request.into_response() + if(response.errored || response.status_code != STATUS_SUCCESS) + message_admins("The GitHub API has failed to create the bug report titled [bug_report_data["title"]] approved by [approving_user], status code:[response.status_code]. Please paste this error code into the development channel on discord.") + external_link_prompt(user) + else + var/client/initial_user = locateUID(initial_user_uid) + message_admins("[user.ckey] has approved a bug report from [initial_key] titled [bug_report_data["title"]] at [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss")].") + to_chat(initial_user, "An admin has successfully submitted your report and it should now be visible on GitHub. Thanks again!") + qdel(src)// approved and submitted, we no longer need the datum. + +// proc that creates a ticket for an admin to approve or deny a bug report request +/datum/tgui_bug_report_form/proc/bug_report_request() + var/client/initial_user = locateUID(initial_user_uid) + if(initial_user) + to_chat(initial_user, "Your bug report has been submitted, thank you!") + GLOB.bug_reports += src + + var/general_message = "[initial_key] has created a bug report which is now pending approval. The report can be viewed using \"View Bug Reports\" in the debug tab. " + message_admins(general_message) + +/datum/tgui_bug_report_form/ui_act(action, list/params, datum/tgui/ui) + . = ..() + if(.) + return + var/mob/user = ui.user + switch(action) + if("confirm") + if(selected_confirm) // prevent someone from spamming the approve button + to_chat(user, "You have already approved this submission, please wait a moment for the API to process your submission.") + return + bug_report_data = sanitize_payload(params) + selected_confirm = TRUE + // bug report request is now waiting for admin approval + if(!awaiting_approval) + bug_report_request() + GLOB.bug_report_time[user.ckey] = world.time + awaiting_approval = TRUE + else // otherwise it's been approved + var/payload_body = create_form() + send_request(payload_body, user.client) + if("cancel") + if(awaiting_approval) // admin has chosen to reject the bug report + reject(user.client) + qdel(src) + ui.close() + . = TRUE + +/datum/tgui_bug_report_form/ui_data(mob/user) + . = list() + .["report_details"] = bug_report_data // only filled out once the user as submitted the form + .["awaiting_approval"] = awaiting_approval + +/datum/tgui_bug_report_form/proc/reject(client/user) + message_admins("[user.ckey] has rejected a bug report from [initial_key] titled [bug_report_data["title"]] at [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss")].") + var/client/initial_user = locateUID(initial_user_uid) + if(initial_user) + to_chat(initial_user_uid, "A staff member has rejected your bug report, this can happen for several reasons. They will most likely get back to you shortly regarding your issue.") + +#undef STATUS_SUCCESS diff --git a/code/game/verbs/ooc.dm b/code/game/verbs/ooc.dm index ed460cdf7da..9947b3d0976 100644 --- a/code/game/verbs/ooc.dm +++ b/code/game/verbs/ooc.dm @@ -1,4 +1,5 @@ #define DEFAULT_PLAYER_OOC_COLOUR "#075FE5" // Can't initial() a global so we store the default in a macro instead +#define BUG_REPORT_CD (5 MINUTES) GLOBAL_VAR_INIT(normal_ooc_colour, DEFAULT_PLAYER_OOC_COLOUR) GLOBAL_VAR_INIT(member_ooc_colour, "#035417") @@ -330,4 +331,25 @@ GLOBAL_VAR_INIT(admin_ooc_colour, "#b82e00") popup.set_content(output.Join("")) popup.open() +/client/verb/submitbug() + set name = "Report a Bug" + set desc = "Submit a bug report." + set category = "OOC" + set hidden = TRUE + if(!usr?.client) + return + + if(GLOB.bug_report_time[usr.ckey] && world.time < (GLOB.bug_report_time[usr.client] + BUG_REPORT_CD)) + var/cd_total_time = GLOB.bug_report_time[usr.ckey] + BUG_REPORT_CD - world.time + var/cd_minutes = round(cd_total_time / (1 MINUTES)) + var/cd_seconds = round((cd_total_time - cd_minutes MINUTES) / (1 SECONDS)) + tgui_alert(usr, "You must wait another [cd_minutes]:[cd_seconds < 10 ? "0" : ""][cd_seconds] minute[cd_minutes < 2 ? "" : "s"] before submitting another bug report", "Bug Report Rate Limit") + return + + var/datum/tgui_bug_report_form/report = new(usr) + + report.ui_interact(usr) + return + #undef DEFAULT_PLAYER_OOC_COLOUR +#undef BUG_REPORT_CD diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 38df2c1a783..59dc07d37bb 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -187,6 +187,7 @@ GLOBAL_LIST_INIT(admin_verbs_debug, list( /client/proc/debug_bloom, /client/proc/cmd_mass_screenshot, /client/proc/allow_browser_inspect, + /client/proc/view_bug_reports, )) GLOBAL_LIST_INIT(admin_verbs_possess, list( /proc/possess, diff --git a/code/modules/admin/verbs/debug.dm b/code/modules/admin/verbs/debug.dm index 0d54cfb624f..9213fe72adb 100644 --- a/code/modules/admin/verbs/debug.dm +++ b/code/modules/admin/verbs/debug.dm @@ -667,7 +667,7 @@ GLOBAL_PROTECT(AdminProcCallSpamPrevention) dresscode = custom_names[selected_name] if(isnull(dresscode)) return - + if(dresscode == "Recover destroyed body...") dresscode = input("Select body to rebuild", "Robust quick dress shop") as null|anything in potential_minds @@ -923,3 +923,22 @@ GLOBAL_PROTECT(AdminProcCallSpamPrevention) CHECK_TICK log_and_message_admins_no_usr("The world has been decontaminated of [counter] radiation components.") + +/client/proc/view_bug_reports() + set name = "View Bug Reports" + set desc = "Select a bug report to view" + set category = "Debug" + if(!check_rights(R_DEBUG|R_VIEWRUNTIMES|R_ADMIN)) + return + if(!length(GLOB.bug_reports)) + to_chat(usr, "There are no bug reports to view") + return + var/list/bug_report_selection = list() + for(var/datum/tgui_bug_report_form/report in GLOB.bug_reports) + bug_report_selection["[report.initial_key] - [report.bug_report_data["title"]]"] = report + var/datum/tgui_bug_report_form/form = bug_report_selection[tgui_input_list(usr, "Select a report to view:", "Bug Reports", bug_report_selection)] + if(!form.assign_approver(usr)) + return + form.ui_interact(usr) + return + diff --git a/config/example/config.toml b/config/example/config.toml index fde64003b45..ca96a3bf90c 100644 --- a/config/example/config.toml +++ b/config/example/config.toml @@ -764,6 +764,8 @@ shutdown_on_reboot = false #api_host = "http://127.0.0.1:8080" # Access key for the internal API. #api_key = "your_secret_here" +# Github API token for in game bug reporting +#github_api_token = "your_secret_here" # List of IP addresses to be ignored by the world/Topic rate limiting. Useful if you have other services topic_ip_ratelimit_bypass = ["127.0.0.1"] # Turn this to true if you are running a production server diff --git a/paradise.dme b/paradise.dme index cfc61e54845..be250c27491 100644 --- a/paradise.dme +++ b/paradise.dme @@ -404,6 +404,7 @@ #include "code\datums\atom_hud.dm" #include "code\datums\beam.dm" #include "code\datums\browser.dm" +#include "code\datums\bug_report.dm" #include "code\datums\callback.dm" #include "code\datums\card_deck_table_tracker.dm" #include "code\datums\chat_payload.dm" diff --git a/tgui/packages/tgui/interfaces/BugReportForm.tsx b/tgui/packages/tgui/interfaces/BugReportForm.tsx new file mode 100644 index 00000000000..eb4a85cf5bc --- /dev/null +++ b/tgui/packages/tgui/interfaces/BugReportForm.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react'; +import { useBackend } from 'tgui/backend'; +import { Window } from 'tgui/layouts'; +import { Flex, Section } from 'tgui-core/components'; +import type { BooleanLike } from 'tgui-core/react'; +interface FormTypes { + awaiting_approval: BooleanLike; + report_details: FormDetails; +} + +// all the information necessary to pass into the github api +type FormDetails = { + steps: string; + title: string; + description: string; + expected_behavior: string; + consequences: string; + approver_note: string; + log: string; +}; + +const InputTitle = (props) => { + return ( +

+ {props.children} + {props.required && {' *'}} +

+ ); +}; + +export const BugReportForm = (props) => { + const { act, data } = useBackend(); + const { awaiting_approval, report_details } = data; + const [checkBox, setCheckbox] = useState(false); + + const [title, setTitle] = useState(report_details?.title || ''); + const [steps, setSteps] = useState(report_details?.steps || ''); + const [description, setDescription] = useState(report_details?.description || ''); + const [expected_behavior, setExpectedBehavior] = useState(report_details?.expected_behavior || ''); + const [consequences, setConsequences] = useState(report_details?.consequences || ''); + const [approver_note, setApproverNote] = useState(report_details?.approver_note || ''); + const [log, setLog] = useState(report_details?.log || ''); + + const submit = () => { + if (!title || !description || !expected_behavior || !steps || !consequences) { + alert('Please fill out all required fields!'); + return; + } + const updatedReportDetails = { + title, + expected_behavior, + description, + consequences, + steps, + log, + approver_note, + }; + act('confirm', updatedReportDetails); + }; + + return ( + + +
+ + + + GitHub Repository + + + +

{'TIP: please be as descriptive as possible, it really does help tremendously'}

+
+ + {'Title'} + setTitle(e.target.value)} /> + + + {'What did you expect to happen?'} + {'Give a description of what you expected to happen'} +