Files
tbd-station-14/Content.Server/Github/GithubClient.cs
beck-thompson a8d6dbc324 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>
2025-08-15 09:10:38 -07:00

418 lines
14 KiB
C#

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
}