From a8d6dbc3241c880f846a7abba8bd330788df9fc1 Mon Sep 17 00:00:00 2001 From: beck-thompson <107373427+beck-thompson@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:10:38 -0700 Subject: [PATCH] Added button and manager for in game bug reports (Part 1) (#35350) * Added the button and manager * Minor cleanup * Reigstered to the wrong thing! * Unload UI * Address the review * First commit :) * Some cleanup * Added some comments and now the placehoder text goes away once you start typing * Some cleanup and better test command * Basic rate limiter class (Not finished) * Cleanup * Removed forgotten comment xD * Whitespace removal * Minor cleanup, cvar hours -> minutes * More minor tweaks * Don't cache timer and add examples to fields * Added CCvar for time between bug reports * Minor crash when restarting rounds fixed * It compiled on my computer! * Fix comment indents * Remove unecessary async, removed magic strings, simplfied sawmill to not use post inject * Make struct private * Simplfiy TryGetLongHeader * Changed list to enumerable * URI cleanup * Got rid of the queue, used a much better way! * Made the comments a little better and fix some issues with them * Added header consts * Maximum reports per round is now an error message * Time between reports is now in seconds * Change ordering * Change hotkey to O * only update window when its open * Split up validation * address review * Address a few issues * inheritance fix * API now doesn't keep track of requests, just uses the rate limited response from github * Rough idea of how channels would work * refactor: reorganized code, placed rate limiter into http-client-handler AND manager (usually only manager-one should work) * cleanup * Add user agent so api doesn't get mad * Better error logs * Cleanup * It now throws! * refactor: renaming, moved some methods, xml-doc cleanups * refactor: BugReportWindow formatted to convention, enforced 1 updates only 1 per sec * Add very basic licence info * Fixed the issues! * Set ccvar default to false * make the button better * fix test fail silly me * Adress the review! * refactor: cleanup of entry point code, binding server-side code with client-facing manager * Resolve the other issues and cleanup and stuff smile :) * not entity * fixes * Cleanup * Cleanup * forgor region * fixes * Split up function and more stuff * Better unsubs yaygit add -A * I pray... * Revert "I pray..." This reverts commit 9629fb4f1289c9009a03e4e4facd9ae975e6303e. * I think I have to add it in the pr * Revert "I think I have to add it in the pr" This reverts commit e185b42f570fe5f0f51e0e44761d7938e22e67f7. * Tweaks * Minor tweak to permissions --------- Co-authored-by: pa.pecherskij --- .../BugReport/BugReportUIController.cs | 113 +++++ .../BugReport/Windows/BugReportWindow.xaml | 31 ++ .../BugReport/Windows/BugReportWindow.xaml.cs | 181 ++++++++ .../MenuBar/GameTopMenuBarUIController.cs | 4 + .../MenuBar/Widgets/GameTopMenuBar.xaml | 9 + Content.Server/BugReports/BugReportManager.cs | 221 ++++++++++ Content.Server/BugReports/IBugReportEvents.cs | 94 ++++ .../BugReports/IBugReportManager.cs | 22 + Content.Server/Entry/EntryPoint.cs | 10 +- .../GameTicking/GameTicker.RoundFlow.cs | 2 + Content.Server/GameTicking/GameTicker.cs | 2 + .../Github/Commands/TestGithubApiCommand.cs | 71 +++ Content.Server/Github/GithubApiManager.cs | 53 +++ .../Github/GithubBackgroundWorker.cs | 74 ++++ Content.Server/Github/GithubClient.cs | 417 ++++++++++++++++++ .../Github/Requests/CreateIssueRequest.cs | 38 ++ .../Github/Requests/IGithubRequest.cs | 44 ++ .../Github/Requests/InstallationsRequest.cs | 18 + .../Github/Requests/TokenRequest.cs | 22 + .../Github/Responses/InstallationResponse.cs | 21 + .../Github/Responses/TokenResponse.cs | 15 + Content.Server/Github/RetryHttpHandler.cs | 100 +++++ Content.Server/IoC/ServerContentIoC.cs | 7 +- Content.Shared.Database/LogType.cs | 6 + Content.Shared/BugReport/BugReportMessage.cs | 42 ++ Content.Shared/CCVar/CCVars.BugReports.cs | 64 +++ Content.Shared/CCVar/CCVars.Github.cs | 71 +++ Resources/Locale/en-US/HUD/game-hud.ftl | 1 + .../en-US/bugreport/bug-report-report.ftl | 1 + .../en-US/bugreport/bug-report-window.ftl | 13 + Resources/Locale/en-US/github/github-api.ftl | 36 ++ Resources/Textures/Interface/bug.svg | 13 + .../Textures/Interface/bug.svg.192dpi.png | Bin 0 -> 1852 bytes .../Textures/Interface/bug.svg.192dpi.png.yml | 2 + Resources/Textures/Interface/splat.svg | 14 + .../Textures/Interface/splat.svg.192dpi.png | Bin 0 -> 2069 bytes .../Interface/splat.svg.192dpi.png.yml | 2 + 37 files changed, 1831 insertions(+), 3 deletions(-) create mode 100644 Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs create mode 100644 Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml create mode 100644 Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs create mode 100644 Content.Server/BugReports/BugReportManager.cs create mode 100644 Content.Server/BugReports/IBugReportEvents.cs create mode 100644 Content.Server/BugReports/IBugReportManager.cs create mode 100644 Content.Server/Github/Commands/TestGithubApiCommand.cs create mode 100644 Content.Server/Github/GithubApiManager.cs create mode 100644 Content.Server/Github/GithubBackgroundWorker.cs create mode 100644 Content.Server/Github/GithubClient.cs create mode 100644 Content.Server/Github/Requests/CreateIssueRequest.cs create mode 100644 Content.Server/Github/Requests/IGithubRequest.cs create mode 100644 Content.Server/Github/Requests/InstallationsRequest.cs create mode 100644 Content.Server/Github/Requests/TokenRequest.cs create mode 100644 Content.Server/Github/Responses/InstallationResponse.cs create mode 100644 Content.Server/Github/Responses/TokenResponse.cs create mode 100644 Content.Server/Github/RetryHttpHandler.cs create mode 100644 Content.Shared/BugReport/BugReportMessage.cs create mode 100644 Content.Shared/CCVar/CCVars.BugReports.cs create mode 100644 Content.Shared/CCVar/CCVars.Github.cs create mode 100644 Resources/Locale/en-US/bugreport/bug-report-report.ftl create mode 100644 Resources/Locale/en-US/bugreport/bug-report-window.ftl create mode 100644 Resources/Locale/en-US/github/github-api.ftl create mode 100644 Resources/Textures/Interface/bug.svg create mode 100644 Resources/Textures/Interface/bug.svg.192dpi.png create mode 100644 Resources/Textures/Interface/bug.svg.192dpi.png.yml create mode 100644 Resources/Textures/Interface/splat.svg create mode 100644 Resources/Textures/Interface/splat.svg.192dpi.png create mode 100644 Resources/Textures/Interface/splat.svg.192dpi.png.yml diff --git a/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs b/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs new file mode 100644 index 0000000000..7d45829bd2 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs @@ -0,0 +1,113 @@ +using Content.Client.Gameplay; +using Content.Client.Resources; +using Content.Client.UserInterface.Controls; +using Content.Client.UserInterface.Systems.BugReport.Windows; +using Content.Client.UserInterface.Systems.MenuBar.Widgets; +using Content.Shared.BugReport; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.Controllers; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Configuration; +using Robust.Shared.Network; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Systems.BugReport; + +[UsedImplicitly] +public sealed class BugReportUIController : UIController, IOnStateEntered, IOnStateExited +{ + [Dependency] private readonly IClientNetManager _net = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceCache _resource = default!; + + // This is the link to the hotbar button + private MenuButton? BugReportButton => UIManager.GetActiveUIWidgetOrNull()?.ReportBugButton; + + // Don't clear this window. It needs to be saved so the input doesn't get erased when it's closed! + private BugReportWindow _bugReportWindow = default!; + + private ResPath Bug = new("/Textures/Interface/bug.svg.192dpi.png"); + private ResPath Splat = new("/Textures/Interface/splat.svg.192dpi.png"); + + public void OnStateEntered(GameplayState state) + { + SetupWindow(); + } + + public void OnStateExited(GameplayState state) + { + CleanupWindow(); + } + + public void LoadButton() + { + if (BugReportButton != null) + BugReportButton.OnPressed += ButtonToggleWindow; + } + + public void UnloadButton() + { + if (BugReportButton != null) + BugReportButton.OnPressed -= ButtonToggleWindow; + } + + private void SetupWindow() + { + if (BugReportButton == null) + return; + + _bugReportWindow = UIManager.CreateWindow(); + // This is to make sure the hotbar button gets checked and unchecked when the window is opened / closed. + _bugReportWindow.OnClose += () => + { + BugReportButton.Pressed = false; + BugReportButton.Icon = _resource.GetTexture(Bug); + }; + _bugReportWindow.OnOpen += () => + { + BugReportButton.Pressed = true; + BugReportButton.Icon = _resource.GetTexture(Splat); + }; + + _bugReportWindow.OnBugReportSubmitted += OnBugReportSubmitted; + + _cfg.OnValueChanged(CCVars.EnablePlayerBugReports, UpdateButtonVisibility, true); + } + + private void CleanupWindow() + { + _bugReportWindow.CleanupCCvars(); + + _cfg.UnsubValueChanged(CCVars.EnablePlayerBugReports, UpdateButtonVisibility); + } + + private void ToggleWindow() + { + if (_bugReportWindow.IsOpen) + _bugReportWindow.Close(); + else + _bugReportWindow.OpenCentered(); + } + + private void OnBugReportSubmitted(PlayerBugReportInformation report) + { + var message = new BugReportMessage { ReportInformation = report }; + _net.ClientSendMessage(message); + _bugReportWindow.Close(); + } + + private void ButtonToggleWindow(BaseButton.ButtonEventArgs obj) + { + ToggleWindow(); + } + + private void UpdateButtonVisibility(bool val) + { + if (BugReportButton == null) + return; + + BugReportButton.Visible = val; + } +} diff --git a/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml new file mode 100644 index 0000000000..d3d6570a41 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs new file mode 100644 index 0000000000..a43ba15d68 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs @@ -0,0 +1,181 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Client.Players.PlayTimeTracking; +using Content.Shared.BugReport; +using Content.Shared.CCVar; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.CustomControls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Configuration; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Systems.BugReport.Windows; + +[GenerateTypedNameReferences] +public sealed partial class BugReportWindow : DefaultWindow +{ + [Dependency] private readonly IConfigurationManager _cfg = default!; + // TODO: Use SharedPlaytimeManager when its refactored out of job requirements + [Dependency] private readonly JobRequirementsManager _job = default!; + + // This action gets invoked when the user submits a bug report. + public event Action? OnBugReportSubmitted; + + private DateTime _lastIsEnabledUpdated; + private readonly TimeSpan _isEnabledUpdateInterval = TimeSpan.FromSeconds(1); + + // These are NOT always up to date. If someone disconnects and reconnects, the values will be reset. + // The only other way of getting updated values would be a message from client -> server then from server -> client. + // I don't think that is worth the added complexity. + private DateTime _lastBugReportSubmittedTime = DateTime.MinValue; + private int _amountOfBugReportsSubmitted; + + private readonly ConfigurationMultiSubscriptionBuilder _configSub; + + #region ccvar + + private bool _enablePlayerBugReports; + private int _minimumPlaytimeBugReports; + private int _minimumTimeBetweenBugReports; + private int _maximumBugReportsPerRound; + + private int _maximumBugReportTitleLength; + private int _minimumBugReportTitleLength; + private int _maximumBugReportDescriptionLength; + private int _minimumBugReportDescriptionLength; + + #endregion + + public BugReportWindow() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + _configSub = _cfg.SubscribeMultiple() + .OnValueChanged(CCVars.EnablePlayerBugReports, x => _enablePlayerBugReports = x, true) + .OnValueChanged(CCVars.MinimumPlaytimeInMinutesToEnableBugReports, x => _minimumPlaytimeBugReports = x, true) + .OnValueChanged(CCVars.MinimumSecondsBetweenBugReports, x => _minimumTimeBetweenBugReports = x, true) + .OnValueChanged(CCVars.MaximumBugReportsPerRound, x => _maximumBugReportsPerRound = x, true) + .OnValueChanged(CCVars.MaximumBugReportTitleLength, x => _maximumBugReportTitleLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportTitleLength, x => _minimumBugReportTitleLength = x, true) + .OnValueChanged(CCVars.MaximumBugReportDescriptionLength, x => _maximumBugReportDescriptionLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportDescriptionLength, x => _minimumBugReportDescriptionLength = x, true); + + // Hook up the events + SubmitButton.OnPressed += _ => OnSubmitButtonPressed(); + BugReportTitle.OnTextChanged += _ => HandleInputChange(); + BugReportDescription.OnTextChanged += _ => HandleInputChange(); + OnOpen += UpdateEnabled; + + HandleInputChange(); + UpdateEnabled(); + } + + private void OnSubmitButtonPressed() + { + var report = new PlayerBugReportInformation + { + BugReportTitle = BugReportTitle.Text, + BugReportDescription = Rope.Collapse(BugReportDescription.TextRope), + }; + OnBugReportSubmitted?.Invoke(report); + + _lastBugReportSubmittedTime = DateTime.UtcNow; + _amountOfBugReportsSubmitted++; + + BugReportTitle.Text = string.Empty; + BugReportDescription.TextRope = Rope.Leaf.Empty; + + HandleInputChange(); + UpdateEnabled(); + } + + /// + /// Deals with the user changing their input. Ensures that things that depend on what the user has inputted get updated + /// (E.g. the amount of characters they have typed) + /// + private void HandleInputChange() + { + var titleLen = BugReportTitle.Text.Length; + var descriptionLen = BugReportDescription.TextLength; + + var invalidTitleLen = titleLen < _minimumBugReportTitleLength || titleLen > _maximumBugReportTitleLength; + var invalidDescriptionLen = descriptionLen < _minimumBugReportDescriptionLength || descriptionLen > _maximumBugReportDescriptionLength; + + TitleCharacterCounter.Text = Loc.GetString("bug-report-window-submit-char-split", ("typed", titleLen), ("total", _maximumBugReportTitleLength)); + TitleCharacterCounter.FontColorOverride = invalidTitleLen ? Color.Red : Color.Green; + + DescriptionCharacterCounter.Text = Loc.GetString("bug-report-window-submit-char-split", ("typed", descriptionLen), ("total", _maximumBugReportDescriptionLength)); + + DescriptionCharacterCounter.FontColorOverride = invalidDescriptionLen ? Color.Red : Color.Green; + + SubmitButton.Disabled = invalidTitleLen || invalidDescriptionLen; + + PlaceholderCenter.Visible = descriptionLen == 0; + } + + /// + /// Checks if the bug report window should be enabled for this client. + /// + private bool IsEnabled([NotNullWhen(false)] out string? errorMessage) + { + errorMessage = null; + + if (!_enablePlayerBugReports) + { + errorMessage = Loc.GetString("bug-report-window-disabled-not-enabled"); + return false; + } + + if (TimeSpan.FromMinutes(_minimumPlaytimeBugReports) > _job.FetchOverallPlaytime()) + { + errorMessage = Loc.GetString("bug-report-window-disabled-playtime"); + return false; + } + + if (_amountOfBugReportsSubmitted >= _maximumBugReportsPerRound) + { + errorMessage = Loc.GetString("bug-report-window-disabled-submissions", ("num", _maximumBugReportsPerRound)); + return false; + } + + var timeSinceLastReport = DateTime.UtcNow - _lastBugReportSubmittedTime; + var timeBetweenBugReports = TimeSpan.FromSeconds(_minimumTimeBetweenBugReports); + + if (timeSinceLastReport <= timeBetweenBugReports) + { + var time = timeBetweenBugReports - timeSinceLastReport; + errorMessage = Loc.GetString("bug-report-window-disabled-cooldown", ("time", time.ToString(@"d\.hh\:mm\:ss"))); + return false; + } + + return true; + } + + // Update the state of the window to display either the bug report window or an error explaining why you can't submit a report. + private void UpdateEnabled() + { + var isEnabled = IsEnabled(out var errorMessage); + DisabledLabel.Text = errorMessage; + + DisabledLabel.Visible = !isEnabled; + BugReportContainer.Visible = isEnabled; + _lastIsEnabledUpdated = DateTime.UtcNow; + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + + if (!Visible) // Don't bother updating if no one can see the window anyway. + return; + + if(DateTime.UtcNow - _lastIsEnabledUpdated > _isEnabledUpdateInterval) + UpdateEnabled(); + } + + public void CleanupCCvars() + { + _configSub.Dispose(); + } +} diff --git a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs index e314310bc0..fb7c7f9d25 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs +++ b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs @@ -1,5 +1,6 @@ using Content.Client.UserInterface.Systems.Actions; using Content.Client.UserInterface.Systems.Admin; +using Content.Client.UserInterface.Systems.BugReport; using Content.Client.UserInterface.Systems.Bwoink; using Content.Client.UserInterface.Systems.Character; using Content.Client.UserInterface.Systems.Crafting; @@ -24,6 +25,7 @@ public sealed class GameTopMenuBarUIController : UIController [Dependency] private readonly SandboxUIController _sandbox = default!; [Dependency] private readonly GuidebookUIController _guidebook = default!; [Dependency] private readonly EmotesUIController _emotes = default!; + [Dependency] private readonly BugReportUIController _bug = default!; private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull(); @@ -47,6 +49,7 @@ public sealed class GameTopMenuBarUIController : UIController _action.UnloadButton(); _sandbox.UnloadButton(); _emotes.UnloadButton(); + _bug.UnloadButton(); } public void LoadButtons() @@ -60,5 +63,6 @@ public sealed class GameTopMenuBarUIController : UIController _action.LoadButton(); _sandbox.LoadButton(); _emotes.LoadButton(); + _bug.LoadButton(); } } diff --git a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml index dc8972970a..2c09666fdf 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml +++ b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml @@ -93,6 +93,15 @@ HorizontalExpand="True" AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}" /> + +public sealed class BugReportManager : IBugReportManager, IPostInjectInit +{ + [Dependency] private readonly IServerNetManager _net = default!; + [Dependency] private readonly IEntityManager _entity = default!; + [Dependency] private readonly PlayTimeTrackingManager _playTime = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IAdminLogManager _admin = default!; + [Dependency] private readonly IGameMapManager _map = default!; + [Dependency] private readonly GithubApiManager _githubApiManager = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly ILogManager _log = default!; + + private ISawmill _sawmill = default!; + + /// + /// List of player NetIds and the number of bug reports they have submitted this round. + /// UserId -> (bug reports this round, last submitted bug report) + /// + private readonly Dictionary _bugReportsPerPlayerThisRound = new(); + + private BugReportLimits _limits = default!; + + private List _tags = []; + + private ConfigurationMultiSubscriptionBuilder _configSub = default!; + + public void Initialize() + { + _net.RegisterNetMessage(ReceivedPlayerBugReport); + + _limits = new BugReportLimits(); + + _configSub = _cfg.SubscribeMultiple() + .OnValueChanged(CCVars.MaximumBugReportTitleLength, x => _limits.TitleMaxLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportTitleLength, x => _limits.TitleMinLength = x, true) + .OnValueChanged(CCVars.MaximumBugReportDescriptionLength, x => _limits.DescriptionMaxLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportDescriptionLength, x => _limits.DescriptionMinLength = x, true) + .OnValueChanged(CCVars.MinimumPlaytimeInMinutesToEnableBugReports, x => _limits.MinimumPlaytimeToEnableBugReports = TimeSpan.FromMinutes(x), true) + .OnValueChanged(CCVars.MaximumBugReportsPerRound, x => _limits.MaximumBugReportsForPlayerPerRound = x, true) + .OnValueChanged(CCVars.MinimumSecondsBetweenBugReports, x => _limits.MinimumTimeBetweenBugReports = TimeSpan.FromSeconds(x), true) + .OnValueChanged(CCVars.BugReportTags, x => _tags = x.Split(",").ToList(), true); + } + + public void Restart() + { + // When the round restarts, clear the dictionary. + _bugReportsPerPlayerThisRound.Clear(); + } + + public void Shutdown() + { + _configSub.Dispose(); + } + + private void ReceivedPlayerBugReport(BugReportMessage message) + { + if (!_cfg.GetCVar(CCVars.EnablePlayerBugReports)) + return; + + var netId = message.MsgChannel.UserId; + var userName = message.MsgChannel.UserName; + var report = message.ReportInformation; + if (!IsBugReportValid(report, (NetId: netId, UserName: userName)) || !CanPlayerSendReport(netId, userName)) + return; + + var playerBugReportingStats = _bugReportsPerPlayerThisRound.GetValueOrDefault(netId); + _bugReportsPerPlayerThisRound[netId] = (playerBugReportingStats.ReportsCount + 1, DateTime.UtcNow); + + var title = report.BugReportTitle; + var description = report.BugReportDescription; + + _admin.Add(LogType.BugReport, LogImpact.High, $"{message.MsgChannel.UserName}, {netId}: submitted a bug report. Title: {title}, Description: {description}"); + + var bugReport = CreateBugReport(message); + + _githubApiManager.TryCreateIssue(bugReport); + } + + /// + /// Checks that the given report is valid (E.g. not too long etc...). + /// Logs problems if report is invalid. + /// + /// True if the report is valid, false there is an issue with the report. + private bool IsBugReportValid(PlayerBugReportInformation report, (NetUserId NetId, string UserName) userData) + { + var descriptionLen = report.BugReportDescription.Length; + var titleLen = report.BugReportTitle.Length; + + // These should only happen if there is a hacked client or a glitch! + if (titleLen < _limits.TitleMinLength || titleLen > _limits.TitleMaxLength) + { + _sawmill.Warning( + $"{userData.UserName}, {userData.NetId}: has tried to submit a bug report " + + $"with a title of {titleLen} characters, min/max: {_limits.TitleMinLength}/{_limits.TitleMaxLength}." + ); + return false; + } + + if (descriptionLen < _limits.DescriptionMinLength || descriptionLen > _limits.DescriptionMaxLength) + { + _sawmill.Warning( + $"{userData.UserName}, {userData.NetId}: has tried to submit a bug report " + + $"with a description of {descriptionLen} characters, min/max: {_limits.DescriptionMinLength}/{_limits.DescriptionMaxLength}." + ); + return false; + } + + return true; + } + + /// + /// Checks that the player sending the report is allowed to (E.g. not spamming etc...). + /// Logs problems if report is invalid. + /// + /// True if the player can submit a report, false if they can't. + private bool CanPlayerSendReport(NetUserId netId, string userName) + { + var session = _player.GetSessionById(netId); + var playtime = _playTime.GetOverallPlaytime(session); + if (_limits.MinimumPlaytimeToEnableBugReports > playtime) + return false; + + var playerBugReportingStats = _bugReportsPerPlayerThisRound.GetValueOrDefault(netId); + var maximumBugReportsForPlayerPerRound = _limits.MaximumBugReportsForPlayerPerRound; + if (playerBugReportingStats.ReportsCount >= maximumBugReportsForPlayerPerRound) + { + _admin.Add(LogType.BugReport, + LogImpact.High, + $"{userName}, {netId}: has tried to submit more than {maximumBugReportsForPlayerPerRound} bug reports this round."); + return false; + } + + var timeSinceLastReport = DateTime.UtcNow - playerBugReportingStats.ReportedDateTime; + var timeBetweenBugReports = _limits.MinimumTimeBetweenBugReports; + if (timeSinceLastReport <= timeBetweenBugReports) + { + _admin.Add(LogType.BugReport, + LogImpact.High, + $"{userName}, {netId}: has tried to submit a bug report. " + + $"Last bug report was {timeSinceLastReport:g} ago. The limit is {timeBetweenBugReports:g} minutes." + ); + return false; + } + + return true; + } + + /// + /// Create a bug report out of the given message. Add will extra metadata that could be useful, along with + /// the original text report from the user. + /// + /// The message from user. + /// A based of the user report. + private ValidPlayerBugReportReceivedEvent CreateBugReport(BugReportMessage message) + { + // todo: dont request entity system out of sim, check if you are in-sim before doing so. Bug report should work out of sim too. + var ticker = _entity.System(); + var metadata = new BugReportMetaData + { + Username = message.MsgChannel.UserName, + PlayerGUID = message.MsgChannel.UserData.UserId, + ServerName = _cfg.GetCVar(CCVars.AdminLogsServerName), + NumberOfPlayers = _player.PlayerCount, + SubmittedTime = DateTime.UtcNow, + BuildVersion = _cfg.GetCVar(CVars.BuildVersion), + EngineVersion = _cfg.GetCVar(CVars.BuildEngineVersion), + }; + + // Only add these if your in round. + if (ticker.Preset != null) + { + metadata.RoundTime = _timing.CurTime.Subtract(ticker.RoundStartTimeSpan); + metadata.RoundNumber = ticker.RoundId; + metadata.RoundType = Loc.GetString(ticker.CurrentPreset?.ModeTitle ?? "bug-report-report-unknown"); + metadata.Map = _map.GetSelectedMap()?.MapName ?? Loc.GetString("bug-report-report-unknown"); + } + + return new ValidPlayerBugReportReceivedEvent( + message.ReportInformation.BugReportTitle.Trim(), + message.ReportInformation.BugReportDescription.Trim(), + metadata, + _tags + ); + } + + void IPostInjectInit.PostInject() + { + _sawmill = _log.GetSawmill("BugReport"); + } + + private sealed class BugReportLimits + { + public int TitleMaxLength; + public int TitleMinLength; + public int DescriptionMaxLength; + public int DescriptionMinLength; + + public TimeSpan MinimumPlaytimeToEnableBugReports; + public int MaximumBugReportsForPlayerPerRound; + public TimeSpan MinimumTimeBetweenBugReports; + } +} diff --git a/Content.Server/BugReports/IBugReportEvents.cs b/Content.Server/BugReports/IBugReportEvents.cs new file mode 100644 index 0000000000..3e79ff542e --- /dev/null +++ b/Content.Server/BugReports/IBugReportEvents.cs @@ -0,0 +1,94 @@ +using Robust.Shared.Network; + +namespace Content.Server.BugReports; + +/// +/// This event stores information related to a player submitted bug report. +/// +public sealed class ValidPlayerBugReportReceivedEvent(string title, string description, BugReportMetaData metaData, List tags) : EventArgs +{ + /// + /// Title for the bug report. This is player controlled! + /// + public string Title = title; + + /// + /// Description for the bug report. This is player controlled! + /// + public string Description = description; + + /// + /// Metadata for bug report, containing data collected by server. + /// + public BugReportMetaData MetaData = metaData; + + public List Tags = tags; +} + +/// +/// Metadata for a bug report. Holds relevant data for bug reports that aren't directly player controlled. +/// +public sealed class BugReportMetaData +{ + /// + /// Bug reporter SS14 username. + /// + /// piggylongsnout + public required string Username; + + /// + /// The GUID of the player who reported the bug. + /// + public required NetUserId PlayerGUID; + + /// + /// Name of the server from which bug report was issued. + /// + /// DeltaV> + public required string ServerName; + + /// + /// Date and time on which player submitted report (NOT round time). + /// The time is UTC and based off the servers clock. + /// + public required DateTime SubmittedTime; + + /// + /// Time that has elapsed in the round. Can be null if bug was not reported during a round. + /// + public TimeSpan? RoundTime; + + /// + /// Round number during which bug report was issued. Can be null if bug was reported not during round. + /// + /// 1311 + public int? RoundNumber; + + /// + /// Type preset title (type of round that is being played). Can be null if bug was reported not during round. + /// + /// Sandbox + public string? RoundType; + + /// + /// The map being played. + /// + /// "Dev"> + public string? Map; + + /// + /// Number of players currently on server. + /// + public int NumberOfPlayers; + + /// + /// Build version of the game. + /// + public required string BuildVersion; + + /// + /// Engine version of the game. + /// + /// 253.0.0 + public required string EngineVersion; +} diff --git a/Content.Server/BugReports/IBugReportManager.cs b/Content.Server/BugReports/IBugReportManager.cs new file mode 100644 index 0000000000..28264bc8c0 --- /dev/null +++ b/Content.Server/BugReports/IBugReportManager.cs @@ -0,0 +1,22 @@ +namespace Content.Server.BugReports; + +/// +/// Manager for validating client bug reports, issued in-game, and relaying creation of issue in tracker to dedicated api client. +/// +public interface IBugReportManager +{ + /// Will get called when the manager is first initialized. + public void Initialize(); + + /// + /// Will get called whenever the round is restarted. + /// Should be used to clean up anything that needs reset after each round. + /// + public void Restart(); + + /// + /// Will get called whenever the round is restarted. + /// Should be used to clean up anything that needs reset after each round. + /// + public void Shutdown(); +} diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index df4af14f1e..1f2d035a4a 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -3,6 +3,7 @@ using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Afk; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; @@ -10,13 +11,12 @@ using Content.Server.Discord.DiscordLink; using Content.Server.EUI; using Content.Server.GameTicking; using Content.Server.GhostKick; +using Content.Server.Github; using Content.Server.GuideGenerator; using Content.Server.Info; using Content.Server.IoC; using Content.Server.Maps; using Content.Server.NodeContainer.NodeGroups; -using Content.Server.Objectives; -using Content.Server.Players; using Content.Server.Players.JobWhitelist; using Content.Server.Players.PlayTimeTracking; using Content.Server.Players.RateLimiting; @@ -111,6 +111,10 @@ namespace Content.Server.Entry IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); _voteManager.Initialize(); _updateManager.Initialize(); @@ -192,6 +196,8 @@ namespace Content.Server.Entry IoCManager.Resolve().Shutdown(); IoCManager.Resolve().Shutdown(); + + IoCManager.Resolve().Shutdown(); } private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill) diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 1dadca4c03..cb5a1a4187 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -725,6 +725,8 @@ namespace Content.Server.GameTicking _banManager.Restart(); + _bugManager.Restart(); + _gameMapManager.ClearSelectedMap(); // Clear up any game rules. diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 55bf51db02..290d363047 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -1,5 +1,6 @@ using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Chat.Systems; using Content.Server.Database; @@ -65,6 +66,7 @@ namespace Content.Server.GameTicking [Dependency] private readonly MetaDataSystem _metaData = default!; [Dependency] private readonly SharedRoleSystem _roles = default!; [Dependency] private readonly ServerDbEntryManager _dbEntryManager = default!; + [Dependency] private readonly IBugReportManager _bugManager = default!; [ViewVariables] private bool _initialized; [ViewVariables] private bool _postInitialized; diff --git a/Content.Server/Github/Commands/TestGithubApiCommand.cs b/Content.Server/Github/Commands/TestGithubApiCommand.cs new file mode 100644 index 0000000000..85ac798057 --- /dev/null +++ b/Content.Server/Github/Commands/TestGithubApiCommand.cs @@ -0,0 +1,71 @@ +using Content.Server.Administration; +using Content.Server.Github.Requests; +using Content.Shared.Administration; +using Content.Shared.CCVar; +using Robust.Shared.Configuration; +using Robust.Shared.Console; + +namespace Content.Server.Github.Commands; + +/// +/// Simple command for testing if the GitHub api is set up correctly! It ensures that all necessary ccvars are set, +/// and will also create one new issue on the targeted repository. +/// +[AdminCommand(AdminFlags.Server)] +public sealed class TestGithubApiCommand : LocalizedCommands +{ + [Dependency] private readonly GithubApiManager _git = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + + public override string Command => Loc.GetString("github-command-test-name"); + + public override async void Execute(IConsoleShell shell, string argStr, string[] args) + { + var enabled = _cfg.GetCVar(CCVars.GithubEnabled); + var path = _cfg.GetCVar(CCVars.GithubAppPrivateKeyPath); + var appId = _cfg.GetCVar(CCVars.GithubAppId); + var repoName = _cfg.GetCVar(CCVars.GithubRepositoryName); + var owner = _cfg.GetCVar(CCVars.GithubRepositoryOwner); + + if (!enabled) + { + shell.WriteError(Loc.GetString("github-command-not-enabled")); + return; + } + + if (string.IsNullOrWhiteSpace(path)) + { + shell.WriteError(Loc.GetString("github-command-no-path")); + return; + } + + if (string.IsNullOrWhiteSpace(appId)) + { + shell.WriteError(Loc.GetString("github-command-no-app-id")); + return; + } + + if (string.IsNullOrWhiteSpace(repoName)) + { + shell.WriteError(Loc.GetString("github-command-no-repo-name")); + return; + } + + if (string.IsNullOrWhiteSpace(owner)) + { + shell.WriteError(Loc.GetString("github-command-no-owner")); + return; + } + + // Create two issues and send them to the api. + var request = new CreateIssueRequest + { + Title = Loc.GetString("github-command-issue-title-one"), + Body = Loc.GetString("github-command-issue-description-one"), + }; + + _git.TryMakeRequest(request); + + shell.WriteLine(Loc.GetString("github-command-finish")); + } +} diff --git a/Content.Server/Github/GithubApiManager.cs b/Content.Server/Github/GithubApiManager.cs new file mode 100644 index 0000000000..44f164fdf0 --- /dev/null +++ b/Content.Server/Github/GithubApiManager.cs @@ -0,0 +1,53 @@ +using Content.Server.Github.Requests; +using System.Threading.Tasks; +using Content.Server.BugReports; + +namespace Content.Server.Github; + +public sealed class GithubApiManager +{ + [Dependency] private readonly GithubBackgroundWorker _githubWorker = default!; + + public void Initialize() + { + Task.Run(() => _githubWorker.HandleQueue()); + } + + public bool TryCreateIssue(ValidPlayerBugReportReceivedEvent bugReport) + { + var createIssueRequest = ConvertToCreateIssue(bugReport); + return TryMakeRequest(createIssueRequest); + } + + public bool TryMakeRequest(IGithubRequest request) + { + return _githubWorker.Writer.TryWrite(request); + } + + private CreateIssueRequest ConvertToCreateIssue(ValidPlayerBugReportReceivedEvent bugReport) + { + var request = new CreateIssueRequest + { + Title = bugReport.Title, + Labels = bugReport.Tags, + }; + + var metadata = bugReport.MetaData; + + request.Body = Loc.GetString("github-issue-format", + ("description", bugReport.Description), + ("buildVersion", metadata.BuildVersion), + ("engineVersion", metadata.EngineVersion), + ("serverName", metadata.ServerName), + ("submittedTime", metadata.SubmittedTime), + ("roundNumber", metadata.RoundNumber.ToString() ?? ""), + ("roundTime", metadata.RoundTime.ToString() ?? ""), + ("roundType", metadata.RoundType ?? ""), + ("map", metadata.Map ?? ""), + ("numberOfPlayers", metadata.NumberOfPlayers), + ("username", metadata.Username), + ("playerGUID", metadata.PlayerGUID)); + + return request; + } +} diff --git a/Content.Server/Github/GithubBackgroundWorker.cs b/Content.Server/Github/GithubBackgroundWorker.cs new file mode 100644 index 0000000000..06d85dd001 --- /dev/null +++ b/Content.Server/Github/GithubBackgroundWorker.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Content.Server.Github.Requests; +using Content.Shared.CCVar; +using Robust.Shared.Configuration; + +namespace Content.Server.Github; + +public sealed class GithubBackgroundWorker +{ + [Dependency] private readonly GithubClient _client = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ILogManager _log = default!; + + private ISawmill _sawmill = default!; + + private bool _enabled; + private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + + public ChannelWriter Writer => _channel.Writer; + + public void Initialize() + { + _sawmill = _log.GetSawmill("github-ratelimit"); + _cfg.OnValueChanged(CCVars.GithubEnabled, val => Interlocked.Exchange(ref _enabled, val), true); + } + + public async Task HandleQueue() + { + var token = _cts.Token; + var reader = _channel.Reader; + while (!token.IsCancellationRequested) + { + await reader.WaitToReadAsync(token); + if (!reader.TryRead(out var request)) + continue; + + await SendRequest(request, token); + } + } + + // this should be called in BaseServer.Cleanup! + public void Shutdown() + { + _cts.Cancel(); + } + + /// + /// Directly send a request to the API. This does not have any rate limits checks so be careful! + /// Only use this if you have a very good reason to! + /// + /// The request to make. + /// Request cancellation token. + /// The direct HTTP response from the API. If null the request could not be made. + private async Task SendRequest(T request, CancellationToken ct) where T : IGithubRequest + { + if (!_enabled) + { + _sawmill.Info("Tried to make a github api request but the api was not enabled."); + return; + } + + try + { + await _client.TryMakeRequestSafe(request, ct); + } + catch (Exception e) + { + _sawmill.Error("Github API exception: {error}", e.ToString()); + } + } +} diff --git a/Content.Server/Github/GithubClient.cs b/Content.Server/Github/GithubClient.cs new file mode 100644 index 0000000000..ed7563dd3f --- /dev/null +++ b/Content.Server/Github/GithubClient.cs @@ -0,0 +1,417 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Content.Server.Github.Requests; +using Content.Server.Github.Responses; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Shared.Configuration; + +namespace Content.Server.Github; + +/// +/// Basic implementation of the GitHub api. This was mainly created for making issues from users bug reports - it is not +/// a full implementation! I tried to follow the spec very closely and the docs are really well done. I highly recommend +/// taking a look at them! +///
+///
Some useful information about the api: +///
Api home page +///
Best practices +///
Rate limit information +///
Troubleshooting +///
+/// As it uses async, it should be called from background worker when possible, like . +public sealed class GithubClient +{ + [Dependency] private readonly ILogManager _log = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + private HttpClient _httpClient = default!; + + private ISawmill _sawmill = default!; + + // Token data for the GitHub app (This is used to authenticate stuff like new issue creation) + private (DateTime? Expiery, string Token) _tokenData; + + // Json web token for the GitHub app (This is used to authenticate stuff like seeing where the app is installed) + // The token is created locally. + private (DateTime? Expiery, string JWT) _jwtData; + + private const int ErrorResponseMaxLogSize = 200; + + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + // Docs say 10 should be the maximum. + private readonly TimeSpan _jwtExpiration = TimeSpan.FromMinutes(10); + private readonly TimeSpan _jwtBackDate = TimeSpan.FromMinutes(1); + + // Buffers because requests can take a while. We don't want the tokens to expire in the middle of doing requests! + private readonly TimeSpan _jwtBuffer = TimeSpan.FromMinutes(2); + private readonly TimeSpan _tokenBuffer = TimeSpan.FromMinutes(2); + + private string _privateKey = ""; + + #region Header constants + + private const string ProductName = "SpaceStation14GithubApi"; + private const string ProductVersion = "1"; + + private const string AcceptHeader = "Accept"; + private const string AcceptHeaderType = "application/vnd.github+json"; + + private const string AuthHeader = "Authorization"; + private const string AuthHeaderBearer = "Bearer "; + + private const string VersionHeader = "X-GitHub-Api-Version"; + private const string VersionNumber = "2022-11-28"; + + #endregion + + private readonly Uri _baseUri = new("https://api.github.com/"); + + #region CCvar values + + private string _appId = ""; + private string _repository = ""; + private string _owner = ""; + private int _maxRetries; + + #endregion + + public void Initialize() + { + _sawmill = _log.GetSawmill("github"); + _tokenData = (null, ""); + _jwtData = (null, ""); + + _cfg.OnValueChanged(CCVars.GithubAppPrivateKeyPath, OnPrivateKeyPathChanged, true); + _cfg.OnValueChanged(CCVars.GithubAppId, val => Interlocked.Exchange(ref _appId, val), true); + _cfg.OnValueChanged(CCVars.GithubRepositoryName, val => Interlocked.Exchange(ref _repository, val), true); + _cfg.OnValueChanged(CCVars.GithubRepositoryOwner, val => Interlocked.Exchange(ref _owner, val), true); + _cfg.OnValueChanged(CCVars.GithubMaxRetries, val => SetValueAndInitHttpClient(ref _maxRetries, val), true); + } + + private void OnPrivateKeyPathChanged(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + if (!File.Exists(path)) + { + _sawmill.Error($"\"{path}\" does not exist."); + return; + } + + string fileText; + try + { + fileText = File.ReadAllText(path); + } + catch (Exception e) + { + _sawmill.Error($"\"{path}\" could not be read!\n{e}"); + return; + } + + var rsa = RSA.Create(); + try + { + rsa.ImportFromPem(fileText); + } + catch + { + _sawmill.Error($"\"{path}\" does not contain a valid private key!"); + return; + } + + _privateKey = fileText; + } + + private void SetValueAndInitHttpClient(ref T toSet, T value) + { + Interlocked.Exchange(ref toSet, value); + + var httpMessageHandler = new RetryHandler(new HttpClientHandler(), _maxRetries, _sawmill); + var newClient = new HttpClient(httpMessageHandler) + { + BaseAddress = _baseUri, + DefaultRequestHeaders = + { + { AcceptHeader, AcceptHeaderType }, + { VersionHeader, VersionNumber }, + }, + Timeout = TimeSpan.FromSeconds(15), + }; + + newClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, ProductVersion)); + + Interlocked.Exchange(ref _httpClient, newClient); + } + + #region Public functions + + /// + /// The standard way to make requests to the GitHub api. This will ensure that the request respects the rate limit + /// and will also retry the request if it fails. Awaiting this to finish could take a very long time depending + /// on what exactly is going on! Only await for it if you're willing to wait a long time. + /// + /// The request you want to make. + /// Token for operation cancellation. + /// The direct HTTP response from the API. If null the request could not be made. + public async Task TryMakeRequestSafe(IGithubRequest request, CancellationToken ct) + { + if (!HaveFullApiData()) + { + _sawmill.Info("Tried to make a github api request but the api was not enabled."); + return null; + } + + if (request.AuthenticationMethod == GithubAuthMethod.Token && !await TryEnsureTokenNotExpired(ct)) + return null; + + return await MakeRequest(request, ct); + } + + private async Task MakeRequest(IGithubRequest request, CancellationToken ct) + { + var httpRequestMessage = BuildRequest(request); + + var response = await _httpClient.SendAsync(httpRequestMessage, ct); + + var message = $"Made a github api request to: '{httpRequestMessage.RequestUri}', status is {response.StatusCode}"; + if (response.IsSuccessStatusCode) + { + _sawmill.Info(message); + return response; + } + + _sawmill.Error(message); + var responseText = await response.Content.ReadAsStringAsync(ct); + + if (responseText.Length > ErrorResponseMaxLogSize) + responseText = responseText.Substring(0, ErrorResponseMaxLogSize); + + _sawmill.Error(message + "\r\n" + responseText); + + return null; + } + + /// + /// A simple helper function that just tries to parse a header value that is expected to be a long int. + /// In general, there are just a lot of single value headers that are longs so this removes a lot of duplicate code. + /// + /// The headers that you want to search. + /// The header you want to get the long value for. + /// Value of header, if found, null otherwise. + /// The headers value if it exists, null otherwise. + public static bool TryGetHeaderAsLong(HttpResponseHeaders? headers, string header, [NotNullWhen(true)] out long? value) + { + value = null; + if (headers == null) + return false; + + if (!headers.TryGetValues(header, out var headerValues)) + return false; + + if (!long.TryParse(headerValues.First(), out var result)) + return false; + + value = result; + return true; + } + + # endregion + + #region Helper functions + + private HttpRequestMessage BuildRequest(IGithubRequest request) + { + var json = JsonSerializer.Serialize(request, _jsonSerializerOptions); + var payload = new StringContent(json, Encoding.UTF8, "application/json"); + + var builder = new UriBuilder(_baseUri) + { + Port = -1, + Path = request.GetLocation(_owner, _repository), + }; + + var httpRequest = new HttpRequestMessage + { + Method = request.RequestMethod, + RequestUri = builder.Uri, + Content = payload, + }; + + httpRequest.Headers.Add(AuthHeader, CreateAuthenticationHeader(request)); + + return httpRequest; + } + + private bool HaveFullApiData() + { + return !string.IsNullOrWhiteSpace(_privateKey) && + !string.IsNullOrWhiteSpace(_repository) && + !string.IsNullOrWhiteSpace(_owner); + } + + private string CreateAuthenticationHeader(IGithubRequest request) + { + return request.AuthenticationMethod switch + { + GithubAuthMethod.Token => AuthHeaderBearer + _tokenData.Token, + GithubAuthMethod.JWT => AuthHeaderBearer + GetValidJwt(), + _ => throw new Exception("Unknown auth method!"), + }; + } + + // TODO: Maybe ensure that perms are only read metadata / write issues so people don't give full access + /// + /// Try to get a valid verification token from the GitHub api + /// + /// True if the token is valid and successfully found, false if there was an error. + private async Task TryEnsureTokenNotExpired(CancellationToken ct) + { + if (_tokenData.Expiery != null && _tokenData.Expiery - _tokenBuffer > DateTime.UtcNow) + return true; + + _sawmill.Info("Token expired - requesting new token!"); + + var installationRequest = new InstallationsRequest(); + var installationHttpResponse = await MakeRequest(installationRequest, ct); + if (installationHttpResponse == null) + { + _sawmill.Error("Could not make http installation request when creating token."); + return false; + } + + var installationResponse = await installationHttpResponse.Content.ReadFromJsonAsync>(_jsonSerializerOptions, ct); + if (installationResponse == null) + { + _sawmill.Error("Could not parse installation response."); + return false; + } + + if (installationResponse.Count == 0) + { + _sawmill.Error("App not installed anywhere."); + return false; + } + + int? installationId = null; + foreach (var installation in installationResponse) + { + if (installation.Account.Login != _owner) + continue; + + installationId = installation.Id; + break; + } + + if (installationId == null) + { + _sawmill.Error("App not installed in given repository."); + return false; + } + + var tokenRequest = new TokenRequest + { + InstallationId = installationId.Value, + }; + + var tokenHttpResponse = await MakeRequest(tokenRequest, ct); + if (tokenHttpResponse == null) + { + _sawmill.Error("Could not make http token request when creating token.."); + return false; + } + + var tokenResponse = await tokenHttpResponse.Content.ReadFromJsonAsync(_jsonSerializerOptions, ct); + if (tokenResponse == null) + { + _sawmill.Error("Could not parse token response."); + return false; + } + + _tokenData = (tokenResponse.ExpiresAt, tokenResponse.Token); + return true; + } + + // See: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app + private string GetValidJwt() + { + if (_jwtData.Expiery != null && _jwtData.Expiery - _jwtBuffer > DateTime.UtcNow) + return _jwtData.JWT; + + var githubClientId = _appId; + var apiPrivateKey = _privateKey; + + var time = DateTime.UtcNow; + var expTime = time + _jwtExpiration; + var iatTime = time - _jwtBackDate; + + var iat = ((DateTimeOffset) iatTime).ToUnixTimeSeconds(); + var exp = ((DateTimeOffset) expTime).ToUnixTimeSeconds(); + + const string headerJson = """ + { + "typ":"JWT", + "alg":"RS256" + } + """; + + var headerEncoded = Base64EncodeUrlSafe(headerJson); + + var payloadJson = $$""" + { + "iat":{{iat}}, + "exp":{{exp}}, + "iss":"{{githubClientId}}" + } + """; + + var payloadJsonEncoded = Base64EncodeUrlSafe(payloadJson); + + var headPayload = $"{headerEncoded}.{payloadJsonEncoded}"; + + var rsa = System.Security.Cryptography.RSA.Create(); + rsa.ImportFromPem(apiPrivateKey); + + var bytesPlainTextData = Encoding.UTF8.GetBytes(headPayload); + + var signedData = rsa.SignData(bytesPlainTextData, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signBase64 = Base64EncodeUrlSafe(signedData); + + var jwt = $"{headPayload}.{signBase64}"; + + _jwtData = (expTime, jwt); + + _sawmill.Info("Generated new JWT."); + + return jwt; + } + + private string Base64EncodeUrlSafe(string plainText) + { + return Base64EncodeUrlSafe(Encoding.UTF8.GetBytes(plainText)); + } + + private string Base64EncodeUrlSafe(byte[] plainText) + { + return Convert.ToBase64String(plainText) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + #endregion +} diff --git a/Content.Server/Github/Requests/CreateIssueRequest.cs b/Content.Server/Github/Requests/CreateIssueRequest.cs new file mode 100644 index 0000000000..92cee609fa --- /dev/null +++ b/Content.Server/Github/Requests/CreateIssueRequest.cs @@ -0,0 +1,38 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class CreateIssueRequest : IGithubRequest +{ + [JsonIgnore] + public HttpMethod RequestMethod => HttpMethod.Post; + + [JsonIgnore] + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.Token; + + #region JSON fields + + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string Title; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Body; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Assignee; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Milestone; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List Labels = []; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List Assignees = []; + + #endregion + + public string GetLocation(string owner, string repository) + { + return $"repos/{owner}/{repository}/issues"; + } +} diff --git a/Content.Server/Github/Requests/IGithubRequest.cs b/Content.Server/Github/Requests/IGithubRequest.cs new file mode 100644 index 0000000000..afc421722d --- /dev/null +++ b/Content.Server/Github/Requests/IGithubRequest.cs @@ -0,0 +1,44 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// Interface for all github api requests. +/// +/// +/// WARNING: You must add this JsonDerivedType for all requests that have json otherwise they will not parse properly! +/// +[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(CreateIssueRequest))] +[JsonDerivedType(typeof(InstallationsRequest))] +[JsonDerivedType(typeof(TokenRequest))] +public interface IGithubRequest +{ + /// + /// The kind of request method for the request. + /// + [JsonIgnore] + public HttpMethod RequestMethod { get; } + + /// + /// There are different types of authentication methods depending on which endpoint you are working with. + /// E.g. the app api endpoint mostly uses JWTs, while stuff like issue creation uses Tokens + /// + [JsonIgnore] + public GithubAuthMethod AuthenticationMethod { get; } + + /// + /// Location of the api endpoint for this request. + /// + /// Owner of the repository. + /// The repository to make the request. + /// The api location for this request. + public string GetLocation(string owner, string repository); +} + +public enum GithubAuthMethod +{ + JWT, + Token, +} diff --git a/Content.Server/Github/Requests/InstallationsRequest.cs b/Content.Server/Github/Requests/InstallationsRequest.cs new file mode 100644 index 0000000000..4e75bbdc38 --- /dev/null +++ b/Content.Server/Github/Requests/InstallationsRequest.cs @@ -0,0 +1,18 @@ +using System.Net.Http; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class InstallationsRequest : IGithubRequest +{ + public HttpMethod RequestMethod => HttpMethod.Get; + + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.JWT; + + public string GetLocation(string owner, string repository) + { + return "app/installations"; + } +} diff --git a/Content.Server/Github/Requests/TokenRequest.cs b/Content.Server/Github/Requests/TokenRequest.cs new file mode 100644 index 0000000000..f07764cdf0 --- /dev/null +++ b/Content.Server/Github/Requests/TokenRequest.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class TokenRequest : IGithubRequest +{ + public HttpMethod RequestMethod => HttpMethod.Post; + + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.JWT; + + [JsonPropertyName("id")] + public required int InstallationId; + + public string GetLocation(string owner, string repository) + { + return $"/app/installations/{InstallationId}/access_tokens"; + } +} diff --git a/Content.Server/Github/Responses/InstallationResponse.cs b/Content.Server/Github/Responses/InstallationResponse.cs new file mode 100644 index 0000000000..ffc84a6f0c --- /dev/null +++ b/Content.Server/Github/Responses/InstallationResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Responses; + +/// +/// Not all fields are filled out - only the necessary ones. If you need more just add them. +/// > +/// +public sealed class InstallationResponse +{ + public required int Id { get; set; } + + public required GithubInstallationAccount Account { get; set; } +} + +/// +public sealed class GithubInstallationAccount +{ + public required string Login { get; set; } +} + diff --git a/Content.Server/Github/Responses/TokenResponse.cs b/Content.Server/Github/Responses/TokenResponse.cs new file mode 100644 index 0000000000..5b3748219c --- /dev/null +++ b/Content.Server/Github/Responses/TokenResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Responses; + +/// +/// Not all fields are filled out - only the necessary ones. If you need more just add them. +/// > +/// +public sealed class TokenResponse +{ + public required string Token { get; set; } + + [JsonPropertyName("expires_at")] + public required DateTime ExpiresAt { get; set; } +} diff --git a/Content.Server/Github/RetryHttpHandler.cs b/Content.Server/Github/RetryHttpHandler.cs new file mode 100644 index 0000000000..28dcff643b --- /dev/null +++ b/Content.Server/Github/RetryHttpHandler.cs @@ -0,0 +1,100 @@ +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; +using System.Net; + +namespace Content.Server.Github; + +/// +/// Basic rate limiter for the GitHub api! Will ensure there is only ever one outgoing request at a time and all +/// requests respect the rate limit the best they can. +///
+///
Links to the api for more information: +///
Best practices +///
Rate limit information +///
+/// This was designed for the 2022-11-28 version of the API. +public sealed class RetryHandler(HttpMessageHandler innerHandler, int maxRetries, ISawmill sawmill) : DelegatingHandler(innerHandler) +{ + private const int MaxWaitSeconds = 32; + + /// Extra buffer time (In seconds) after getting rate limited we don't make the request exactly when we get more credits. + private const long ExtraBufferTime = 1L; + + #region Headers + + private const string RetryAfterHeader = "retry-after"; + + private const string RemainingHeader = "x-ratelimit-remaining"; + private const string RateLimitResetHeader = "x-ratelimit-reset"; + + #endregion + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + HttpResponseMessage response; + var i = 0; + do + { + response = await base.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + return response; + + i++; + if (i < maxRetries) + { + var waitTime = CalculateNextRequestTime(response, i); + await Task.Delay(waitTime, cancellationToken); + } + } while (!response.IsSuccessStatusCode && i < maxRetries); + + return response; + } + + /// + /// Follows these guidelines but also has a small buffer so you should never quite hit zero: + ///
+ /// + ///
+ /// The last response from the API. + /// Number of current call attempt. + /// The amount of time to wait until the next request. + private TimeSpan CalculateNextRequestTime(HttpResponseMessage response, int attempt) + { + var headers = response.Headers; + var statusCode = response.StatusCode; + + // Specific checks for rate limits. + if (statusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests) + { + // Retry after header + if (GithubClient.TryGetHeaderAsLong(headers, RetryAfterHeader, out var retryAfterSeconds)) + return TimeSpan.FromSeconds(retryAfterSeconds.Value + ExtraBufferTime); + + // Reset header (Tells us when we get more api credits) + if (GithubClient.TryGetHeaderAsLong(headers, RemainingHeader, out var remainingRequests) + && GithubClient.TryGetHeaderAsLong(headers, RateLimitResetHeader, out var resetTime) + && remainingRequests == 0) + { + var delayTime = resetTime.Value - DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + sawmill.Warning( + "github returned '{status}' status, have to wait until limit reset - in '{delay}' seconds", + response.StatusCode, + delayTime + ); + return TimeSpan.FromSeconds(delayTime + ExtraBufferTime); + } + } + + // If the status code is not the expected one or the rate limit checks are failing, just do an exponential backoff. + return ExponentialBackoff(attempt); + } + + private static TimeSpan ExponentialBackoff(int i) + { + return TimeSpan.FromSeconds(Math.Min(MaxWaitSeconds, Math.Pow(2, i))); + } +} diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index b4d999bef4..96124330f0 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -3,6 +3,7 @@ using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Administration.Notes; using Content.Server.Afk; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; @@ -11,6 +12,7 @@ using Content.Server.Discord.DiscordLink; using Content.Server.Discord.WebhookMessages; using Content.Server.EUI; using Content.Server.GhostKick; +using Content.Server.Github; using Content.Server.Info; using Content.Server.Mapping; using Content.Server.Maps; @@ -59,6 +61,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); @@ -76,9 +79,11 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index 58a41a5f7a..388c44bb28 100644 --- a/Content.Shared.Database/LogType.cs +++ b/Content.Shared.Database/LogType.cs @@ -464,6 +464,7 @@ public enum LogType /// Logs related to botany, such as planting and harvesting crops /// Botany = 100, + /// /// Artifact node got activated. /// @@ -478,4 +479,9 @@ public enum LogType /// Events relating to midi playback. /// Instrument = 103, + + /// + /// For anything relating to bug reports. + /// + BugReport = 104, } diff --git a/Content.Shared/BugReport/BugReportMessage.cs b/Content.Shared/BugReport/BugReportMessage.cs new file mode 100644 index 0000000000..46976eda6e --- /dev/null +++ b/Content.Shared/BugReport/BugReportMessage.cs @@ -0,0 +1,42 @@ +using Lidgren.Network; +using Robust.Shared.Network; +using Robust.Shared.Serialization; + +namespace Content.Shared.BugReport; + +/// +/// Message with bug report data, which should be handled by server and used to create issue on issue tracker +/// (or some other notification). +/// +public sealed class BugReportMessage : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.Command; + + public PlayerBugReportInformation ReportInformation = new(); + + public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) + { + ReportInformation.BugReportTitle = buffer.ReadString(); + ReportInformation.BugReportDescription = buffer.ReadString(); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) + { + buffer.Write(ReportInformation.BugReportTitle); + buffer.Write(ReportInformation.BugReportDescription); + } + + public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered; +} + +/// +/// Stores user specified information from a bug report. +/// +/// +/// Clients can put whatever they want here so be careful! +/// +public sealed class PlayerBugReportInformation +{ + public string BugReportTitle = string.Empty; + public string BugReportDescription = string.Empty; +} diff --git a/Content.Shared/CCVar/CCVars.BugReports.cs b/Content.Shared/CCVar/CCVars.BugReports.cs new file mode 100644 index 0000000000..789ffc9a48 --- /dev/null +++ b/Content.Shared/CCVar/CCVars.BugReports.cs @@ -0,0 +1,64 @@ +using Robust.Shared.Configuration; + +namespace Content.Shared.CCVar; + +public sealed partial class CCVars +{ + /// + /// Allow users to submit bug reports. Will enable a button on the hotbar. See for + /// setting up the GitHub API! + /// + public static readonly CVarDef EnablePlayerBugReports = + CVarDef.Create("bug_reports.enable_player_bug_reports", false, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum playtime that players need to have played to submit bug reports. + /// + public static readonly CVarDef MinimumPlaytimeInMinutesToEnableBugReports = + CVarDef.Create("bug_reports.minimum_playtime_in_minutes_to_enable_bug_reports", 120, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum number of bug reports a user can submit per round. + /// + public static readonly CVarDef MaximumBugReportsPerRound = + CVarDef.Create("bug_reports.maximum_bug_reports_per_round", 5, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum time between bug reports. + /// + public static readonly CVarDef MinimumSecondsBetweenBugReports = + CVarDef.Create("bug_reports.minimum_seconds_between_bug_reports", 120, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum length of a bug report title. + /// + public static readonly CVarDef MaximumBugReportTitleLength = + CVarDef.Create("bug_reports.maximum_bug_report_title_length", 35, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum length of a bug report title. + /// + public static readonly CVarDef MinimumBugReportTitleLength = + CVarDef.Create("bug_reports.minimum_bug_report_title_length", 10, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum length of a bug report description. + /// + public static readonly CVarDef MaximumBugReportDescriptionLength = + CVarDef.Create("bug_reports.maximum_bug_report_description_length", 750, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum length of a bug report description. + /// + public static readonly CVarDef MinimumBugReportDescriptionLength = + CVarDef.Create("bug_reports.minimum_bug_report_description_length", 10, CVar.SERVER | CVar.REPLICATED); + + /// + /// List of tags that are added to the report. Separate each value with ",". + /// + /// + /// IG report, Bug + /// + public static readonly CVarDef BugReportTags = + CVarDef.Create("bug_reports.tags", "IG bug report", CVar.SERVER | CVar.REPLICATED); +} diff --git a/Content.Shared/CCVar/CCVars.Github.cs b/Content.Shared/CCVar/CCVars.Github.cs new file mode 100644 index 0000000000..77c1ffc2fe --- /dev/null +++ b/Content.Shared/CCVar/CCVars.Github.cs @@ -0,0 +1,71 @@ +using Robust.Shared.Configuration; + +namespace Content.Shared.CCVar; + +public sealed partial class CCVars +{ + /// + /// Marker, for if the GitHub api is enabled. If it is not enabled, any actions that require GitHub API will be ignored. + /// To fully set up the API, you also need to set , , + /// and . + /// + public static readonly CVarDef GithubEnabled = + CVarDef.Create("github.github_enabled", true, CVar.SERVERONLY); + + /// + /// GitHub app private keys location. PLEASE READ THIS CAREFULLY!! + /// + /// + /// Its highly recommend to create a new (private) repository specifically for this app. This will help avoid + /// moderation issues and also allow you to ignore duplicate or useless issues. You can just transfer legitimate + /// issues from the private repository to the main public one. + /// + /// + /// Only create the auth token with the MINIMUM required access (Specifically only give it access to one + /// repository - and the minimum required access for your use case). + ///

If this token is only for forwarding issues then you should only need to grant read and write + /// permission to "Issues" and read only permissions to "Metadata". + ///
+ ///
+ /// Also remember to use the testgithubapi command to test if you set everything up correctly. + /// [Insert YouTube video link with walkthrough here] + ///
+ /// + /// (If your on linux): /home/beck/key.pem + /// + public static readonly CVarDef GithubAppPrivateKeyPath = + CVarDef.Create("github.github_app_private_key_path", "", CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// The GitHub apps app id. Go to https://github.com/settings/apps/APPNAME to find the app id. + /// + /// + /// 1009555 + /// + public static readonly CVarDef GithubAppId = + CVarDef.Create("github.github_app_id", "", CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// Name of the targeted GitHub repository. + /// + /// + /// If your URL was https://github.com/space-wizards/space-station-14 the repo name would be "space-station-14". + /// > + public static readonly CVarDef GithubRepositoryName = + CVarDef.Create("github.github_repository_name", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// Owner of the GitHub repository. + /// + /// + /// If your URL was https://github.com/space-wizards/space-station-14 the owner would be "space-wizards". + /// + public static readonly CVarDef GithubRepositoryOwner = + CVarDef.Create("github.github_repository_owner", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// The maximum number of times the api will retry requests before giving up. + /// + public static readonly CVarDef GithubMaxRetries = + CVarDef.Create("github.github_max_retries", 3, CVar.SERVERONLY | CVar.CONFIDENTIAL); +} diff --git a/Resources/Locale/en-US/HUD/game-hud.ftl b/Resources/Locale/en-US/HUD/game-hud.ftl index ea423f080a..d88403a6af 100644 --- a/Resources/Locale/en-US/HUD/game-hud.ftl +++ b/Resources/Locale/en-US/HUD/game-hud.ftl @@ -7,3 +7,4 @@ game-hud-open-crafting-menu-button-tooltip = Open crafting menu. game-hud-open-actions-menu-button-tooltip = Open actions menu. game-hud-open-admin-menu-button-tooltip = Open admin menu. game-hud-open-sandbox-menu-button-tooltip = Open sandbox menu. +game-hud-open-bug-report-window-button-tooltip = Open bug report menu. diff --git a/Resources/Locale/en-US/bugreport/bug-report-report.ftl b/Resources/Locale/en-US/bugreport/bug-report-report.ftl new file mode 100644 index 0000000000..c6296c520f --- /dev/null +++ b/Resources/Locale/en-US/bugreport/bug-report-report.ftl @@ -0,0 +1 @@ +bug-report-report-unknown = unknown diff --git a/Resources/Locale/en-US/bugreport/bug-report-window.ftl b/Resources/Locale/en-US/bugreport/bug-report-window.ftl new file mode 100644 index 0000000000..794014ca98 --- /dev/null +++ b/Resources/Locale/en-US/bugreport/bug-report-window.ftl @@ -0,0 +1,13 @@ +bug-report-window-name = Create bug report +bug-report-window-explanation = Try to be as detailed as possible. If you have recreation steps, list them! +bug-report-window-disabled-not-enabled = Bug reports are currently disabled! +bug-report-window-disabled-playtime = You do not have enough playtime to submit a bug report! +bug-report-window-disabled-cooldown = You can submit a new bug report in {$time}. +bug-report-window-disabled-submissions = You have reached the maximum number of bug reports ({$num}) for this round. +bug-report-window-title-place-holder = Bug report title +bug-report-window-description-place-holder = Type bug report here +bug-report-window-submit-button-text = Submit +bug-report-window-submit-button-confirm-text = Click again to submit! +bug-report-window-submit-button-disclaimer = Your SS14 username and other in game information will be saved. + +bug-report-window-submit-char-split = {$typed}/{$total} diff --git a/Resources/Locale/en-US/github/github-api.ftl b/Resources/Locale/en-US/github/github-api.ftl new file mode 100644 index 0000000000..c439503b60 --- /dev/null +++ b/Resources/Locale/en-US/github/github-api.ftl @@ -0,0 +1,36 @@ +github-command-test-name = testgithubapi + +cmd-testgithubapi-desc = This command makes an issue request to the github api. Remember to check the servers console for errors. +cmd-testgithubapi-help = Usage: testgithubapi + +github-command-not-enabled = The api is not enabled! +github-command-no-path = The key path is empty! +github-command-no-app-id = The app id is empty! +github-command-no-repo-name = The repository name is empty! +github-command-no-owner = The repository owner is empty! + +github-command-issue-title-one = This is a test issue! +github-command-issue-description-one = This is the description of the first issue. :) + +github-command-finish = Check your repository for a newly created issue. If you don't see any, check the server console for errors! + +github-issue-format = ## Description: + {$description} + + ## Meta Data: + Build version: {$buildVersion} + Engine version: {$engineVersion} + + Server name: {$serverName} + Submitted time: {$submittedTime} + + -- Round information -- + Round number: {$roundNumber} + Round time: {$roundTime} + Round type: {$roundType} + Map: {$map} + Number of players: {$numberOfPlayers} + + -- Submitter information -- + Player name: {$username} + Player GUID: {$playerGUID} diff --git a/Resources/Textures/Interface/bug.svg b/Resources/Textures/Interface/bug.svg new file mode 100644 index 0000000000..f79bfe3e04 --- /dev/null +++ b/Resources/Textures/Interface/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/bug.svg.192dpi.png b/Resources/Textures/Interface/bug.svg.192dpi.png new file mode 100644 index 0000000000000000000000000000000000000000..d901996bd6ba1130b25e69c088893dbaa16199e0 GIT binary patch literal 1852 zcmV-C2gCS@P)EX>4Tx04R}tkv&MmKpe$iQ>8^J4rUN>$WWauh>AFB6^c+H)C#RSm|Xe=O$fqw6tAnc`2!4RL3r>nIQsV!TLW>v=j{EWM-sA2aAT+8>x4Xsw-Egc- zDkN7|Tmsr#XZa7O)5z5>(VsK@|=Xv>K$?$k2W=z(45uWpb(Hs(_JW z9vjf1cz*Cd_}#5toEZ0$Vo9L;#dSZ1fyge4ux=P;w2Zz8& ziL%!M-W~1i?cX!4{(b<}^>Sz{?N6})000JJOGiWi{{a600FA#$9smFU32;bRa{vGx zhX4Q_hXIe}@nrx200(qQO+^Rk0u%=cBQDOb`~Uz2I!Q!9RCwC$n{Q}Ubr{FLzqRI; zfA(SpsYU+7W|Si%Qev?%XO<9V5e7x{a){2dmr=cnO0(WX80Csy)Iv-lA`KkE`a@9| zlxQt&mb2Pg>2Pi8KE3c6-F}>N&%O7YbMIO13m5jg_nhbV{e92x&+|OznKX$>Od>9T z7N7%o45&-7pE~QK18B+0;O77%{EHr7bqf8gHrwME0p?^ufK>!z4=^tQ`}53J5LRVD zfT_UY5Q1lbr+`|Z3TOtl0A0XIU>Fzz4gvdtXMiPUx7O}G6Jq-?Ff}X7pdNS$I2%It zH{fRqN3RJKgtOLHeHJ&v#lQj5!vSD%)=vZnJOuoa;J^pWrc0V{Rni8Xb4co!bXma9 zBa+^hbf2U-lIkQ)lhi2bK}nsGMg#7fCaKqKXWl1R2%HLdihF^p3b=nhush)X-+{Rk zL4f@p@iB8=T#c z+yva5#B>D~c&;|UjTc+6)#(ZFbY_pil(5@tH)l=&;BIqwkEal%eCf4kC+4?A45~`f zMoCSQ_Db66oEr(b_PC_+bAC|Lc1g1&)yCxbw>egua=tt5oSPBCV~V7;l3FDll=QN5 zZmiU&+U~8reqbxm=v`Ms`BCF>Xf$s9-gWJ9ruB}H&c`gIT?5NqSh)H> z!*Nh6X{MxGC9SXkDhmCQ-jei+bFL?;U49C%&V2o`kVM56pdm$npaFO>B=It4+}7Df zm>P*1JL+=rC$OT7j!~=inTwzNjWMmxHwHZU)(UusD}i$!mks6U_=wl()(X1F+x$dn z+8r(m$oe)^P=JPjtZ`$SuJCfNz0Wx}P$>bd?~fxmCMtlR((2AYpyYG7I!ys)dhNap z2+$Y7F;M~P0s@svV>p82{~{ld8ZLSPszc)cTSCNHHUY-84Co9AoIcN+9|(y5qtKRt zd|_LWoZIs)fu@+pVkJ;4ho*c2ys6-Kb7A-{#*Ot6(z-R7_~75?*Y!aE^{T9 zgzN#EynSFK2Ju%dXE^?Msk6!F@TsJ2A$!1A&bgzZ0+Elvi3;$m*UrN0YM;Z&GzB=~wXe^B05?Q%O!UmX&O1~8 z0;)^2^&gaI<5J+XXX@HC1#m#0*I{b~1-Q#Ib0XDPz-n|p@VgRw3<(>gni{vLoo68}90}gr}@2r3Tej(M@WvL$mW&!U81R3|1O90^hfd1bBW~DXg zDl3ZKm8Tqbu$ZM#ssJ|i2lJHC?lP`bNe%pQ;8=m7<`2M?X$b&a;g!ti!!hGt6-R)m z{J>C%2=h}C;DrJ?6kHz=rA}CW;2TNnEr{vxew#1p3t&wOtIRnR}ydR?e&ai!<#2Ii6@U6WG?(n@(V5xB&%K5L7mbm-CT=Tf<^ITJ% zr<&xPof>!M5)Ko<_MQ3&PBjs~*Bj^Z1#9LXYzQc7%*83-L*V&<-=4Gg`{TZSXCga! z1(K9aKzBlQK(}im!e+w*0Ul$qSfYsx6QUW|4xA`r;3t61<>2VbEXX1|mKN&5OU&uI qP-Qk?pYJ7o=A7%*BqlM5a^N4Qe+CTN2#ARQ0000 + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/splat.svg.192dpi.png b/Resources/Textures/Interface/splat.svg.192dpi.png new file mode 100644 index 0000000000000000000000000000000000000000..9540031f0445426f6148d774edb4906bb4cc9810 GIT binary patch literal 2069 zcmV+w2EX>4Tx04R}tkv&MmKpe$iQ?()$hjtKA$WWau6u)rPDionYsTEpvFuC+YXws0R zxHt-~1qVMCs}3&Cx;nTDg5U>;yWphgA|?JWDYS_3;J6>}?mh0_0YbgZRI?)nsG4P@ z6LB${TM+}V=)w>}m_$rsraqfWCE+=~?&0I>U6f~epZjz4DtVIuK9P8a>4rtTPCT_~ z>74h8!>lAJ#OK7L23?T&k?XR{Z=8z`3p_JyWYTlQVPdh^#&R38lA#h$5JwbMqkJLj zvch?bvs$jQ<~{ifgL!RviR&~6k-!2Lk%9;rRcxRP3sG7%QcR?2KknflaQrE9$>iDq zBgZ@{P$4;f@IUz7tyvf!bCZH`p!3DHKZb$8F3_mi_V=-EH%pV2qvfY2?_v+DNN+{ftykfE-UH^9Lm zFjA!KHIH|9xA*q%nPz`K`UP^`_=WEk00006VoOIv03HAy03zi}T>=0A010qNS#tmY z9ftq_9ftvr{DWHV|581~32s1`dy~sld|l&_ndoA@4ZDQxgYkPo&V0v?wOsP*(3eO$>f3% zK!K!UNqr?9m9*a&(|AE!9vB5Y1MC2rIiL0etAHs${)MFYFkpRv(m}Kfxa&eteim?& z)M-7?wLL0sfEC0;JAjgQKF(x;NDBpsC0LsF@v$&zl9ROsQ`E@?u$ zaFoDJ0q^@JU}O^BB4Bv{|C;uod;##2NBEbccy?eaaNL7)U9KBox<}}D0FwgN-JX^3 zZ7NuqG{9Gmzz2bzY2dB)7<62&DqjSga)e!&CgwoTHo7=h4KT$sTO(q?{oKKQUo<3)Q(J{#Rp@Vx2D2&4ZZ+nDpvS;{-z!2a{;5pz(3PyS( z1{nu5}+!A@>}h0lCGSKJlNOAVSvRR;E73idIBFLQTz* z#uGsAII#01^>Y5J%fZ8CU#Gp>7*ijY0bGR#g;08P*0tB?=O2mVRX2=&0# zVVur=!26cLrK%iojD3K)z&ADwk3t84fnf*g3cT(CDO8S-T@{0DQfCNI0`#`+1p$+| zA<6)ccmRsjP6!z6N%%AaW5Y1dwug__RIPSKz&KCdF9c-%0M~l=B$+L}CChEL8 zK>5maDlu6$K)IXw3hH=IfbyGxVzpDU!O%H*&jQ~TC~1j@|3yhp8e>jpN%+2!MoKD@ z)F^4EF(ztJWU$k&@eMT1GxIC5IBOGuEjIn`E6zL^qDs}WRsQVoZ}fF^lbsFO*z&9s zUKuj5sYn*$dftD4GGE8PoldK=puDkBXcTG!o^p@JcIM}LR?y+|Ce}t!F5R0213Z?- zfoG3R_0I&J_bmGsD)$Y1g{R}#3@hI&Aas4p7I8WQLxH6MSLedeCtkl{9ga(-88*PI zuz{B~L`4e9_Xrw%yoX_Uh7GX6v!wGA^e?b=gSrrv?DB*=2GHNbI4Q#hsC34EHNvYi z+g`z~woY-cO`wzkeZqhgc;2gJac^%)p1W6Tm$s$B&sh#O%5!Uj(`HILUhPTKXLJer?)T0~1MkuVH&2>?o1Om4Jbel?X@DM{g6XlK`vOU@ihQn)%7r2In;_VNh)H)J!5QUYvCL-{)3)_}m}_YnMFWW8K(9n%cEOvBPC zd;P8?0q*#KgHH>67b=3wpdq^1Z+F%O#NJKzJDvq$D=*d`eB_+ht1U*GCtppP2I;P| zS8BTG>{Xk6spbsrw?w6q8g%wndb9qgwx@pqQe3SuX=^^u00000NkvXXu0mjfb}!J1 literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/splat.svg.192dpi.png.yml b/Resources/Textures/Interface/splat.svg.192dpi.png.yml new file mode 100644 index 0000000000..dabd6601f7 --- /dev/null +++ b/Resources/Textures/Interface/splat.svg.192dpi.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true