- Implemented news management functionality, allowing admins to create, read, update, and delete news posts. - Introduced a new SignalR event for broadcasting news updates to all connected clients. - Updated API documentation to include new endpoints for news management. - Enhanced frontend with a dedicated news page and admin interface for managing news posts. - Added necessary localization for news-related terms in both Russian and English.
172 lines
8.5 KiB
C#
172 lines
8.5 KiB
C#
using System.Net;
|
||
using System.Text.Json.Serialization;
|
||
using Microsoft.AspNetCore.HttpOverrides;
|
||
using Microsoft.AspNetCore.RateLimiting;
|
||
using Microsoft.Extensions.Options;
|
||
using PnvPanel.Api.Common;
|
||
using PnvPanel.Api.Endpoints;
|
||
using PnvPanel.Api.Hubs;
|
||
using PnvPanel.Api.Telegram;
|
||
using PnvPanel.Application;
|
||
using PnvPanel.Application.Common.Interfaces;
|
||
using PnvPanel.Infrastructure;
|
||
using PnvPanel.Infrastructure.Identity;
|
||
using PnvPanel.Infrastructure.Persistence;
|
||
using PnvPanel.Infrastructure.Telegram;
|
||
using Scalar.AspNetCore;
|
||
using Serilog;
|
||
using Telegram.Bot;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
||
builder.Services.AddSerilog((services, configuration) => configuration
|
||
.ReadFrom.Configuration(builder.Configuration)
|
||
.ReadFrom.Services(services)
|
||
.Enrich.FromLogContext());
|
||
|
||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||
// прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации)
|
||
// остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через
|
||
// ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example).
|
||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||
{
|
||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||
|
||
foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [])
|
||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||
|
||
foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
|
||
{
|
||
var parts = network.Split('/');
|
||
options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
|
||
}
|
||
});
|
||
|
||
builder.Services.AddHttpContextAccessor();
|
||
builder.Services.AddApplication();
|
||
builder.Services.AddInfrastructure(builder.Configuration);
|
||
|
||
builder.Services.AddSignalR();
|
||
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
|
||
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
||
|
||
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
|
||
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
|
||
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
|
||
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
|
||
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
|
||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||
{
|
||
var options = sp.GetRequiredService<IOptions<TelegramOptions>>().Value;
|
||
var token = string.IsNullOrWhiteSpace(options.BotToken)
|
||
? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||
: options.BotToken;
|
||
|
||
if (string.IsNullOrWhiteSpace(options.ProxyUrl))
|
||
return new TelegramBotClient(token);
|
||
|
||
// Прокси (обычно socks5://) для запросов к Bot API — на случай, если Telegram недоступен напрямую
|
||
// с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение (см. .env.example).
|
||
var proxyUri = new Uri(options.ProxyUrl);
|
||
var proxy = new WebProxy(proxyUri);
|
||
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
|
||
{
|
||
var credentials = proxyUri.UserInfo.Split(':', 2);
|
||
proxy.Credentials = new NetworkCredential(credentials[0], credentials.Length > 1 ? credentials[1] : string.Empty);
|
||
}
|
||
|
||
sp.GetRequiredService<ILogger<Program>>().LogInformation(
|
||
"Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port);
|
||
|
||
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
|
||
return new TelegramBotClient(token, new HttpClient(handler));
|
||
});
|
||
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
|
||
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
|
||
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
||
builder.Services.AddHostedService<TelegramBotHostedService>();
|
||
|
||
builder.Services.AddRateLimiter(options =>
|
||
{
|
||
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
|
||
{
|
||
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
|
||
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
|
||
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||
limiterOptions.QueueLimit = 0;
|
||
});
|
||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||
});
|
||
|
||
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
|
||
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
|
||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||
|
||
builder.Services.AddProblemDetails();
|
||
builder.Services.AddOpenApi();
|
||
builder.Services.AddHealthChecks()
|
||
.AddDbContextCheck<AppDbContext>();
|
||
|
||
var app = builder.Build();
|
||
|
||
// Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания
|
||
// расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим.
|
||
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
|
||
{
|
||
app.Logger.LogWarning(
|
||
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent " +
|
||
"and will be lost when the container is recreated. Mount a volume and set the path in production.");
|
||
}
|
||
|
||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||
await app.Services.ApplyMigrationsAsync();
|
||
await app.Services.SeedDataAsync();
|
||
|
||
app.UseForwardedHeaders();
|
||
app.UseSerilogRequestLogging();
|
||
app.UseExceptionHandler();
|
||
|
||
app.UseRateLimiter();
|
||
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
|
||
// OpenAPI-схема (/openapi/v1.json) + современный UI Scalar (/scalar).
|
||
app.MapOpenApi();
|
||
app.MapScalarApiReference();
|
||
|
||
app.MapHealthChecks("/health");
|
||
|
||
app.MapAuthEndpoints();
|
||
app.MapActivationEndpoints();
|
||
app.MapRoleEndpoints();
|
||
app.MapNodeEndpoints();
|
||
app.MapInboundEndpoints();
|
||
app.MapConfigEndpoints();
|
||
app.MapSubscriptionEndpoints();
|
||
app.MapAppEndpoints();
|
||
app.MapNewsEndpoints();
|
||
app.MapAdminUserEndpoints();
|
||
app.MapAdminStatsEndpoints();
|
||
app.MapAdminAppEndpoints();
|
||
app.MapAdminNewsEndpoints();
|
||
app.MapTelegramEndpoints();
|
||
|
||
app.MapHub<PanelHub>("/hubs/panel");
|
||
|
||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||
app.UseDefaultFiles();
|
||
app.UseStaticFiles();
|
||
app.MapFallbackToFile("index.html");
|
||
|
||
app.Run();
|
||
|
||
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.</summary>
|
||
public partial class Program;
|