Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HuaJiBot.NET.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Project Path="src/HuaJiBot.NET.Plugin.DailySummary/HuaJiBot.NET.Plugin.DailySummary.csproj" />
<Project Path="src/HuaJiBot.NET.Plugin.GitHubBridge/HuaJiBot.NET.Plugin.GitHubBridge.csproj" />
<Project Path="src/HuaJiBot.NET.Plugin.MessageBridge/HuaJiBot.NET.Plugin.MessageBridge.csproj" />
<Project Path="src/HuaJiBot.NET.Plugin.PushPanel/HuaJiBot.NET.Plugin.PushPanel.csproj" />
<Project Path="src/HuaJiBot.NET.Plugin.RepairTeam/HuaJiBot.NET.Plugin.RepairTeam.csproj" />
<Project Path="src/HuaJiBot.NET.SourceGenerator/HuaJiBot.NET.SourceGenerator.csproj" />
<Project Path="src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions src/HuaJiBot.NET.Plugin.PushPanel/EntryPoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using HuaJiBot.NET.Plugin.PushPanel;

[assembly: HuaJiBot.NET.PluginEntryPoint<PluginMain>("PushPanel", "推送规则面板")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HuaJiBot.NET\HuaJiBot.NET.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.Plugin.Calendar\HuaJiBot.NET.Plugin.Calendar.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.Plugin.DailySummary\HuaJiBot.NET.Plugin.DailySummary.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.Plugin.GitHubBridge\HuaJiBot.NET.Plugin.GitHubBridge.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.Plugin.MessageBridge\HuaJiBot.NET.Plugin.MessageBridge.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.Plugin.RepairTeam\HuaJiBot.NET.Plugin.RepairTeam.csproj" />
<ProjectReference Include="..\HuaJiBot.NET.SourceGenerator\HuaJiBot.NET.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="panel.html" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HuaJiBot.NET.UnitTest" />
</ItemGroup>
</Project>
206 changes: 206 additions & 0 deletions src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Net;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using HuaJiBot.NET.Config;
using Newtonsoft.Json;

namespace HuaJiBot.NET.Plugin.PushPanel;

internal sealed class PanelServer : IDisposable
{
private const int MaxBodyChars = 256 * 1024;
private readonly HttpListener _listener = new();
private readonly string _token;
private readonly ConfigWrapper _config;
private readonly string _page;
private readonly CancellationTokenSource _cts = new();

public PanelServer(string host, int port, string token, ConfigWrapper config)
{
_token = token;
_config = config;
_page = ReadPage();
_listener.Prefixes.Add($"http://{host}:{port}/");
}

public void Start()
{
_listener.Start();
_ = Task.Run(ListenAsync);
}

private async Task ListenAsync()
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext context;
try
{
context = await _listener.GetContextAsync();
}
catch (ObjectDisposedException) when (_cts.IsCancellationRequested)
{
return;
}
catch (HttpListenerException) when (_cts.IsCancellationRequested)
{
return;
}
_ = Task.Run(() => HandleAsync(context));
}
}

private async Task HandleAsync(HttpListenerContext context)
{
try
{
var request = context.Request;
var handler = SelectHandler(request.Url?.AbsolutePath ?? "/", request.HttpMethod);
if (handler is null)
{
await WriteJson(context.Response, 404, new { error = "没有这个地址" });
return;
}
await handler(context);
}
catch (Exception)
{
try
{
await WriteJson(context.Response, 500, new { error = "面板内部出错" });
}
catch (Exception)
{
context.Response.Abort();
}
}
}

private Func<HttpListenerContext, Task>? SelectHandler(string path, string method)
{
if (method == "GET" && path is "/" or "/index.html")
return ServePage;
if (path != "/api/rules")
return null;
return method switch
{
"GET" => ServeRules,
"PUT" => SaveRules,
_ => MethodNotAllowed,
};
}

private Task ServePage(HttpListenerContext context) =>
WriteText(context.Response, 200, "text/html; charset=utf-8", _page);

private async Task ServeRules(HttpListenerContext context)
{
if (!Authorized(context.Request))
{
await WriteJson(context.Response, 401, new { error = "口令不对" });
return;
}
await WriteJson(context.Response, 200, PushRules.Read(_config));
}

private async Task SaveRules(HttpListenerContext context)
{
if (!Authorized(context.Request))
{
await WriteJson(context.Response, 401, new { error = "口令不对" });
return;
}
var body = await ReadBody(context.Request);
if (body.Length > MaxBodyChars)
{
await WriteJson(context.Response, 413, new { error = "请求过大" });
return;
}
PushRulesDocument? document;
try
{
document = JsonConvert.DeserializeObject<PushRulesDocument>(body, PushRules.Json);
}
catch (JsonException)
{
await WriteJson(context.Response, 400, new { error = "请求不是规则 JSON" });
return;
}
if (document is null)
{
await WriteJson(context.Response, 400, new { error = "请求是空的" });
return;
}
var error = PushRules.Apply(_config, document);
if (error is not null)
{
await WriteJson(context.Response, 400, new { error });
return;
}
await WriteJson(context.Response, 200, PushRules.Read(_config));
}

private Task MethodNotAllowed(HttpListenerContext context) =>
WriteJson(context.Response, 405, new { error = "只接受读取和保存" });

private bool Authorized(HttpListenerRequest request)
{
var header = request.Headers["Authorization"];
const string prefix = "Bearer ";
if (header is null || !header.StartsWith(prefix, StringComparison.Ordinal))
return false;
var presented = Encoding.UTF8.GetBytes(header[prefix.Length..]);
var expected = Encoding.UTF8.GetBytes(_token);
return presented.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(presented, expected);
}

private static async Task<string> ReadBody(HttpListenerRequest request)
{
if (request.ContentLength64 > MaxBodyChars)
return new string(' ', MaxBodyChars + 1);
using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
return await reader.ReadToEndAsync();
}

private static async Task WriteJson(HttpListenerResponse response, int status, object body)
{
var json = JsonConvert.SerializeObject(body, PushRules.Json);
await WriteText(response, status, "application/json; charset=utf-8", json);
}

private static async Task WriteText(
HttpListenerResponse response,
int status,
string contentType,
string text
)
{
var bytes = Encoding.UTF8.GetBytes(text);
response.StatusCode = status;
response.ContentType = contentType;
response.Headers["Cache-Control"] = "no-store";
response.Headers["X-Content-Type-Options"] = "nosniff";
response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes);
response.Close();
}

private static string ReadPage()
{
var name = typeof(PanelServer).Assembly.GetManifestResourceNames().Single(resource =>
resource.EndsWith("panel.html", StringComparison.Ordinal)
);
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name)!;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}

public void Dispose()
{
_cts.Cancel();
_listener.Close();
_cts.Dispose();
}
}
66 changes: 66 additions & 0 deletions src/HuaJiBot.NET.Plugin.PushPanel/PluginMain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Net;
using HuaJiBot.NET.Config;

namespace HuaJiBot.NET.Plugin.PushPanel;

public class PluginConfig : ConfigBase
{
/// <summary>面板口令。为空时不监听。</summary>
public string Token { get; set; } = "";

/// <summary>监听地址。默认只绑本机。</summary>
public string Host { get; set; } = "127.0.0.1";

public int Port { get; set; } = 8787;

/// <summary>允许 Host 不是本机地址。口令仍然必须带上。</summary>
public bool AllowRemote { get; set; }
}

public partial class PluginMain : PluginBase, IPluginWithConfig<PluginConfig>
{
public PluginConfig Config { get; } = new();
private PanelServer? _server;

protected override void Initialize()
{
if (string.IsNullOrWhiteSpace(Config.Token))
{
Service.Log("[PushPanel] 未配置 Token,面板不监听");
return;
}
if (Config.Port is < 1 or > 65535)
{
Service.Warn("[PushPanel] Port 不在 1–65535,面板不监听");
return;
}
if (!Config.AllowRemote && !IsLoopback(Config.Host))
{
Service.Warn("[PushPanel] Host 不是本机地址。要对外监听请把 AllowRemote 设为 true");
return;
}
try
{
_server = new PanelServer(Config.Host, Config.Port, Config.Token, Service.Config);
_server.Start();
Service.Log($"[PushPanel] http://{Config.Host}:{Config.Port}/");
}
catch (Exception ex)
{
Service.LogError("[PushPanel] 启动失败", ex);
_server = null;
}
}

protected override void Unload()
{
_server?.Dispose();
_server = null;
}

internal static bool IsLoopback(string host) =>
host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host == "127.0.0.1"
|| host == "::1"
|| (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address));
}
Loading
Loading