From 51b47a94a971f0139334f3498221aa225e06aa8f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 10 Mar 2025 18:07:55 -0400 Subject: [PATCH] Add new Game PR Announcer (#89647) Ready for deployment by operations. --- .github/CODEOWNERS | 4 +- .gitignore | 5 + code/datums/world_topic.dm | 2 + .../GameServersConnectivityHealthCheck.cs | 149 ++++ .../PRAnnouncePayload.cs | 13 + .../PRAnnouncePayloadPullRequest.cs | 27 + .../Tgstation.PRAnnouncer/PRAnnounceQuery.cs | 59 ++ tools/Tgstation.PRAnnouncer/Program.cs | 84 ++ tools/Tgstation.PRAnnouncer/ServerConfig.cs | 26 + tools/Tgstation.PRAnnouncer/Settings.cs | 35 + .../Tgstation.PRAnnouncer.csproj | 21 + .../TgstationWebhookEventProcessor.cs | 175 +++++ tools/Tgstation.PRAnnouncer/TopicTimeouts.cs | 33 + tools/Tgstation.PRAnnouncer/appsettings.json | 9 + tools/Tgstation.PRAnnouncer/deps.json | 322 ++++++++ tools/Tgstation.PRAnnouncer/flake.nix | 13 + tools/Tgstation.PRAnnouncer/package.nix | 57 ++ .../tgstation-pr-announcer.nix | 102 +++ .../tgstation-pr-announcer.sln | 25 + .../github_webhook_processor.php | 741 ------------------ tools/WebhookProcessor/secret.php | 136 ---- 21 files changed, 1159 insertions(+), 879 deletions(-) create mode 100644 tools/Tgstation.PRAnnouncer/GameServersConnectivityHealthCheck.cs create mode 100644 tools/Tgstation.PRAnnouncer/PRAnnouncePayload.cs create mode 100644 tools/Tgstation.PRAnnouncer/PRAnnouncePayloadPullRequest.cs create mode 100644 tools/Tgstation.PRAnnouncer/PRAnnounceQuery.cs create mode 100644 tools/Tgstation.PRAnnouncer/Program.cs create mode 100644 tools/Tgstation.PRAnnouncer/ServerConfig.cs create mode 100644 tools/Tgstation.PRAnnouncer/Settings.cs create mode 100644 tools/Tgstation.PRAnnouncer/Tgstation.PRAnnouncer.csproj create mode 100644 tools/Tgstation.PRAnnouncer/TgstationWebhookEventProcessor.cs create mode 100644 tools/Tgstation.PRAnnouncer/TopicTimeouts.cs create mode 100644 tools/Tgstation.PRAnnouncer/appsettings.json create mode 100644 tools/Tgstation.PRAnnouncer/deps.json create mode 100644 tools/Tgstation.PRAnnouncer/flake.nix create mode 100644 tools/Tgstation.PRAnnouncer/package.nix create mode 100644 tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.nix create mode 100644 tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.sln delete mode 100644 tools/WebhookProcessor/github_webhook_processor.php delete mode 100644 tools/WebhookProcessor/secret.php diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 035e7dd9996..9b7814ea469 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,6 +23,8 @@ /code/modules/tgs/ @Cyberboss /code/ze_genesis_call/ @Cyberboss /tools/tgs_test/ @Cyberboss +/tools/Tgstation.DiscordDiscussions/ @Cyberboss +/tools/Tgstation.PRAnnouncer/ @Cyberboss # Cobby @@ -214,8 +216,6 @@ /tools/build/ @scriptis @stylemistake /tools/tgs_scripts/ @Cyberboss @scriptis -/tools/WebhookProcessor/ @BraveMole @TiviPlus - # Host Hell /code/controllers/configuration/entries @scriptis diff --git a/.gitignore b/.gitignore index 0a43cf8a696..87c5006457e 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,11 @@ Temporary Items /tools/Tgstation.DiscordDiscussions/bin/* /tools/Tgstation.DiscordDiscussions/obj/* /tools/Tgstation.DiscordDiscussions/Properties/launchSettings.json +/tools/Tgstation.PRAnnouncer/.vs/* +/tools/Tgstation.PRAnnouncer/bin/* +/tools/Tgstation.PRAnnouncer/obj/* +/tools/Tgstation.PRAnnouncer/Properties/launchSettings.json +/tools/Tgstation.PRAnnouncer/appsettings.*.json #GitHub Atom .atom-build.json diff --git a/code/datums/world_topic.dm b/code/datums/world_topic.dm index b363423f0ac..cd817fe3873 100644 --- a/code/datums/world_topic.dm +++ b/code/datums/world_topic.dm @@ -44,6 +44,7 @@ // TOPICS +// If you modify the protocol for this, update tools/Tgstation.PRAnnouncer /datum/world_topic/ping keyword = "ping" log = FALSE @@ -60,6 +61,7 @@ /datum/world_topic/playing/Run(list/input) return GLOB.player_list.len +// If you modify the protocol for this, update tools/Tgstation.PRAnnouncer /datum/world_topic/pr_announce keyword = "announce" require_comms_key = TRUE diff --git a/tools/Tgstation.PRAnnouncer/GameServersConnectivityHealthCheck.cs b/tools/Tgstation.PRAnnouncer/GameServersConnectivityHealthCheck.cs new file mode 100644 index 00000000000..0ff5e061216 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/GameServersConnectivityHealthCheck.cs @@ -0,0 +1,149 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Byond.TopicSender; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Tgstation.PRAnnouncer +{ + /// + /// An that checks connectivity to configured game servers. + /// + sealed class GameServersConnectivityHealthCheck : IHealthCheck, IDisposable + { + /// + /// The name of the health check. + /// + public const string Name = "GameServerConnectivity"; + + /// + /// The to use. + /// + readonly ITopicClient topicClient; + + /// + /// The for the . + /// + readonly IOptionsMonitor settings; + + /// + /// The to write to. + /// + readonly ILogger logger; + + /// + /// The returned from for . + /// + readonly IDisposable? optionsMonitorRegistration; + + /// + /// The last time was run. + /// + DateTimeOffset lastCheck; + + /// + /// The last response from . + /// + HealthCheckResult cachedResult; + + /// + /// Initializes a new instance of the class.. + /// + /// The value of . + /// The value of . + /// The value of . + public GameServersConnectivityHealthCheck( + ITopicClient topicClient, + IOptionsMonitor settings, + ILogger logger) + { + this.topicClient = topicClient ?? throw new ArgumentNullException(nameof(topicClient)); + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + optionsMonitorRegistration = settings.OnChange(_ => lastCheck = DateTimeOffset.MinValue); + } + + /// + public void Dispose() + => optionsMonitorRegistration?.Dispose(); + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var nextCheck = lastCheck + TimeSpan.FromSeconds(settings.CurrentValue.GameServerHealthCheckSeconds); + if (now >= nextCheck) + { + cachedResult = await LiveHealthCheck(cancellationToken); + lastCheck = now; + } + + return cachedResult; + } + + /// + /// Run a non-cached health check. + /// + /// The for the operation. + /// A resulting in the . + async ValueTask LiveHealthCheck(CancellationToken cancellationToken) + { + var servers = settings.CurrentValue.Servers; + if (servers == null || servers.Count == 0) + return HealthCheckResult.Healthy("No servers to check on"); + + var tasks = servers.ToDictionary(server => server, server => CheckServer(server, cancellationToken)); + await Task.WhenAll(tasks.Values); + + var failedTasks = tasks.Where(kvp => kvp.Value.Result != null).ToArray(); + var counter = 0; + var data = tasks.ToDictionary( + kvp => $"#{++counter}: {kvp.Key.Address}:{kvp.Key.Port}", + kvp => kvp.Value.Result == null ? (object)"Success" : "Failed"); + if (failedTasks.Length > 0) + { + var exception = new AggregateException(failedTasks.Select(kvp => kvp.Value.Result!)); + if (failedTasks.Length == servers.Count) + return HealthCheckResult.Degraded("All servers have failed the ping test!", exception, data); + + return HealthCheckResult.Degraded("Some servers have failed the ping test!", exception, data); + } + + return HealthCheckResult.Healthy("All servers passed the ping test", data); + } + + /// + /// Check on a given . + /// + /// The of the server to check on. + /// The for the operation. + /// A resulting in on a successful connection test, or an if an error occurred. + async Task CheckServer(ServerConfig server, CancellationToken cancellationToken) + { + var address = server.Address; + try + { + if (address == null) + throw new Exception($"A server has a null {nameof(ServerConfig.Address)}!"); + + var result = await topicClient.SendTopic(address, "ping", server.Port, cancellationToken) + ?? throw new Exception("Topic client returned null!"); + if (result.ResponseType != TopicResponseType.FloatResponse) + throw new Exception("Response was not a float!"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Server \"{address}:{port}\" failed health check!", address, server.Port); + return ex; + } + + return null; + } + } +} diff --git a/tools/Tgstation.PRAnnouncer/PRAnnouncePayload.cs b/tools/Tgstation.PRAnnouncer/PRAnnouncePayload.cs new file mode 100644 index 00000000000..1166e29bc1a --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/PRAnnouncePayload.cs @@ -0,0 +1,13 @@ +namespace Tgstation.PRAnnouncer +{ + /// + /// The "payload" option for the PR announce topic. + /// + sealed class PRAnnouncePayload + { + /// + /// The . + /// + public required PRAnnouncePayloadPullRequest PullRequest { get; init; } + } +} diff --git a/tools/Tgstation.PRAnnouncer/PRAnnouncePayloadPullRequest.cs b/tools/Tgstation.PRAnnouncer/PRAnnouncePayloadPullRequest.cs new file mode 100644 index 00000000000..fbc6fef892f --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/PRAnnouncePayloadPullRequest.cs @@ -0,0 +1,27 @@ +using System; + +using Octokit.Webhooks.Models.PullRequestEvent; + +namespace Tgstation.PRAnnouncer +{ + /// + /// The pull_request entry in the announce payload. + /// + public class PRAnnouncePayloadPullRequest + { + /// + /// The . + /// + public long Id { get; } + + /// + /// Initializes a new instance of the . + /// + /// The . + public PRAnnouncePayloadPullRequest(PullRequest pullRequest) + { + ArgumentNullException.ThrowIfNull(pullRequest); + Id = pullRequest.Id; + } + } +} diff --git a/tools/Tgstation.PRAnnouncer/PRAnnounceQuery.cs b/tools/Tgstation.PRAnnouncer/PRAnnounceQuery.cs new file mode 100644 index 00000000000..6a47357277c --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/PRAnnounceQuery.cs @@ -0,0 +1,59 @@ +using System; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Web; + +using Octokit.Webhooks.Events; + +namespace Tgstation.PRAnnouncer +{ + /// + /// The payload the /datum/world_topic/pr_announce handler expects. + /// + sealed class PRAnnounceQuery + { + /// + /// The for sending payloads to game servers. + /// + static readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + readonly PRAnnouncePayload payload; + + /// + /// The raw html announce . + /// + readonly string announce; + + /// + /// The comms key. + /// + readonly string key; + + /// + /// Initializes a new instance of the class. + /// + /// The to announce. + /// The value of . + public PRAnnounceQuery(PullRequestEvent pullRequestEvent, string commsKey) + { + ArgumentNullException.ThrowIfNull(pullRequestEvent); + key = commsKey ?? throw new ArgumentNullException(commsKey); + payload = new PRAnnouncePayload + { + PullRequest = new PRAnnouncePayloadPullRequest(pullRequestEvent.PullRequest), + }; + + announce = $"[{pullRequestEvent.PullRequest.Base.Repo.FullName}] Pull Request {(pullRequestEvent.PullRequest.Merged == true ? "merged" : (pullRequestEvent.Action ?? "(NULL ACTION)"))} {HtmlEncoder.Default.Encode(pullRequestEvent.Sender?.Login ?? "(NULL)")}: #{pullRequestEvent.PullRequest.Number} {HtmlEncoder.Default.Encode($"{pullRequestEvent.PullRequest.User.Login} - {pullRequestEvent.PullRequest.Title}")}"; + } + + /// + /// Serialize the to a topic . + /// + /// The serialized . + public string Serialize() + => $"?key={HttpUtility.UrlEncode(key)}&announce={HttpUtility.UrlEncode(announce)}&payload={HttpUtility.UrlEncode(JsonSerializer.Serialize(payload, serializerOptions))}"; + } +} diff --git a/tools/Tgstation.PRAnnouncer/Program.cs b/tools/Tgstation.PRAnnouncer/Program.cs new file mode 100644 index 00000000000..13c7091b5ee --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/Program.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; + +using Byond.TopicSender; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Octokit.Webhooks; +using Octokit.Webhooks.AspNetCore; + +using Prometheus; + +namespace Tgstation.PRAnnouncer +{ + /// + /// The program. + /// + public class Program + { + /// + /// Program entrypoint. + /// + /// Command line arguments. + /// A representing the lifetime of the program. + public static async Task Main(string[] args) + { + var appBuilder = WebApplication.CreateBuilder(args); + + appBuilder.Host.UseSystemd(); + + var servicesBuilder = appBuilder.Services; + + servicesBuilder.AddOptions(); + + servicesBuilder.AddHealthChecks() + .AddCheck(GameServersConnectivityHealthCheck.Name) + .ForwardToPrometheus(); + + servicesBuilder.AddLogging(loggingBuilder => loggingBuilder.AddConsole()); + + servicesBuilder.Configure(appBuilder.Configuration.GetSection("Settings")); + + servicesBuilder.AddSingleton(services => { + var timeouts = services.GetRequiredService>().Value.TopicTimeouts; + return new TopicClient( + new SocketParameters + { + ConnectTimeout = TimeSpan.FromSeconds(timeouts?.ConnectTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds), + SendTimeout = TimeSpan.FromSeconds(timeouts?.SendTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds), + ReceiveTimeout = TimeSpan.FromSeconds(timeouts?.ReceiveTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds), + DisconnectTimeout = TimeSpan.FromSeconds(timeouts?.DisconnectTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds), + }, + services.GetService>()); + }); + + servicesBuilder.AddSingleton(_ => Metrics.DefaultFactory); + + servicesBuilder.AddSingleton(); + + await using var app = appBuilder.Build(); + var services = app.Services; + var logger = services.GetRequiredService>(); + try + { + var settings = services.GetRequiredService>(); + var secret = settings.Value.GitHubSecret; + + app.MapGitHubWebhooks(secret: secret); + app.MapMetrics(); + app.MapHealthChecks("/health"); + + await app.RunAsync(); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Application crashed!"); + } + } + } +} diff --git a/tools/Tgstation.PRAnnouncer/ServerConfig.cs b/tools/Tgstation.PRAnnouncer/ServerConfig.cs new file mode 100644 index 00000000000..4692ab0c19f --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/ServerConfig.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Tgstation.PRAnnouncer +{ + /// + /// Configuration for a game server to send announcement messages to. + /// + sealed class ServerConfig + { + /// + /// The server's address. + /// + public string? Address { get; set; } + + /// + /// The server's port. + /// + public ushort Port { get; set; } + + /// + /// The list of repository slugs that the server should listen to pull request events for. If , all repositories will be listened to. + /// + /// tgstation/tgstation + public IReadOnlyList? InterestedRepoSlugs { get; set; } + } +} diff --git a/tools/Tgstation.PRAnnouncer/Settings.cs b/tools/Tgstation.PRAnnouncer/Settings.cs new file mode 100644 index 00000000000..bf3d8c5c017 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/Settings.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Tgstation.PRAnnouncer +{ + /// + /// App settings. + /// + sealed class Settings + { + /// + /// The . These require a server restart to change. + /// + public TopicTimeouts? TopicTimeouts { get; set; } + + /// + /// Secret for communication with game servers. + /// + public string? CommsKey { get; set; } + + /// + /// The secret for the GitHub webhook, if any. + /// + public string? GitHubSecret { get; set; } + + /// + /// The number of seconds between ping checks on configured . + /// + public uint GameServerHealthCheckSeconds { get; set; } + + /// + /// The s for each server to forward topics to. + /// + public IReadOnlyList? Servers { get; set; } + } +} diff --git a/tools/Tgstation.PRAnnouncer/Tgstation.PRAnnouncer.csproj b/tools/Tgstation.PRAnnouncer/Tgstation.PRAnnouncer.csproj new file mode 100644 index 00000000000..21637e06e44 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/Tgstation.PRAnnouncer.csproj @@ -0,0 +1,21 @@ + + + + 1.0.0 + Exe + net8.0 + enable + true + false + true + true + + + + + + + + + + diff --git a/tools/Tgstation.PRAnnouncer/TgstationWebhookEventProcessor.cs b/tools/Tgstation.PRAnnouncer/TgstationWebhookEventProcessor.cs new file mode 100644 index 00000000000..e2e770b152a --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/TgstationWebhookEventProcessor.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Web; + +using Byond.TopicSender; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Octokit.Webhooks; +using Octokit.Webhooks.Events; +using Octokit.Webhooks.Events.PullRequest; + +using Prometheus; + +namespace Tgstation.PRAnnouncer +{ + /// + /// Tgstation webhook processor. + /// + sealed class TgstationWebhookEventProcessor : WebhookEventProcessor + { + /// + /// The to use. + /// + readonly ITopicClient topicClient; + + /// + /// The to write to. + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly IOptionsMonitor options; + + readonly Counter announcementsTriggered; + readonly Counter badCalls; + readonly Counter successfulTopicCalls; + readonly Counter failedTopicCalls; + + /// + /// Initializes a new instanc eof the class. + /// + /// The value of . + /// The used to create metrics. + /// The value of . + /// The value of . + public TgstationWebhookEventProcessor( + ITopicClient topicClient, + IMetricFactory metricFactory, + IOptionsMonitor options, + ILogger logger) + { + this.topicClient = topicClient ?? throw new ArgumentNullException(nameof(topicClient)); + ArgumentNullException.ThrowIfNull(metricFactory); + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + announcementsTriggered = metricFactory.CreateCounter("pr_announcer_announcements_triggered", "The number of webhooks that triggered a PR announcement that have been processed"); + badCalls = metricFactory.CreateCounter("pr_announcer_bad_calls", "The number of malformed webhook calls received"); + successfulTopicCalls = metricFactory.CreateCounter("pr_announcer_successful_topic_calls", "Total number of successful topic calls"); + failedTopicCalls = metricFactory.CreateCounter("pr_announcer_failed_topic_calls", "Total number of failed topic calls"); + } + + /// + protected override Task ProcessPullRequestWebhookAsync(WebhookHeaders headers, PullRequestEvent pullRequestEvent, PullRequestAction action) + { + var repo = pullRequestEvent.Repository; + if (repo == null) + { + logger.LogWarning("Bad payload: Repo was null"); + badCalls.Inc(); + return Task.CompletedTask; + } + + var slug = $"{repo.Owner.Login}/{repo.Name}"; + if (!(action == PullRequestAction.Closed + || action == PullRequestAction.Opened)) + { + logger.LogDebug( + "Ignoring unwanted PR action {action}: {slug} #{number} by @{author}", + pullRequestEvent.Action, + slug, + pullRequestEvent.Number, + pullRequestEvent.PullRequest.User.Login); + return Task.CompletedTask; + } + + logger.LogInformation( + "Received pull request webhook: {slug} #{number} by @{author} {action}", + slug, + pullRequestEvent.Number, + pullRequestEvent.PullRequest.User.Login, + pullRequestEvent.Action); + +#pragma warning disable IDE0059 // Unnecessary assignment of a value, WTF VS? + if (pullRequestEvent.AdditionalProperties?.TryGetValue("author_association", out var authorAssociation) ?? false +#pragma warning restore IDE0059 + && (authorAssociation.ValueEquals("FIRST_TIMER") || authorAssociation.ValueEquals("FIRST_TIME_CONTRIBUTROR"))) + { + logger.LogInformation( + "Not triggering announcement, first time contributor detected"); + return Task.CompletedTask; + } + + var settings = options.CurrentValue; + var commsKey = settings.CommsKey; + + if(commsKey == null) + { + logger.LogError("Cannot process webhook, {commsKey} is null!", nameof(Settings.CommsKey)); + return Task.CompletedTask; + } + + var relevantServers = (IReadOnlyCollection?)settings + .Servers + ?.Where( + config => config + .InterestedRepoSlugs + ?.Any( + interestedSlug => interestedSlug.Equals( + slug, + StringComparison.OrdinalIgnoreCase)) + ?? true) + .ToList() + ?? []; + + if (relevantServers.Count == 0) + { + logger.LogInformation("No servers interested"); + return Task.CompletedTask; + } + + announcementsTriggered.Inc(); + + var payload = new PRAnnounceQuery(pullRequestEvent, commsKey); + + return Task.WhenAll(relevantServers.Select(server => SendPayload(server, payload))); + } + + /// + /// Send a given to a given . + /// + /// The of the server to send to. + /// The + /// + async Task SendPayload(ServerConfig server, PRAnnounceQuery payload) + { + var address = server.Address; + if(address == null) + { + logger.LogError("A server has a null Address configured!"); + return; + } + + var encodedPayload = payload.Serialize(); + + try + { + var result = await topicClient.SendTopic(address, encodedPayload, server.Port); + successfulTopicCalls.Inc(); + } + catch (Exception ex) + { + failedTopicCalls.Inc(); + logger.LogError(ex, "Failed to send topic to game server {address}:{port}", address, server.Port); + } + } + } +} diff --git a/tools/Tgstation.PRAnnouncer/TopicTimeouts.cs b/tools/Tgstation.PRAnnouncer/TopicTimeouts.cs new file mode 100644 index 00000000000..68ced668bf8 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/TopicTimeouts.cs @@ -0,0 +1,33 @@ +namespace Tgstation.PRAnnouncer +{ + /// + /// Timeout controls for sending topics. + /// + sealed class TopicTimeouts + { + /// + /// The default value for properties if they are . + /// + public const uint DefaultTimeoutSeconds = 5; + + /// + /// The timeout for the send operation. + /// + public uint? SendTimeoutSeconds { get; set; } + + /// + /// The timeout for the receive operation. + /// + public uint? ReceiveTimeoutSeconds { get; set; } + + /// + /// The timeout for the receive operation. + /// + public uint? ConnectTimeoutSeconds { get; set; } + + /// + /// The timeout for the disconnect operation. + /// + public uint? DisconnectTimeoutSeconds { get; set; } + } +} diff --git a/tools/Tgstation.PRAnnouncer/appsettings.json b/tools/Tgstation.PRAnnouncer/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tools/Tgstation.PRAnnouncer/deps.json b/tools/Tgstation.PRAnnouncer/deps.json new file mode 100644 index 00000000000..fd43ea8ed23 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/deps.json @@ -0,0 +1,322 @@ +[ + { + "pname": "Byond.TopicSender", + "version": "8.0.1", + "hash": "sha256-Pcsegjpwrw7R1zo6NOSOIIkjs8k6SCoB5BbnDUYWLKw=" + }, + { + "pname": "Macross.Json.Extensions", + "version": "3.0.0", + "hash": "sha256-nBoB4vY+R19jGPdnlN6hNmZjOeAEkOA1CS+DHLjZUJA=" + }, + { + "pname": "Microsoft.Extensions.Configuration", + "version": "3.1.0", + "hash": "sha256-KI1WXvnF/Xe9cKTdDjzm0vd5h9bmM+3KinuWlsF/X+c=" + }, + { + "pname": "Microsoft.Extensions.Configuration", + "version": "9.0.2", + "hash": "sha256-AUNaLhYTcHUkqKGhSL7QgrifV9JkjKhNQ4Ws8UtZhlM=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Abstractions", + "version": "3.1.0", + "hash": "sha256-GMxvf0iAiWUWo0awlDczzcxNo8+MITBLp0/SqqYo8Lg=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Abstractions", + "version": "6.0.0", + "hash": "sha256-Evg+Ynj2QUa6Gz+zqF+bUyfGD0HI5A2fHmxZEXbn3HA=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Abstractions", + "version": "9.0.2", + "hash": "sha256-icRtfbi0nDRUYDErtKYx0z6A1gWo5xdswsSM6o4ozxc=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Binder", + "version": "3.1.0", + "hash": "sha256-/B7WjPZPvRM+CPgfaCQunSi2mpclH4orrFxHGLs8Uo4=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Binder", + "version": "9.0.2", + "hash": "sha256-lYWUfvSnpp9M4N4wIfFnMlB+8K79g9uUa1NXsgnxs0k=" + }, + { + "pname": "Microsoft.Extensions.Configuration.CommandLine", + "version": "9.0.2", + "hash": "sha256-qsEwiAO/n2+k8Q8/AftqdSlvvQWDx7WKb+9VlP8Nuxw=" + }, + { + "pname": "Microsoft.Extensions.Configuration.EnvironmentVariables", + "version": "9.0.2", + "hash": "sha256-XgSdv8+zh2vXmhP+a31/+Y+mNLwQwLflfCiEtDemea0=" + }, + { + "pname": "Microsoft.Extensions.Configuration.FileExtensions", + "version": "9.0.2", + "hash": "sha256-eeZbwf2lcV74mjXtOX8q0MxvP4QzEYyHXr1EGFS/orU=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Json", + "version": "9.0.2", + "hash": "sha256-7/ewyjh0gXu798fYcJxOCkdaAPIzrJ8reuTzqz93IJ0=" + }, + { + "pname": "Microsoft.Extensions.Configuration.UserSecrets", + "version": "9.0.2", + "hash": "sha256-0OmAQn8gIqTPN4s0NkcidXivjq5LsEGiNVxmp3qxGoo=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection", + "version": "3.1.0", + "hash": "sha256-S72hzDAYWzrfCH5JLJBRtwPEM/Xjh17HwcKuA3wLhvU=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection", + "version": "9.0.2", + "hash": "sha256-jNQVj2Xo7wzVdNDu27bLbYCVUOF8yDVrFtC3cZ9OsXo=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "3.1.0", + "hash": "sha256-cG0XS3ibJ9siu8eaQGJnyRwlEbQ9c/eGCtvPjs7Rdd8=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "6.0.0", + "hash": "sha256-SZke0jNKIqJvvukdta+MgIlGsrP2EdPkkS8lfLg7Ju4=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "8.0.0", + "hash": "sha256-75KzEGWjbRELczJpCiJub+ltNUMMbz5A/1KQU+5dgP8=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "9.0.2", + "hash": "sha256-WoTLgw/OlXhgN54Szip0Zpne7i/YTXwZ1ZLCPcHV6QM=" + }, + { + "pname": "Microsoft.Extensions.Diagnostics", + "version": "9.0.2", + "hash": "sha256-ImTZ6PZyKEdq1XvqYT5DPr6cG0BSTrsrO7rTDuy29fc=" + }, + { + "pname": "Microsoft.Extensions.Diagnostics.Abstractions", + "version": "9.0.2", + "hash": "sha256-JTJ8LCW3aYUO86OPgXRQthtDTUMikOfILExgeOF8CX4=" + }, + { + "pname": "Microsoft.Extensions.Diagnostics.HealthChecks", + "version": "6.0.9", + "hash": "sha256-2KRX3U+FNauAZJln0zeJayHPVUR86luWCmfESutHvRo=" + }, + { + "pname": "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions", + "version": "6.0.9", + "hash": "sha256-4inPpRPTC+hs3vaZJ5Par/uTbiFqlRvxMBQfDDDPYts=" + }, + { + "pname": "Microsoft.Extensions.FileProviders.Abstractions", + "version": "6.0.0", + "hash": "sha256-uBjWjHKEXjZ9fDfFxMjOou3lhfTNhs1yO+e3fpWreLk=" + }, + { + "pname": "Microsoft.Extensions.FileProviders.Abstractions", + "version": "9.0.2", + "hash": "sha256-RmVshMCWW1/RE/Wk8AeT4r6uZ+XFuwDFYzdxYKSm440=" + }, + { + "pname": "Microsoft.Extensions.FileProviders.Physical", + "version": "9.0.2", + "hash": "sha256-vQBgVLW813wOnJ1+943ArDWReok6p0jAl7fhwvyFtL8=" + }, + { + "pname": "Microsoft.Extensions.FileSystemGlobbing", + "version": "9.0.2", + "hash": "sha256-oH6X8SQjqi5Q2HLRILcUr9iPqnC1Ky5m5GbYYCKCxag=" + }, + { + "pname": "Microsoft.Extensions.Hosting", + "version": "9.0.2", + "hash": "sha256-eI9ckarRX0UCX+mBsEBYdvHZrmN86bXyTRvbH4gU9JM=" + }, + { + "pname": "Microsoft.Extensions.Hosting.Abstractions", + "version": "6.0.0", + "hash": "sha256-ksIPO6RhfbYx/i3su4J3sDhoL+TDnITKsgIpEqnpktc=" + }, + { + "pname": "Microsoft.Extensions.Hosting.Abstractions", + "version": "9.0.2", + "hash": "sha256-PUCam4g5g84qIqfPA9sVBNVPA26rWFq7js9nHF3WLZc=" + }, + { + "pname": "Microsoft.Extensions.Hosting.Systemd", + "version": "9.0.2", + "hash": "sha256-J2L7JYO2dmTd4JIQZ0zphtk0p12y9GgYcJHFO8dO1/c=" + }, + { + "pname": "Microsoft.Extensions.Http", + "version": "3.1.0", + "hash": "sha256-nhkt3qVsTXccgrW3mvx8veaJICREzeJrXfrjXI7rNwo=" + }, + { + "pname": "Microsoft.Extensions.Logging", + "version": "3.1.0", + "hash": "sha256-BDrsqgiLYAphIOlnEuXy6iLoED/ykFO53merHCSGfrQ=" + }, + { + "pname": "Microsoft.Extensions.Logging", + "version": "9.0.2", + "hash": "sha256-vPCb4ZoiwZUSGJIOhYiLwcZLnsd0ZZhny6KQkT88nI0=" + }, + { + "pname": "Microsoft.Extensions.Logging.Abstractions", + "version": "3.1.0", + "hash": "sha256-D3GHIGN0r6zLHHP2/5jt6hB0oMvRyl5ysvVrPVmmyv8=" + }, + { + "pname": "Microsoft.Extensions.Logging.Abstractions", + "version": "6.0.2", + "hash": "sha256-VRyyMGCMBh25vIIzbLapMAqY8UffqJRvkF/kcYcjZfM=" + }, + { + "pname": "Microsoft.Extensions.Logging.Abstractions", + "version": "8.0.0", + "hash": "sha256-Jmddjeg8U5S+iBTwRlVAVLeIHxc4yrrNgqVMOB7EjM4=" + }, + { + "pname": "Microsoft.Extensions.Logging.Abstractions", + "version": "9.0.2", + "hash": "sha256-mCxeuc+37XY0bmZR+z4p1hrZUdTZEg+FRcs/m6dAQDU=" + }, + { + "pname": "Microsoft.Extensions.Logging.Configuration", + "version": "9.0.2", + "hash": "sha256-SeNQ8us2cZ8xbJx8TK7xm3IxQR95EanSfMYhqvP2pWU=" + }, + { + "pname": "Microsoft.Extensions.Logging.Console", + "version": "9.0.2", + "hash": "sha256-yD30lW3ax4JHmZ9QIp1b0ELrXiwykP5KHF/feJGweyE=" + }, + { + "pname": "Microsoft.Extensions.Logging.Debug", + "version": "9.0.2", + "hash": "sha256-0WP9jFTsbXCIhYx/2IFL69mv2+K3Ld7C4QvwY00iOD0=" + }, + { + "pname": "Microsoft.Extensions.Logging.EventLog", + "version": "9.0.2", + "hash": "sha256-e4q/Z6xLq2HzQiKI7npagyEZdkfUe+FbIz3Tg+hPH9g=" + }, + { + "pname": "Microsoft.Extensions.Logging.EventSource", + "version": "9.0.2", + "hash": "sha256-W7yidllNOKxTvgIUqjJ3h55PAIR/XREfbuH+8TUhD0o=" + }, + { + "pname": "Microsoft.Extensions.ObjectPool", + "version": "7.0.0", + "hash": "sha256-JxlxPnjmWbEhYLNWlSn+kNxUfwvlxgKiKFjkJyYGn5Y=" + }, + { + "pname": "Microsoft.Extensions.Options", + "version": "3.1.0", + "hash": "sha256-0EOsmu/oLAz9WXp1CtMlclzdvs5jea0zJmokeyFnbCo=" + }, + { + "pname": "Microsoft.Extensions.Options", + "version": "6.0.0", + "hash": "sha256-DxnEgGiCXpkrxFkxXtOXqwaiAtoIjA8VSSWCcsW0FwE=" + }, + { + "pname": "Microsoft.Extensions.Options", + "version": "9.0.2", + "hash": "sha256-y2jZfcWx/H6Sx7wklA248r6kPjZmzTTLGxW8ZxrzNLM=" + }, + { + "pname": "Microsoft.Extensions.Options.ConfigurationExtensions", + "version": "9.0.2", + "hash": "sha256-xOYLRlXDI4gMEoQ+J+sQBNRT2RPDNrSCZkob7qBiV10=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "3.1.0", + "hash": "sha256-K/cDq+LMfK4cBCvKWkmWAC+IB6pEWolR1J5zL60QPvA=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "6.0.0", + "hash": "sha256-AgvysszpQ11AiTBJFkvSy8JnwIWTj15Pfek7T7ThUc4=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "8.0.0", + "hash": "sha256-FU8qj3DR8bDdc1c+WeGZx/PCZeqqndweZM9epcpXjSo=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "9.0.2", + "hash": "sha256-zy/YNMaY47o6yNv2WuYiAJEjtoOF8jlWgsWHqXeSm4s=" + }, + { + "pname": "Octokit.Webhooks", + "version": "2.4.1", + "hash": "sha256-FZ2Eg432VkDh8fQWXkacGJNf9Uvv5LUCAU/xrqugnFc=" + }, + { + "pname": "Octokit.Webhooks.AspNetCore", + "version": "2.4.1", + "hash": "sha256-UGRKHb8wwtNl1nMIp2hZs0DTQZmWOAFZB8MXzbRBV7g=" + }, + { + "pname": "prometheus-net", + "version": "8.2.1", + "hash": "sha256-NxHeXd4fwwc4MMsT6mrfX81czjHnq2GMStWTabZxMDw=" + }, + { + "pname": "prometheus-net.AspNetCore", + "version": "8.2.1", + "hash": "sha256-dhrATENkD/1GfSPBkAd3GvyHvzR5q+c+k22UTp33z+c=" + }, + { + "pname": "prometheus-net.AspNetCore.HealthChecks", + "version": "8.2.1", + "hash": "sha256-C0RIYDSfmaWMJrQE7QTWdtGVc1iLbNUkTEe0bBUpSQQ=" + }, + { + "pname": "System.Diagnostics.DiagnosticSource", + "version": "9.0.2", + "hash": "sha256-vhlhNgWeEosMB3DyneAUgH2nlpHORo7vAIo5Bx5Dgrc=" + }, + { + "pname": "System.Diagnostics.EventLog", + "version": "9.0.2", + "hash": "sha256-IoiQbH8To9UqzYgJzYpFbuiRV3KGU85y4ccPTyttP/w=" + }, + { + "pname": "System.IO.Pipelines", + "version": "9.0.2", + "hash": "sha256-uxM7J0Q/dzEsD0NGcVBsOmdHiOEawZ5GNUKBwpdiPyE=" + }, + { + "pname": "System.Runtime.CompilerServices.Unsafe", + "version": "6.0.0", + "hash": "sha256-bEG1PnDp7uKYz/OgLOWs3RWwQSVYm+AnPwVmAmcgp2I=" + }, + { + "pname": "System.Text.Encodings.Web", + "version": "9.0.2", + "hash": "sha256-tZhc/Xe+SF9bCplthph2QmQakWxKVjMfQJZzD1Xbpg8=" + }, + { + "pname": "System.Text.Json", + "version": "9.0.2", + "hash": "sha256-kftKUuGgZtF4APmp77U79ws76mEIi+R9+DSVGikA5y8=" + } +] diff --git a/tools/Tgstation.PRAnnouncer/flake.nix b/tools/Tgstation.PRAnnouncer/flake.nix new file mode 100644 index 00000000000..1c80226c719 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/flake.nix @@ -0,0 +1,13 @@ +{ + description = "tgstation-pr-announcer"; + + inputs = {}; + + outputs = { ... }: { + nixosModules = { + default = { ... }: { + imports = [ ./tgstation-pr-announcer.nix ]; + }; + }; + }; +} diff --git a/tools/Tgstation.PRAnnouncer/package.nix b/tools/Tgstation.PRAnnouncer/package.nix new file mode 100644 index 00000000000..19608dc929c --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/package.nix @@ -0,0 +1,57 @@ +{ + pkgs, + ... +}: + +let + inherit (pkgs) stdenv lib; + + versionParse = stdenv.mkDerivation { + pname = "tgstation-pr-announcer-version-parse"; + version = "1.0.0"; + + meta = with pkgs.lib; { + description = "Version parser for tgstation-pr-announcer"; + homepage = "https://github.com/tgstation/tgstation"; + license = licenses.agpl3Plus; + platforms = platforms.x86_64; + }; + + nativeBuildInputs = with pkgs; [ + xmlstarlet + ]; + + src = ./.; + + installPhase = '' + mkdir -p $out + xmlstarlet sel --template --value-of /Project/PropertyGroup/Version ./Tgstation.PRAnnouncer.csproj > $out/tgstation-pr-announcer-version.txt + ''; + }; + version = (builtins.readFile "${versionParse}/tgstation-pr-announcer-version.txt"); +in +pkgs.buildDotnetModule { + pname = "tgstation-pr-announcer"; + version = (builtins.readFile "${versionParse}/tgstation-pr-announcer-version.txt"); + + meta = with pkgs.lib; { + description = "Tool for forwarding GitHub webhooks for PRs to DM game servers"; + homepage = "https://github.com/tgstation/tgstation"; + license = licenses.agpl3Plus; + platforms = platforms.x86_64; + }; + + nativeBuildInputs = with pkgs; [ + versionParse + ]; + + src = ./.; + + projectFile = "Tgstation.PRAnnouncer.csproj"; + nugetDeps = ./deps.json; + + dotnet-sdk = pkgs.dotnetCorePackages.sdk_8_0; + dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_8_0; + + executables = [ "Tgstation.PRAnnouncer" ]; +} diff --git a/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.nix b/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.nix new file mode 100644 index 00000000000..c91357660b0 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.nix @@ -0,0 +1,102 @@ +inputs@{ + config, + lib, + systemdUtils, + nixpkgs, + pkgs, + ... +}: + +let + cfg = config.services.tgstation-pr-announcer; + + package = import ./package.nix inputs; +in +{ + ##### interface. here we define the options that users of our service can specify + options = { + # the options for our service will be located under services.foo + services.tgstation-pr-announcer = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to enable tgstation-pr-announcer. + ''; + }; + + username = lib.mkOption { + type = lib.types.nonEmptyStr; + default = "tgstation-pr-announcer"; + description = '' + The name of the user used to execute tgstation-pr-announcer. + ''; + }; + + groupname = lib.mkOption { + type = lib.types.nonEmptyStr; + default = "tgstation-pr-announcer"; + description = '' + The name of group the user used to execute tgstation-pr-announcer will belong to. + ''; + }; + + production-appsettings = lib.mkOption { + type = lib.types.path; + default = ''''; + description = '' + A formatted appsettings.Production.json file. + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Environment file as defined in {manpage}`systemd.exec(5)` + ''; + }; + + wants = lib.mkOption { + type = lib.types.listOf systemdUtils.lib.unitNameType; + default = []; + description = '' + Start the specified units when this unit is started. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + users.groups."${cfg.groupname}" = { }; + + users.users."${cfg.username}" = { + isSystemUser = true; + group = cfg.groupname; + }; + + environment.etc = { + "tgstation-pr-announcer.d/appsettings.Production.json" = { + source = cfg.production-appsettings; + group = cfg.groupname; + mode = "0640"; + }; + }; + + systemd.services.tgstation-pr-announcer = { + description = "tgstation-pr-announcer"; + serviceConfig = { + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; + User = cfg.username; + Type = "notify"; + WorkingDirectory = "/etc/tgstation-pr-announcer.d"; + ExecStart = "${package}/bin/Tgstation.PRAnnouncer"; + Restart = "always"; + }; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + ]; + }; + }; +} diff --git a/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.sln b/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.sln new file mode 100644 index 00000000000..f951aea0523 --- /dev/null +++ b/tools/Tgstation.PRAnnouncer/tgstation-pr-announcer.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tgstation.PRAnnouncer", "Tgstation.PRAnnouncer.csproj", "{3BD2EB29-D776-4533-969D-B94564A69DCD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3BD2EB29-D776-4533-969D-B94564A69DCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BD2EB29-D776-4533-969D-B94564A69DCD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BD2EB29-D776-4533-969D-B94564A69DCD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BD2EB29-D776-4533-969D-B94564A69DCD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {899C600F-81CE-456E-9D76-58F8CD5CEACB} + EndGlobalSection +EndGlobal diff --git a/tools/WebhookProcessor/github_webhook_processor.php b/tools/WebhookProcessor/github_webhook_processor.php deleted file mode 100644 index 6fe9dfa8b73..00000000000 --- a/tools/WebhookProcessor/github_webhook_processor.php +++ /dev/null @@ -1,741 +0,0 @@ -getLine() . ': ' . $e->getMessage()); - die(); -}); -$rawPost = NULL; -if (!$hookSecret || $hookSecret == '08ajh0qj93209qj90jfq932j32r') - throw new \Exception("Hook secret is required and can not be default"); -if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) { - throw new \Exception("HTTP header 'X-Hub-Signature' is missing."); -} elseif (!extension_loaded('hash')) { - throw new \Exception("Missing 'hash' extension to check the secret code validity."); -} -list($algo, $hash) = explode('=', $_SERVER['HTTP_X_HUB_SIGNATURE'], 2) + array('', ''); -if (!in_array($algo, hash_algos(), TRUE)) { - throw new \Exception("Hash algorithm '$algo' is not supported."); -} -$rawPost = file_get_contents('php://input'); -if ($hash !== hash_hmac($algo, $rawPost, $hookSecret)) { - throw new \Exception('Hook secret does not match.'); -} - -$contenttype = null; -//apache and nginx/fastcgi/phpfpm call this two different things. -if (!isset($_SERVER['HTTP_CONTENT_TYPE'])) { - if (!isset($_SERVER['CONTENT_TYPE'])) - throw new \Exception("Missing HTTP 'Content-Type' header."); - else - $contenttype = $_SERVER['CONTENT_TYPE']; -} else { - $contenttype = $_SERVER['HTTP_CONTENT_TYPE']; -} -if (!isset($_SERVER['HTTP_X_GITHUB_EVENT'])) { - throw new \Exception("Missing HTTP 'X-Github-Event' header."); -} -switch ($contenttype) { - case 'application/json': - $json = $rawPost ?: file_get_contents('php://input'); - break; - case 'application/x-www-form-urlencoded': - $json = $_POST['payload']; - break; - default: - throw new \Exception("Unsupported content type: $contenttype"); -} -# Payload structure depends on triggered event -# https://developer.github.com/v3/activity/events/types/ -$payload = json_decode($json, true); - -switch (strtolower($_SERVER['HTTP_X_GITHUB_EVENT'])) { - case 'ping': - echo 'pong'; - break; - case 'pull_request': - handle_pr($payload); - break; - case 'pull_request_review': - if($payload['action'] == 'submitted'){ - $lower_state = strtolower($payload['review']['state']); - if(($lower_state == 'approved' || $lower_state == 'changes_requested') && is_maintainer($payload, $payload['review']['user']['login'])) - remove_ready_for_review($payload); - } - break; - default: - header('HTTP/1.0 404 Not Found'); - echo "Event:$_SERVER[HTTP_X_GITHUB_EVENT] Payload:\n"; - print_r($payload); # For debug only. Can be found in GitHub hook log. - die(); -} - -function apisend($url, $method = 'GET', $content = null, $authorization = null) { - if (is_array($content)) - $content = json_encode($content); - - $headers = array(); - $headers[] = 'Content-type: application/json'; - if ($authorization) - $headers[] = 'Authorization: ' . $authorization; - - $scontext = array('http' => array( - 'method' => $method, - 'header' => implode("\r\n", $headers), - 'ignore_errors' => true, - 'user_agent' => 'tgstation13.org-Github-Automation-Tools' - )); - - if ($content) - $scontext['http']['content'] = $content; - - return file_get_contents($url, false, stream_context_create($scontext)); - -} - -function github_apisend($url, $method = 'GET', $content = NULL) { - global $apiKey; - return apisend($url, $method, $content, 'token ' . $apiKey); -} - -function discord_webhook_send($webhook, $content) { - return apisend($webhook, 'POST', $content); -} - -function validate_user($payload) { - global $validation, $validation_count; - $query = array(); - if (empty($validation)) - $validation = 'org'; - switch (strtolower($validation)) { - case 'disable': - return TRUE; - case 'repo': - $query['repo'] = $payload['pull_request']['base']['repo']['full_name']; - break; - default: - $query['user'] = $payload['pull_request']['base']['repo']['owner']['login']; - break; - } - $query['author'] = $payload['pull_request']['user']['login']; - $query['is'] = 'merged'; - $querystring = ''; - foreach($query as $key => $value) - $querystring .= ($querystring == '' ? '' : '+') . urlencode($key) . ':' . urlencode($value); - $res = github_apisend('https://api.github.com/search/issues?q='.$querystring); - $res = json_decode($res, TRUE); - return (isset($res['total_count']) && $res['total_count'] >= (int)$validation_count); - -} - -function get_labels($payload){ - $url = $payload['pull_request']['issue_url'] . '/labels'; - $existing_labels = json_decode(github_apisend($url), true); - $existing = array(); - foreach((array) $existing_labels as $label) - $existing[] = $label['name']; - return $existing; -} - -function remove_ready_for_review($payload, $labels = null){ - if($labels == null) - $labels = get_labels($payload); - $index = array_search('Needs Review', $labels); - if($index !== FALSE) - unset($labels[$index]); - $url = $payload['pull_request']['issue_url'] . '/labels'; - github_apisend($url, 'PUT', $labels); -} - -function dismiss_review($payload, $id, $reason){ - $content = array('message' => $reason); - github_apisend($payload['pull_request']['url'] . '/reviews/' . $id . '/dismissals', 'PUT', $content); -} - -function get_reviews($payload){ - return json_decode(github_apisend($payload['pull_request']['url'] . '/reviews'), true); -} - -function check_dismiss_changelog_review($payload){ - global $require_changelogs; - global $no_changelog; - - if(!$require_changelogs) - return; - - if(!$no_changelog) - checkchangelog($payload); - - $review_message = 'Your changelog for this PR is either malformed or non-existent. Please create one to document your changes.'; - - $reviews = get_reviews($payload); - if($no_changelog){ - //check and see if we've already have this review - foreach($reviews as $R) - if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested') - return; - //otherwise make it ourself - github_apisend($payload['pull_request']['url'] . '/reviews', 'POST', array('body' => $review_message, 'event' => 'REQUEST_CHANGES')); - } - else - //kill previous reviews - foreach($reviews as $R) - if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested') - dismiss_review($payload, $R['id'], 'Changelog added/fixed.'); -} - -function is_blacklisted($blacklist, $name) { - foreach ($blacklist as $pattern) { - if (preg_match($pattern, $name)) { - return true; - } - } - - return false; -} - -function handle_pr($payload) { - global $discord_announce_blacklist; - global $no_changelog; - global $game_announce_whitelist; - - $action = 'opened'; - $validated = validate_user($payload); - switch ($payload["action"]) { - case 'opened': - if($no_changelog) - check_dismiss_changelog_review($payload); - break; - case 'edited': - check_dismiss_changelog_review($payload); - case 'synchronize': - return; - case 'reopened': - $action = $payload['action']; - break; - case 'closed': - if (!$payload['pull_request']['merged']) { - $action = 'closed'; - } - else { - $action = 'merged'; - auto_update($payload); - checkchangelog($payload); - $validated = TRUE; //pr merged events always get announced. - } - break; - default: - return; - } - - $pr_flags = 0; - if (strpos(strtolower($payload['pull_request']['title']), '[s]') !== false) { - $pr_flags |= F_SECRET_PR; - } - if (!$validated) { - $pr_flags |= F_UNVALIDATED_USER; - } - - $repo_name = $payload['repository']['name']; - - if (!is_blacklisted($discord_announce_blacklist, $repo_name)) { - discord_announce($action, $payload, $pr_flags); - } - - if (in_array($repo_name, $game_announce_whitelist)) { - game_announce($action, $payload, $pr_flags); - } -} - -function filter_announce_targets($targets, $owner, $repo, $action, $pr_flags) { - foreach ($targets as $i=>$target) { - if (isset($target['exclude_events']) && in_array($action, array_map('strtolower', $target['exclude_events']))) { - unset($targets[$i]); - continue; - } - - if (isset($target['announce_secret']) && $target['announce_secret']) { - if (!($pr_flags & F_SECRET_PR) && $target['announce_secret'] === 'only') { - unset($targets[$i]); - continue; - } - } else if ($pr_flags & F_SECRET_PR) { - unset($targets[$i]); - continue; - } - - if (isset($target['announce_unvalidated']) && $target['announce_unvalidated']) { - if (!($pr_flags & F_UNVALIDATED_USER) && $target['announce_unvalidated'] === 'only') { - unset($targets[$i]); - continue; - } - } else if ($pr_flags & F_UNVALIDATED_USER) { - unset($targets[$i]); - continue; - } - - $wildcard = false; - if (isset($target['include_repos'])) { - foreach ($target['include_repos'] as $match_string) { - $owner_repo_pair = explode('/', strtolower($match_string)); - if (count($owner_repo_pair) != 2) { - log_error('Bad include repo: `'. $match_string.'`'); - continue; - } - if (strtolower($owner) == $owner_repo_pair[0]) { - if (strtolower($repo) == $owner_repo_pair[1]) - continue 2; //don't parse excludes when we have an exact include match - if ($owner_repo_pair[1] == '*') { - $wildcard = true; - continue; //do parse excludes when we have a wildcard match (but check the other entries for exact matches first) - } - } - } - if (!$wildcard) { - unset($targets[$i]); - continue; - } - } - - if (isset($target['exclude_repos'])) - foreach ($target['exclude_repos'] as $match_string) { - $owner_repo_pair = explode('/', strtolower($match_string)); - if (count($owner_repo_pair) != 2) { - log_error('Bad exclude repo: `'. $match_string.'`'); - continue; - } - if (strtolower($owner) == $owner_repo_pair[0]) { - if (strtolower($repo) == $owner_repo_pair[1]) { - unset($targets[$i]); - continue 2; - } - if ($owner_repo_pair[1] == '*') { - if ($wildcard) - log_error('Identical wildcard include and exclude: `'.$match_string.'`. Excluding.'); - unset($targets[$i]); - continue 2; - } - } - } - } - return $targets; -} - -function game_announce($action, $payload, $pr_flags) { - global $servers; - - $msg = '['.$payload['pull_request']['base']['repo']['full_name'].'] Pull Request '.$action.' by '.htmlSpecialChars($payload['sender']['login']).': '.htmlSpecialChars('#'.$payload['pull_request']['number'].' '.$payload['pull_request']['user']['login'].' - '.$payload['pull_request']['title']).''; - - $game_servers = filter_announce_targets($servers, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags); - $game_payload = array(); - $game_payload['pull_request'] = array(); - $game_payload['pull_request']['id'] = $payload['pull_request']['id']; - $msg = '?announce='.urlencode($msg).'&payload='.urlencode(json_encode($game_payload)); - - foreach ($game_servers as $serverid => $server) { - try { - $server_message = $msg; - if (isset($server['comskey'])) - $server_message .= '&key='.urlencode($server['comskey']); - game_server_send($server['address'], $server['port'], $server_message); - } catch (exception $e) { - log_error('Error on line ' . $e->getLine() . ': ' . $e->getMessage()); - continue; - } - } - -} - -function discord_announce($action, $payload, $pr_flags) { - global $discordWebHooks; - $color; - switch ($action) { - case 'reopened': - case 'opened': - $color = 0x2cbe4e; - break; - case 'closed': - $color = 0xcb2431; - break; - case 'merged': - $color = 0x6f42c1; - break; - default: - return; - } - $data = array( - 'username' => 'GitHub', - 'avatar_url' => $payload['pull_request']['base']['user']['avatar_url'], - ); - - $content = 'Pull Request #'.$payload['pull_request']['number'].' *'.$action.'* by '.discord_sanitize($payload['sender']['login'])."\n".discord_sanitize($payload['pull_request']['user']['login']).' - __**'.discord_sanitize($payload['pull_request']['title']).'**__'."\n".'<'.$payload['pull_request']['html_url'].'>'; - - $embeds = array( - array( - 'title' => '__**'.discord_sanitize($payload['pull_request']['title'], S_MARKDOWN).'**__', - 'description' => discord_sanitize(str_replace(array("\r\n", "\n"), array(' ', ' '), substr($payload['pull_request']['body'], 0, 320)), S_HTML_COMMENTS), - 'url' => $payload['pull_request']['html_url'], - 'color' => $color, - 'author' => array( - 'name' => discord_sanitize($payload['pull_request']['user']['login'], S_MARKDOWN), - 'url' => $payload['pull_request']['user']['html_url'], - 'icon_url' => $payload['pull_request']['user']['avatar_url'] - ), - 'footer' => array( - 'text' => '#'.$payload['pull_request']['number'].' '.discord_sanitize($payload['pull_request']['base']['repo']['full_name'], S_MARKDOWN).' '.discord_sanitize($payload['pull_request']['head']['ref'], S_MARKDOWN).' -> '.discord_sanitize($payload['pull_request']['base']['ref'], S_MARKDOWN), - 'icon_url' => $payload['pull_request']['base']['user']['avatar_url'] - ) - ) - ); - $discordWebHook_targets = filter_announce_targets($discordWebHooks, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags); - foreach ($discordWebHook_targets as $discordWebHook) { - $sending_data = $data; - if (isset($discordWebHook['embed']) && $discordWebHook['embed']) { - $sending_data['embeds'] = $embeds; - if (!isset($discordWebHook['no_text']) || !$discordWebHook['no_text']) - $sending_data['content'] = $content; - } else { - $sending_data['content'] = $content; - } - discord_webhook_send($discordWebHook['url'], $sending_data); - } - -} - -function discord_sanitize($text, $flags = S_MENTIONS|S_LINK_EMBED|S_MARKDOWN) { - if ($flags & S_MARKDOWN) - $text = str_ireplace(array('\\', '*', '_', '~', '`', '|'), (array('\\\\', '\\*', '\\_', '\\~', '\\`', '\\|')), $text); - - if ($flags & S_HTML_COMMENTS) - $text = preg_replace('//Uis', '', $text); - - if ($flags & S_MENTIONS) - $text = str_ireplace(array('@everyone', '@here', '<@'), array('`@everyone`', '`@here`', '@<'), $text); - - if ($flags & S_LINK_EMBED) - $text = preg_replace("/((https?|ftp|byond)\:\/\/)([a-z0-9-.]*)\.([a-z]{2,3})(\:[0-9]{2,5})?(\/(?:[a-z0-9+\$_-]\.?)+)*\/?(\?[a-z+&\$_.-][a-z0-9;:@&%=+\/\$_.-]*)?(#[a-z_.-][a-z0-9+\$_.-]*)?/mi", '<$0>', $text); - - return $text; -} - -//creates a comment on the payload issue -function create_comment($payload, $comment){ - github_apisend($payload['pull_request']['comments_url'], 'POST', json_encode(array('body' => $comment))); -} - -function is_maintainer($payload, $author){ - global $maintainer_team_id; - $repo_is_org = $payload['pull_request']['base']['repo']['owner']['type'] == 'Organization'; - if($maintainer_team_id == null || !$repo_is_org) { - $collaburl = str_replace('{/collaborator}', '/' . $author, $payload['pull_request']['base']['repo']['collaborators_url']) . '/permission'; - $perms = json_decode(github_apisend($collaburl), true); - $permlevel = $perms['permission']; - return $permlevel == 'admin' || $permlevel == 'write'; - } - else { - $check_url = 'https://api.github.com/teams/' . $maintainer_team_id . '/memberships/' . $author; - $result = json_decode(github_apisend($check_url), true); - return isset($result['state']) && $result['state'] == 'active'; - } -} - -$github_diff = null; - -function get_diff($payload) { - global $github_diff; - if ($github_diff === null && $payload['pull_request']['diff_url']) { - //go to the diff url - $url = $payload['pull_request']['diff_url']; - $github_diff = file_get_contents($url); - } - return $github_diff; -} - -function auto_update($payload){ - global $enable_live_tracking; - global $path_to_script; - global $repoOwnerAndName; - global $tracked_branch; - global $github_diff; - if(!$enable_live_tracking || !has_tree_been_edited($payload, $path_to_script) || $payload['pull_request']['base']['ref'] != $tracked_branch) - return; - - get_diff($payload); - $content = file_get_contents('https://raw.githubusercontent.com/' . $repoOwnerAndName . '/' . $tracked_branch . '/'. $path_to_script); - $content_diff = "### Diff not available. :slightly_frowning_face:"; - if($github_diff && preg_match('/(diff --git a\/' . preg_quote($path_to_script, '/') . '.+?)(?:\Rdiff|$)/s', $github_diff, $matches)) { - $script_diff = $matches[1]; - if($script_diff) { - $content_diff = "``" . "`DIFF\n" . $script_diff ."\n``" . "`"; - } - } - create_comment($payload, "Edit detected. Self updating... \n
Here are my changes:\n\n" . $content_diff . "\n
\n
Here is my new code:\n\n``" . "`HTML+PHP\n" . $content . "\n``" . '`\n
'); - - $code_file = fopen(basename($path_to_script), 'w'); - fwrite($code_file, $content); - fclose($code_file); -} - -function has_tree_been_edited($payload, $tree){ - global $github_diff; - get_diff($payload); - //find things in the _maps/map_files tree - //e.g. diff --git a/_maps/map_files/Cerestation/cerestation.dmm b/_maps/map_files/Cerestation/cerestation.dmm - return ($github_diff !== FALSE) && (preg_match('/^diff --git a\/' . preg_quote($tree, '/') . '/m', $github_diff) !== 0); -} - -$no_changelog = false; -function checkchangelog($payload) { - global $no_changelog; - if (!isset($payload['pull_request']) || !isset($payload['pull_request']['body'])) { - return array(); - } - if (!isset($payload['pull_request']['user']) || !isset($payload['pull_request']['user']['login'])) { - return array(); - } - $body = $payload['pull_request']['body']; - - $tags = array(); - - if(preg_match('/(?i)(fix|fixes|fixed|resolve|resolves|resolved)\s*#[0-9]+/',$body)) //github autoclose syntax - $tags[] = 'Fix'; - - $body = str_replace("\r\n", "\n", $body); - $body = explode("\n", $body); - - $incltag = false; - $foundcltag = false; - foreach ($body as $line) { - $line = trim($line); - if (substr($line,0,4) == ':cl:' || substr($line,0,1) == '??') { - $incltag = true; - $foundcltag = true; - continue; - } else if (substr($line,0,5) == '/:cl:' || substr($line,0,6) == '/ :cl:' || substr($line,0,5) == ':/cl:' || substr($line,0,5) == '/??' || substr($line,0,6) == '/ ??' ) { - $incltag = false; - continue; - } - if (!$incltag) - continue; - - $firstword = explode(' ', $line)[0]; - $pos = strpos($line, " "); - $item = ''; - if ($pos) { - $firstword = trim(substr($line, 0, $pos)); - $item = trim(substr($line, $pos+1)); - } else { - $firstword = $line; - } - - // Line is empty - if (!strlen($firstword)) { - continue; - } - - //not a prefix line. - if (!strlen($firstword) || $firstword[strlen($firstword)-1] != ':') { - continue; - } - - $cltype = strtolower(substr($firstword, 0, -1)); - - // !!! - // !!! If you are changing any of these at the bottom, also edit `tools/pull_request_hooks/changelogConfig.js`. - // !!! - - switch ($cltype) { - case 'fix': - case 'fixes': - case 'bugfix': - if($item != 'fixed a few things') { - $tags[] = 'Fix'; - } - break; - case 'qol': - if($item != 'made something easier to use') { - $tags[] = 'Quality of Life'; - } - break; - case 'sound': - if($item != 'added/modified/removed audio or sound effects') { - $tags[] = 'Sound'; - } - break; - case 'add': - case 'adds': - case 'rscadd': - if($item != 'Added new mechanics or gameplay changes' && $item != 'Added more things') { - $tags[] = 'Feature'; - } - break; - case 'del': - case 'dels': - case 'rscdel': - if($item != 'Removed old things') { - $tags[] = 'Removal'; - } - break; - case 'image': - if($item != 'added/modified/removed some icons or images') { - $tags[] = 'Sprites'; - } - break; - case 'typo': - case 'spellcheck': - if($item != 'fixed a few typos') { - $tags[] = 'Grammar and Formatting'; - } - break; - case 'balance': - if($item != 'rebalanced something'){ - $tags[] = 'Balance'; - } - break; - case 'code_imp': - case 'code': - if($item != 'changed some code'){ - $tags[] = 'Code Improvement'; - } - break; - case 'refactor': - if($item != 'refactored some code'){ - $tags[] = 'Refactor'; - } - break; - case 'config': - if($item != 'changed some config setting'){ - $tags[] = 'Config Update'; - } - break; - case 'admin': - if($item != 'messed with admin stuff'){ - $tags[] = 'Administration'; - } - break; - } - } - return $tags; -} - -function game_server_send($addr, $port, $str) { - // All queries must begin with a question mark (ie "?players") - if($str[0] != '?') $str = ('?' . $str); - - /* --- Prepare a packet to send to the server (based on a reverse-engineered packet structure) --- */ - $query = "\x00\x83" . pack('n', strlen($str) + 6) . "\x00\x00\x00\x00\x00" . $str . "\x00"; - - /* --- Create a socket and connect it to the server --- */ - $server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP) or exit("ERROR"); - socket_set_option($server, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 2, 'usec' => 0)); //sets connect and send timeout to 2 seconds - if(!@socket_connect($server,$addr,$port)) { - return "ERROR: Connection failed"; - } - - - /* --- Send bytes to the server. Loop until all bytes have been sent --- */ - $bytestosend = strlen($query); - $bytessent = 0; - while ($bytessent < $bytestosend) { - //echo $bytessent.'
'; - $result = socket_write($server,substr($query,$bytessent),$bytestosend-$bytessent); - //echo 'Sent '.$result.' bytes
'; - if ($result===FALSE) - return "ERROR: " . socket_strerror(socket_last_error()); - $bytessent += $result; - } - - /* --- Idle for a while until received bytes from game server --- */ - $result = @socket_read($server, 10000, PHP_BINARY_READ); - @socket_close($server); // we don't need this anymore - - if($result != "") { - if($result[0] == "\x00" || $result[1] == "\x83") { // make sure it's the right packet format - - // Actually begin reading the output: - $sizebytes = unpack('n', $result[2] . $result[3]); // array size of the type identifier and content - $size = $sizebytes[1] - 1; // size of the string/floating-point (minus the size of the identifier byte) - - if($result[4] == "\x2a") { // 4-byte big-endian floating-point - $unpackint = unpack('f', $result[5] . $result[6] . $result[7] . $result[8]); // 4 possible bytes: add them up together, unpack them as a floating-point - return $unpackint[1]; - } - else if($result[4] == "\x06") { // ASCII string - $unpackstr = ""; // result string - $index = 5; // string index - - while($size > 0) { // loop through the entire ASCII string - $size--; - $unpackstr .= $result[$index]; // add the string position to return string - $index++; - } - return $unpackstr; - } - } - } - return ""; -} -?> diff --git a/tools/WebhookProcessor/secret.php b/tools/WebhookProcessor/secret.php deleted file mode 100644 index 57773bffa16..00000000000 --- a/tools/WebhookProcessor/secret.php +++ /dev/null @@ -1,136 +0,0 @@ -