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 <pa.pecherskij@interfax.ru>
This commit is contained in:
beck-thompson
2025-08-15 09:10:38 -07:00
committed by GitHub
parent 890ac9f645
commit a8d6dbc324
37 changed files with 1831 additions and 3 deletions

View File

@@ -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<GameplayState>, IOnStateExited<GameplayState>
{
[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<GameTopMenuBar>()?.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<BugReportWindow>();
// 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;
}
}

View File

@@ -0,0 +1,31 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
Title="{Loc bug-report-window-name}"
MinSize="600 400">
<BoxContainer Orientation="Vertical" Margin="10 0 20 0">
<RichTextLabel Name="DisabledLabel" HorizontalAlignment="Center" Visible="False" ReservesSpace="False" />
<BoxContainer Orientation="Vertical" Name="BugReportContainer">
<RichTextLabel Name="BugReportExplanation" HorizontalAlignment="Center" Margin="0 0 0 10" Text="{Loc bug-report-window-explanation}" />
<!-- Title -->
<BoxContainer VerticalAlignment="Center">
<LineEdit Name="BugReportTitle" PlaceHolder="{Loc bug-report-window-title-place-holder}" HorizontalExpand="True" />
<Label Name="TitleCharacterCounter" StyleClasses="LabelSmall" Margin="10 0 0 0" VerticalAlignment="Center" />
</BoxContainer>
<!-- Description -->
<TextEdit Name="BugReportDescription" MaxHeight="300" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" MinHeight="200" Margin="0 10 0 0" >
<Label Name="PlaceholderCenter" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Loc bug-report-window-description-place-holder}" StyleClasses="PlaceholderText" />
</TextEdit>
<!-- Footer -->
<BoxContainer HorizontalAlignment="Center" Orientation="Horizontal" >
<Label Name="DescriptionCharacterCounter" StyleClasses="LabelSmall"/>
</BoxContainer>
<!-- Submit button and disclaimer -->
<controls:ConfirmButton Name="SubmitButton" ConfirmationText="{Loc bug-report-window-submit-button-confirm-text}" Text="{Loc bug-report-window-submit-button-text}" Margin="0 5 0 0" />
<Label HorizontalAlignment="Center" Text="{Loc bug-report-window-submit-button-disclaimer}" StyleClasses="LabelSmall" />
</BoxContainer>
</BoxContainer>
</DefaultWindow>

View File

@@ -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<PlayerBugReportInformation>? 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();
}
/// <summary>
/// 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)
/// </summary>
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;
}
/// <summary>
/// Checks if the bug report window should be enabled for this client.
/// </summary>
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();
}
}

View File

@@ -1,5 +1,6 @@
using Content.Client.UserInterface.Systems.Actions; using Content.Client.UserInterface.Systems.Actions;
using Content.Client.UserInterface.Systems.Admin; using Content.Client.UserInterface.Systems.Admin;
using Content.Client.UserInterface.Systems.BugReport;
using Content.Client.UserInterface.Systems.Bwoink; using Content.Client.UserInterface.Systems.Bwoink;
using Content.Client.UserInterface.Systems.Character; using Content.Client.UserInterface.Systems.Character;
using Content.Client.UserInterface.Systems.Crafting; using Content.Client.UserInterface.Systems.Crafting;
@@ -24,6 +25,7 @@ public sealed class GameTopMenuBarUIController : UIController
[Dependency] private readonly SandboxUIController _sandbox = default!; [Dependency] private readonly SandboxUIController _sandbox = default!;
[Dependency] private readonly GuidebookUIController _guidebook = default!; [Dependency] private readonly GuidebookUIController _guidebook = default!;
[Dependency] private readonly EmotesUIController _emotes = default!; [Dependency] private readonly EmotesUIController _emotes = default!;
[Dependency] private readonly BugReportUIController _bug = default!;
private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull<GameTopMenuBar>(); private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull<GameTopMenuBar>();
@@ -47,6 +49,7 @@ public sealed class GameTopMenuBarUIController : UIController
_action.UnloadButton(); _action.UnloadButton();
_sandbox.UnloadButton(); _sandbox.UnloadButton();
_emotes.UnloadButton(); _emotes.UnloadButton();
_bug.UnloadButton();
} }
public void LoadButtons() public void LoadButtons()
@@ -60,5 +63,6 @@ public sealed class GameTopMenuBarUIController : UIController
_action.LoadButton(); _action.LoadButton();
_sandbox.LoadButton(); _sandbox.LoadButton();
_emotes.LoadButton(); _emotes.LoadButton();
_bug.LoadButton();
} }
} }

View File

@@ -93,6 +93,15 @@
HorizontalExpand="True" HorizontalExpand="True"
AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}" AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}"
/> />
<ui:MenuButton
Name="ReportBugButton"
Access="Internal"
Icon="{xe:Tex '/Textures/Interface/bug.svg.192dpi.png'}"
ToolTip="{Loc 'game-hud-open-bug-report-window-button-tooltip'}"
MinSize="42 64"
HorizontalExpand="True"
AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}"
/>
<ui:MenuButton <ui:MenuButton
Name="AHelpButton" Name="AHelpButton"
Access="Internal" Access="Internal"

View File

@@ -0,0 +1,221 @@
using System.Linq;
using Content.Server.Administration.Logs;
using Content.Server.GameTicking;
using Content.Server.Github;
using Content.Server.Maps;
using Content.Server.Players.PlayTimeTracking;
using Content.Shared.BugReport;
using Content.Shared.CCVar;
using Content.Shared.Database;
using Robust.Server.Player;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Timing;
namespace Content.Server.BugReports;
/// <inheritdoc cref="IBugReportManager"/>
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!;
/// <summary>
/// List of player NetIds and the number of bug reports they have submitted this round.
/// UserId -> (bug reports this round, last submitted bug report)
/// </summary>
private readonly Dictionary<NetUserId, (int ReportsCount, DateTime ReportedDateTime)> _bugReportsPerPlayerThisRound = new();
private BugReportLimits _limits = default!;
private List<string> _tags = [];
private ConfigurationMultiSubscriptionBuilder _configSub = default!;
public void Initialize()
{
_net.RegisterNetMessage<BugReportMessage>(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);
}
/// <summary>
/// Checks that the given report is valid (E.g. not too long etc...).
/// Logs problems if report is invalid.
/// </summary>
/// <returns>True if the report is valid, false there is an issue with the report.</returns>
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;
}
/// <summary>
/// Checks that the player sending the report is allowed to (E.g. not spamming etc...).
/// Logs problems if report is invalid.
/// </summary>
/// <returns>True if the player can submit a report, false if they can't.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="message">The message from user.</param>
/// <returns>A <see cref="ValidPlayerBugReportReceivedEvent"/> based of the user report.</returns>
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<GameTicker>();
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;
}
}

View File

@@ -0,0 +1,94 @@
using Robust.Shared.Network;
namespace Content.Server.BugReports;
/// <summary>
/// This event stores information related to a player submitted bug report.
/// </summary>
public sealed class ValidPlayerBugReportReceivedEvent(string title, string description, BugReportMetaData metaData, List<string> tags) : EventArgs
{
/// <summary>
/// Title for the bug report. This is player controlled!
/// </summary>
public string Title = title;
/// <summary>
/// Description for the bug report. This is player controlled!
/// </summary>
public string Description = description;
/// <summary>
/// Metadata for bug report, containing data collected by server.
/// </summary>
public BugReportMetaData MetaData = metaData;
public List<string> Tags = tags;
}
/// <summary>
/// Metadata for a bug report. Holds relevant data for bug reports that aren't directly player controlled.
/// </summary>
public sealed class BugReportMetaData
{
/// <summary>
/// Bug reporter SS14 username.
/// </summary>
/// <example>piggylongsnout</example>
public required string Username;
/// <summary>
/// The GUID of the player who reported the bug.
/// </summary>
public required NetUserId PlayerGUID;
/// <summary>
/// Name of the server from which bug report was issued.
/// </summary>
/// <example>DeltaV</example>>
public required string ServerName;
/// <summary>
/// Date and time on which player submitted report (NOT round time).
/// The time is UTC and based off the servers clock.
/// </summary>
public required DateTime SubmittedTime;
/// <summary>
/// Time that has elapsed in the round. Can be null if bug was not reported during a round.
/// </summary>
public TimeSpan? RoundTime;
/// <summary>
/// Round number during which bug report was issued. Can be null if bug was reported not during round.
/// </summary>
/// <example>1311</example>
public int? RoundNumber;
/// <summary>
/// Type preset title (type of round that is being played). Can be null if bug was reported not during round.
/// </summary>
/// <example>Sandbox</example>
public string? RoundType;
/// <summary>
/// The map being played.
/// </summary>
/// <example>"Dev"</example>>
public string? Map;
/// <summary>
/// Number of players currently on server.
/// </summary>
public int NumberOfPlayers;
/// <summary>
/// Build version of the game.
/// </summary>
public required string BuildVersion;
/// <summary>
/// Engine version of the game.
/// </summary>
/// <example>253.0.0</example>
public required string EngineVersion;
}

View File

@@ -0,0 +1,22 @@
namespace Content.Server.BugReports;
/// <summary>
/// Manager for validating client bug reports, issued in-game, and relaying creation of issue in tracker to dedicated api client.
/// </summary>
public interface IBugReportManager
{
/// <summary> Will get called when the manager is first initialized. </summary>
public void Initialize();
/// <summary>
/// Will get called whenever the round is restarted.
/// Should be used to clean up anything that needs reset after each round.
/// </summary>
public void Restart();
/// <summary>
/// Will get called whenever the round is restarted.
/// Should be used to clean up anything that needs reset after each round.
/// </summary>
public void Shutdown();
}

View File

@@ -3,6 +3,7 @@ using Content.Server.Administration;
using Content.Server.Administration.Logs; using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers; using Content.Server.Administration.Managers;
using Content.Server.Afk; using Content.Server.Afk;
using Content.Server.BugReports;
using Content.Server.Chat.Managers; using Content.Server.Chat.Managers;
using Content.Server.Connection; using Content.Server.Connection;
using Content.Server.Database; using Content.Server.Database;
@@ -10,13 +11,12 @@ using Content.Server.Discord.DiscordLink;
using Content.Server.EUI; using Content.Server.EUI;
using Content.Server.GameTicking; using Content.Server.GameTicking;
using Content.Server.GhostKick; using Content.Server.GhostKick;
using Content.Server.Github;
using Content.Server.GuideGenerator; using Content.Server.GuideGenerator;
using Content.Server.Info; using Content.Server.Info;
using Content.Server.IoC; using Content.Server.IoC;
using Content.Server.Maps; using Content.Server.Maps;
using Content.Server.NodeContainer.NodeGroups; using Content.Server.NodeContainer.NodeGroups;
using Content.Server.Objectives;
using Content.Server.Players;
using Content.Server.Players.JobWhitelist; using Content.Server.Players.JobWhitelist;
using Content.Server.Players.PlayTimeTracking; using Content.Server.Players.PlayTimeTracking;
using Content.Server.Players.RateLimiting; using Content.Server.Players.RateLimiting;
@@ -111,6 +111,10 @@ namespace Content.Server.Entry
IoCManager.Resolve<GhostKickManager>().Initialize(); IoCManager.Resolve<GhostKickManager>().Initialize();
IoCManager.Resolve<ServerInfoManager>().Initialize(); IoCManager.Resolve<ServerInfoManager>().Initialize();
IoCManager.Resolve<ServerApi>().Initialize(); IoCManager.Resolve<ServerApi>().Initialize();
IoCManager.Resolve<GithubClient>().Initialize();
IoCManager.Resolve<GithubApiManager>().Initialize();
IoCManager.Resolve<GithubBackgroundWorker>().Initialize();
IoCManager.Resolve<IBugReportManager>().Initialize();
_voteManager.Initialize(); _voteManager.Initialize();
_updateManager.Initialize(); _updateManager.Initialize();
@@ -192,6 +196,8 @@ namespace Content.Server.Entry
IoCManager.Resolve<DiscordLink>().Shutdown(); IoCManager.Resolve<DiscordLink>().Shutdown();
IoCManager.Resolve<DiscordChatLink>().Shutdown(); IoCManager.Resolve<DiscordChatLink>().Shutdown();
IoCManager.Resolve<IBugReportManager>().Shutdown();
} }
private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill) private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill)

View File

@@ -725,6 +725,8 @@ namespace Content.Server.GameTicking
_banManager.Restart(); _banManager.Restart();
_bugManager.Restart();
_gameMapManager.ClearSelectedMap(); _gameMapManager.ClearSelectedMap();
// Clear up any game rules. // Clear up any game rules.

View File

@@ -1,5 +1,6 @@
using Content.Server.Administration.Logs; using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers; using Content.Server.Administration.Managers;
using Content.Server.BugReports;
using Content.Server.Chat.Managers; using Content.Server.Chat.Managers;
using Content.Server.Chat.Systems; using Content.Server.Chat.Systems;
using Content.Server.Database; using Content.Server.Database;
@@ -65,6 +66,7 @@ namespace Content.Server.GameTicking
[Dependency] private readonly MetaDataSystem _metaData = default!; [Dependency] private readonly MetaDataSystem _metaData = default!;
[Dependency] private readonly SharedRoleSystem _roles = default!; [Dependency] private readonly SharedRoleSystem _roles = default!;
[Dependency] private readonly ServerDbEntryManager _dbEntryManager = default!; [Dependency] private readonly ServerDbEntryManager _dbEntryManager = default!;
[Dependency] private readonly IBugReportManager _bugManager = default!;
[ViewVariables] private bool _initialized; [ViewVariables] private bool _initialized;
[ViewVariables] private bool _postInitialized; [ViewVariables] private bool _postInitialized;

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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"));
}
}

View File

@@ -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;
}
}

View File

@@ -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<IGithubRequest> _channel = Channel.CreateUnbounded<IGithubRequest>();
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
public ChannelWriter<IGithubRequest> 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();
}
/// <summary>
/// Directly send a request to the API. This does not have any rate limits checks so be careful!
/// <b>Only use this if you have a very good reason to!</b>
/// </summary>
/// <param name="request">The request to make.</param>
/// <param name="ct">Request cancellation token.</param>
/// <returns>The direct HTTP response from the API. If null the request could not be made.</returns>
private async Task SendRequest<T>(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());
}
}
}

View File

@@ -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;
/// <summary>
/// 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!
/// <br/>
/// <br/> Some useful information about the api:
/// <br/> <see href="https://docs.github.com/en/rest?apiVersion=2022-11-28">Api home page</see>
/// <br/> <see href="https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28">Best practices</see>
/// <br/> <see href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28">Rate limit information</see>
/// <br/> <see href="https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2022-11-28">Troubleshooting</see>
/// </summary>
/// <remarks>As it uses async, it should be called from background worker when possible, like <see cref="GithubBackgroundWorker"/>.</remarks>
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<T>(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
/// <summary>
/// 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.
/// </summary>
/// <param name="request">The request you want to make.</param>
/// <param name="ct">Token for operation cancellation.</param>
/// <returns>The direct HTTP response from the API. If null the request could not be made.</returns>
public async Task<HttpResponseMessage?> 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<HttpResponseMessage?> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="headers">The headers that you want to search.</param>
/// <param name="header">The header you want to get the long value for.</param>
/// <param name="value">Value of header, if found, null otherwise.</param>
/// <returns>The headers value if it exists, null otherwise.</returns>
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
/// <summary>
/// Try to get a valid verification token from the GitHub api
/// </summary>
/// <returns>True if the token is valid and successfully found, false if there was an error.</returns>
private async Task<bool> 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<List<InstallationResponse>>(_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<TokenResponse>(_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
}

View File

@@ -0,0 +1,38 @@
using System.Net.Http;
using System.Text.Json.Serialization;
namespace Content.Server.Github.Requests;
/// <summary>
/// <see href="https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue"/>>
/// </summary>
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<string> Labels = [];
[JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> Assignees = [];
#endregion
public string GetLocation(string owner, string repository)
{
return $"repos/{owner}/{repository}/issues";
}
}

View File

@@ -0,0 +1,44 @@
using System.Net.Http;
using System.Text.Json.Serialization;
namespace Content.Server.Github.Requests;
/// <summary>
/// Interface for all github api requests.
/// </summary>
/// <remarks>
/// WARNING: You must add this JsonDerivedType for all requests that have json otherwise they will not parse properly!
/// </remarks>
[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(CreateIssueRequest))]
[JsonDerivedType(typeof(InstallationsRequest))]
[JsonDerivedType(typeof(TokenRequest))]
public interface IGithubRequest
{
/// <summary>
/// The kind of request method for the request.
/// </summary>
[JsonIgnore]
public HttpMethod RequestMethod { get; }
/// <summary>
/// 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
/// </summary>
[JsonIgnore]
public GithubAuthMethod AuthenticationMethod { get; }
/// <summary>
/// Location of the api endpoint for this request.
/// </summary>
/// <param name="owner">Owner of the repository.</param>
/// <param name="repository">The repository to make the request.</param>
/// <returns>The api location for this request.</returns>
public string GetLocation(string owner, string repository);
}
public enum GithubAuthMethod
{
JWT,
Token,
}

View File

@@ -0,0 +1,18 @@
using System.Net.Http;
namespace Content.Server.Github.Requests;
/// <summary>
/// <see href="https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token"/>>
/// </summary>
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";
}
}

View File

@@ -0,0 +1,22 @@
using System.Net.Http;
using System.Text.Json.Serialization;
namespace Content.Server.Github.Requests;
/// <summary>
/// <see href="https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app"/>>
/// </summary>
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";
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Content.Server.Github.Responses;
/// <summary>
/// Not all fields are filled out - only the necessary ones. If you need more just add them.
/// <see href="https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app"/>>
/// </summary>
public sealed class InstallationResponse
{
public required int Id { get; set; }
public required GithubInstallationAccount Account { get; set; }
}
/// <inheritdoc cref="InstallationResponse"/>
public sealed class GithubInstallationAccount
{
public required string Login { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Content.Server.Github.Responses;
/// <summary>
/// Not all fields are filled out - only the necessary ones. If you need more just add them.
/// <see href="https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app"/>>
/// </summary>
public sealed class TokenResponse
{
public required string Token { get; set; }
[JsonPropertyName("expires_at")]
public required DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,100 @@
using System.Net.Http;
using System.Threading.Tasks;
using System.Threading;
using System.Net;
namespace Content.Server.Github;
/// <summary>
/// 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.
/// <br/>
/// <br/> Links to the api for more information:
/// <br/> <see href="https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28">Best practices</see>
/// <br/> <see href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28">Rate limit information</see>
/// </summary>
/// <remarks> This was designed for the 2022-11-28 version of the API. </remarks>
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<HttpResponseMessage> 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;
}
/// <summary>
/// Follows these guidelines but also has a small buffer so you should never quite hit zero:
/// <br/>
/// <see href="https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28#handle-rate-limit-errors-appropriately"/>
/// </summary>
/// <param name="response">The last response from the API.</param>
/// <param name="attempt">Number of current call attempt.</param>
/// <returns>The amount of time to wait until the next request.</returns>
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)));
}
}

View File

@@ -3,6 +3,7 @@ using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers; using Content.Server.Administration.Managers;
using Content.Server.Administration.Notes; using Content.Server.Administration.Notes;
using Content.Server.Afk; using Content.Server.Afk;
using Content.Server.BugReports;
using Content.Server.Chat.Managers; using Content.Server.Chat.Managers;
using Content.Server.Connection; using Content.Server.Connection;
using Content.Server.Database; using Content.Server.Database;
@@ -11,6 +12,7 @@ using Content.Server.Discord.DiscordLink;
using Content.Server.Discord.WebhookMessages; using Content.Server.Discord.WebhookMessages;
using Content.Server.EUI; using Content.Server.EUI;
using Content.Server.GhostKick; using Content.Server.GhostKick;
using Content.Server.Github;
using Content.Server.Info; using Content.Server.Info;
using Content.Server.Mapping; using Content.Server.Mapping;
using Content.Server.Maps; using Content.Server.Maps;
@@ -59,6 +61,7 @@ namespace Content.Server.IoC
IoCManager.Register<GhostKickManager>(); IoCManager.Register<GhostKickManager>();
IoCManager.Register<ISharedAdminLogManager, AdminLogManager>(); IoCManager.Register<ISharedAdminLogManager, AdminLogManager>();
IoCManager.Register<IAdminLogManager, AdminLogManager>(); IoCManager.Register<IAdminLogManager, AdminLogManager>();
IoCManager.Register<IBugReportManager, BugReportManager>();
IoCManager.Register<PlayTimeTrackingManager>(); IoCManager.Register<PlayTimeTrackingManager>();
IoCManager.Register<UserDbDataManager>(); IoCManager.Register<UserDbDataManager>();
IoCManager.Register<ServerInfoManager>(); IoCManager.Register<ServerInfoManager>();
@@ -76,9 +79,11 @@ namespace Content.Server.IoC
IoCManager.Register<ConnectionManager>(); IoCManager.Register<ConnectionManager>();
IoCManager.Register<MultiServerKickManager>(); IoCManager.Register<MultiServerKickManager>();
IoCManager.Register<CVarControlManager>(); IoCManager.Register<CVarControlManager>();
IoCManager.Register<DiscordLink>(); IoCManager.Register<DiscordLink>();
IoCManager.Register<DiscordChatLink>(); IoCManager.Register<DiscordChatLink>();
IoCManager.Register<GithubApiManager>();
IoCManager.Register<GithubBackgroundWorker>();
IoCManager.Register<GithubClient>();
} }
} }
} }

View File

@@ -464,6 +464,7 @@ public enum LogType
/// Logs related to botany, such as planting and harvesting crops /// Logs related to botany, such as planting and harvesting crops
/// </summary> /// </summary>
Botany = 100, Botany = 100,
/// <summary> /// <summary>
/// Artifact node got activated. /// Artifact node got activated.
/// </summary> /// </summary>
@@ -478,4 +479,9 @@ public enum LogType
/// Events relating to midi playback. /// Events relating to midi playback.
/// </summary> /// </summary>
Instrument = 103, Instrument = 103,
/// <summary>
/// For anything relating to bug reports.
/// </summary>
BugReport = 104,
} }

View File

@@ -0,0 +1,42 @@
using Lidgren.Network;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared.BugReport;
/// <summary>
/// Message with bug report data, which should be handled by server and used to create issue on issue tracker
/// (or some other notification).
/// </summary>
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;
}
/// <summary>
/// Stores user specified information from a bug report.
/// </summary>
/// <remarks>
/// Clients can put whatever they want here so be careful!
/// </remarks>
public sealed class PlayerBugReportInformation
{
public string BugReportTitle = string.Empty;
public string BugReportDescription = string.Empty;
}

View File

@@ -0,0 +1,64 @@
using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
public sealed partial class CCVars
{
/// <summary>
/// Allow users to submit bug reports. Will enable a button on the hotbar. See <see cref="GithubEnabled" /> for
/// setting up the GitHub API!
/// </summary>
public static readonly CVarDef<bool> EnablePlayerBugReports =
CVarDef.Create("bug_reports.enable_player_bug_reports", false, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Minimum playtime that players need to have played to submit bug reports.
/// </summary>
public static readonly CVarDef<int> MinimumPlaytimeInMinutesToEnableBugReports =
CVarDef.Create("bug_reports.minimum_playtime_in_minutes_to_enable_bug_reports", 120, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Maximum number of bug reports a user can submit per round.
/// </summary>
public static readonly CVarDef<int> MaximumBugReportsPerRound =
CVarDef.Create("bug_reports.maximum_bug_reports_per_round", 5, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Minimum time between bug reports.
/// </summary>
public static readonly CVarDef<int> MinimumSecondsBetweenBugReports =
CVarDef.Create("bug_reports.minimum_seconds_between_bug_reports", 120, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Maximum length of a bug report title.
/// </summary>
public static readonly CVarDef<int> MaximumBugReportTitleLength =
CVarDef.Create("bug_reports.maximum_bug_report_title_length", 35, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Minimum length of a bug report title.
/// </summary>
public static readonly CVarDef<int> MinimumBugReportTitleLength =
CVarDef.Create("bug_reports.minimum_bug_report_title_length", 10, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Maximum length of a bug report description.
/// </summary>
public static readonly CVarDef<int> MaximumBugReportDescriptionLength =
CVarDef.Create("bug_reports.maximum_bug_report_description_length", 750, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Minimum length of a bug report description.
/// </summary>
public static readonly CVarDef<int> MinimumBugReportDescriptionLength =
CVarDef.Create("bug_reports.minimum_bug_report_description_length", 10, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// List of tags that are added to the report. Separate each value with ",".
/// </summary>
/// <example>
/// IG report, Bug
/// </example>
public static readonly CVarDef<string> BugReportTags =
CVarDef.Create("bug_reports.tags", "IG bug report", CVar.SERVER | CVar.REPLICATED);
}

View File

@@ -0,0 +1,71 @@
using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
public sealed partial class CCVars
{
/// <summary>
/// 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 <see cref="GithubAppPrivateKeyPath"/>, <see cref="GithubAppId"/>,
/// <see cref="GithubRepositoryName"/> and <see cref="GithubRepositoryOwner"/>.
/// </summary>
public static readonly CVarDef<bool> GithubEnabled =
CVarDef.Create("github.github_enabled", true, CVar.SERVERONLY);
/// <summary>
/// GitHub app private keys location. <b>PLEASE READ THIS CAREFULLY!!</b>
/// <list type="bullet">
/// <item>
/// 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.
/// </item>
/// <item>
/// 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).
/// <br/><br/>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".
/// </item>
/// </list>
/// Also remember to use the <code>testgithubapi</code> command to test if you set everything up correctly.
/// [Insert YouTube video link with walkthrough here]
/// </summary>
/// <example>
/// (If your on linux): /home/beck/key.pem
/// </example>
public static readonly CVarDef<string> GithubAppPrivateKeyPath =
CVarDef.Create("github.github_app_private_key_path", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
/// <summary>
/// The GitHub apps app id. Go to https://github.com/settings/apps/APPNAME to find the app id.
/// </summary>
/// <example>
/// 1009555
/// </example>
public static readonly CVarDef<string> GithubAppId =
CVarDef.Create("github.github_app_id", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
/// <summary>
/// Name of the targeted GitHub repository.
/// </summary>
/// <example>
/// If your URL was https://github.com/space-wizards/space-station-14 the repo name would be "space-station-14".
/// </example>>
public static readonly CVarDef<string> GithubRepositoryName =
CVarDef.Create("github.github_repository_name", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
/// <summary>
/// Owner of the GitHub repository.
/// </summary>
/// <example>
/// If your URL was https://github.com/space-wizards/space-station-14 the owner would be "space-wizards".
/// </example>
public static readonly CVarDef<string> GithubRepositoryOwner =
CVarDef.Create("github.github_repository_owner", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
/// <summary>
/// The maximum number of times the api will retry requests before giving up.
/// </summary>
public static readonly CVarDef<int> GithubMaxRetries =
CVarDef.Create("github.github_max_retries", 3, CVar.SERVERONLY | CVar.CONFIDENTIAL);
}

View File

@@ -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-actions-menu-button-tooltip = Open actions menu.
game-hud-open-admin-menu-button-tooltip = Open admin menu. game-hud-open-admin-menu-button-tooltip = Open admin menu.
game-hud-open-sandbox-menu-button-tooltip = Open sandbox menu. game-hud-open-sandbox-menu-button-tooltip = Open sandbox menu.
game-hud-open-bug-report-window-button-tooltip = Open bug report menu.

View File

@@ -0,0 +1 @@
bug-report-report-unknown = unknown

View File

@@ -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}

View File

@@ -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}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools :) -->
<!-- From: https://www.svgrepo.com/page/licensing/#CC%20Attribution with a CC Attribution License -->
<!-- Recolored to white by Beck -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M14 21.7101C13.3663 21.8987 12.695 22 12 22C8.13401 22 5 18.866 5 15V11.9375C5 9.76288 6.76288 8 8.9375 8H15.0625C17.2371 8 19 9.76288 19 11.9375V15C19 16.9073 18.2372 18.6364 17 19.899" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M8.25 7.5C8.25 7.08579 7.91421 6.75 7.5 6.75C7.08579 6.75 6.75 7.08579 6.75 7.5H8.25ZM7.63452 4.58281C7.40411 4.92702 7.49636 5.39285 7.84058 5.62326C8.18479 5.85367 8.65062 5.76141 8.88103 5.41719L7.63452 4.58281ZM17.25 8.5V7.5H15.75V8.5H17.25ZM6.75 7.5V8.5H8.25V7.5H6.75ZM17.25 7.5C17.25 4.60051 14.8995 2.25 12 2.25V3.75C14.0711 3.75 15.75 5.42893 15.75 7.5H17.25ZM12 2.25C10.179 2.25 8.57506 3.17771 7.63452 4.58281L8.88103 5.41719C9.55501 4.41032 10.7005 3.75 12 3.75V2.25Z" fill="#ffffff"/> <path d="M19 14H22" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M5 14H2" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M14.5 3.5L17 2" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M9.5 3.5L7 2" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M20.5 20.0002L18.5 19.2002" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M20.5 7.9998L18.5 8.7998" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M3.5 20.0002L5.5 19.2002" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M3.5 7.9998L5.5 8.7998" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M12 21.5V15" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,2 @@
sample:
filter: true

View File

@@ -0,0 +1,14 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<!-- From: https://www.svgrepo.com/svg/20672/color-splash -->
<!-- Licence: CC0 -->
<!-- Edited by Beck to be white under the same licence -->
<svg fill="#ffffff" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 297 297" xml:space="preserve">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <g> <path d="M165.797,288.71c-16.89,0-23.974-15.545-26.301-20.652c-1.919-4.214-4.4-6.728-6.637-6.728 c-1.343,0-2.797,0.826-3.621,2.059c-10.602,15.854-29.265,25.322-49.922,25.322c-34.197,0-62.021-27.887-62.021-62.166 c0-21.166,8.271-38.889,22.124-47.404c3.865-2.381,5.826-4.702,5.826-6.9c0-2.814-3.012-4.884-5.989-5.476 C15.409,162.026,0,144.645,0,122.485c0-24.713,20.065-44.82,44.729-44.82c11.259,0,22.653,4.772,30.479,12.766 c3.585,3.661,7.638,5.365,12.756,5.365c8.769,0,16.306-6.502,16.459-14.196c0.047-2.183-0.073-9.916-0.124-12.712 c-0.001-0.063-0.002-0.124-0.002-0.185c0-33.875,27.013-60.413,61.499-60.413c34.199,0,62.024,27.887,62.024,62.166 c0,14.94-7.221,31.259-12.493,43.174l-0.237,0.537c-3.781,8.552-3.697,16.272,0.246,22.327c4.468,6.86,13.725,11.124,24.159,11.124 c1.115,0,2.254-0.048,3.384-0.143c2.557-0.215,7.247-0.388,9.649-0.428c0.243-0.004,0.471-0.006,0.7-0.006 c24.135,0,43.77,20.104,43.77,44.818c0,24.714-20.065,44.82-44.729,44.82c-12.84,0-22.554-6.859-30.36-12.371 c-0.97-0.685-1.936-1.366-2.905-2.034c-4.171-2.877-7.974-4.159-12.333-4.159c-4.903,0-9.571,2.035-13.147,5.728 c-3.759,3.884-5.732,9.02-5.557,14.46c0.102,3.117,0.82,5.201,1.91,8.355c1.066,3.087,2.392,6.927,3.264,12.284 c1.13,6.959-0.928,13.939-5.793,19.656C181.964,284.93,173.906,288.71,165.797,288.71z M132.859,241.057 c10.559,0,19.702,6.778,25.084,18.596c4.004,8.785,6.701,8.785,7.854,8.785c2.142,0,4.599-1.195,6.113-2.975 c0.55-0.647,1.44-1.931,1.222-3.269c-0.591-3.637-1.477-6.201-2.414-8.916c-1.317-3.814-2.812-8.137-3.012-14.318 c-0.355-10.979,3.642-21.355,11.255-29.217c7.319-7.563,17.421-11.899,27.712-11.899c8.487,0,16.285,2.532,23.837,7.739 c1.029,0.709,2.061,1.436,3.094,2.166c6.58,4.646,12.264,8.658,18.668,8.658c13.484,0,24.456-11.011,24.456-24.547 c0-13.534-10.541-24.547-23.498-24.547l-0.364,0.004c-2.324,0.04-6.461,0.206-8.29,0.359c-1.692,0.142-3.401,0.213-5.079,0.213 c-17.474,0-32.855-7.6-41.145-20.331c-7.756-11.908-8.395-26.679-1.8-41.591l0.237-0.541c4.543-10.262,10.761-24.316,10.761-34.971 c0-23.1-18.73-41.893-41.752-41.893c-23.473,0-41.181,17.214-41.227,40.053c0.025,1.451,0.185,10.525,0.121,13.406 c-0.373,18.766-16.85,34.047-36.727,34.047c-10.633,0-19.798-3.854-27.24-11.454c-3.972-4.057-10.25-6.677-15.995-6.677 c-13.485,0-24.457,11.012-24.457,24.547c0,12.422,8.573,21.542,22.935,24.395c13.139,2.611,22.313,13.039,22.313,25.359 c0,6.678-2.686,16.292-15.477,24.166c-9.2,5.656-12.476,19.276-12.476,30.139c0,23.1,18.729,41.894,41.749,41.894 c13.875,0,26.239-6.103,33.074-16.325C116.959,245.29,124.8,241.057,132.859,241.057z"/> </g> </g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,2 @@
sample:
filter: true