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
}