Files
Telegram-Panel/src/TelegramPanel.Web/Program.cs
T

1074 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MudBlazor.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using System.Security.Claims;
using Serilog;
using TelegramPanel.Core;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Services;
using TelegramPanel.Core.Services.Telegram;
using TelegramPanel.Data;
using TelegramPanel.Web.Modules;
using TelegramPanel.Web.Services;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.Sqlite;
using System.Diagnostics;
// 诊断:对某个目录下的 *.json/*.session 做一次“可转换/可校验”检查(不写数据库)
// 用法:dotnet run --project src/TelegramPanel.Web -- --diag-session-dir "D:/path/to/dir"
if (args.Length >= 2 && string.Equals(args[0], "--diag-session-dir", StringComparison.OrdinalIgnoreCase))
{
var dir = args[1];
if (!Directory.Exists(dir))
{
Console.Error.WriteLine($"目录不存在:{dir}");
return;
}
using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(b =>
{
b.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.TimestampFormat = "HH:mm:ss ";
});
b.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information);
});
var logger = loggerFactory.CreateLogger("SessionDiag");
var jsonFiles = Directory.EnumerateFiles(dir, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
if (jsonFiles.Count == 0)
{
Console.Error.WriteLine("目录内未找到任何 .json 文件");
return;
}
var tempOutDir = Path.Combine(Path.GetTempPath(), "telegram-panel-diag-sessions");
Directory.CreateDirectory(tempOutDir);
foreach (var jsonPath in jsonFiles)
{
try
{
var json = await File.ReadAllTextAsync(jsonPath);
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (!TryGetInt(root, out var apiId, "api_id", "app_id"))
{
Console.WriteLine($"[FAIL] {Path.GetFileName(jsonPath)}: 缺少 api_id/app_id");
continue;
}
if (!TryGetString(root, out var apiHash, "api_hash", "app_hash") || string.IsNullOrWhiteSpace(apiHash))
{
Console.WriteLine($"[FAIL] {Path.GetFileName(jsonPath)}: 缺少 api_hash/app_hash");
continue;
}
if (!TryGetString(root, out var phone, "phone") || string.IsNullOrWhiteSpace(phone))
{
if (!TryGetString(root, out phone, "session_file", "sessionFile") || string.IsNullOrWhiteSpace(phone))
phone = Path.GetFileNameWithoutExtension(jsonPath);
if (string.IsNullOrWhiteSpace(phone))
{
Console.WriteLine($"[FAIL] {Path.GetFileName(jsonPath)}: 缺少 phone");
continue;
}
}
_ = TryGetLong(root, out var userId, "user_id", "uid");
_ = TryGetString(root, out var sessionString, "session_string", "sessionString");
phone = phone.Trim();
apiHash = apiHash.Trim();
sessionString = string.IsNullOrWhiteSpace(sessionString) ? null : sessionString.Trim();
var baseName = Path.GetFileNameWithoutExtension(jsonPath);
var sessionPath = Path.Combine(dir, $"{baseName}.session");
if (!File.Exists(sessionPath))
sessionPath = Directory.EnumerateFiles(dir, "*.session", SearchOption.TopDirectoryOnly).FirstOrDefault() ?? string.Empty;
if (string.IsNullOrWhiteSpace(sessionPath) || !File.Exists(sessionPath))
{
Console.WriteLine($"[FAIL] {phone}: 未找到 .session 文件(json={Path.GetFileName(jsonPath)})");
continue;
}
var targetSessionPath = Path.Combine(tempOutDir, $"{phone}.session");
TelegramPanel.Core.Services.Telegram.SessionDataConverter.SessionConvertResult converted;
if (TelegramPanel.Core.Services.Telegram.SessionDataConverter.LooksLikeSqliteSession(sessionPath))
{
if (!string.IsNullOrWhiteSpace(sessionString))
{
converted = await TelegramPanel.Core.Services.Telegram.SessionDataConverter.TryCreateWTelegramSessionFromSessionStringAsync(
sessionString: sessionString,
apiId: apiId,
apiHash: apiHash,
targetSessionPath: targetSessionPath,
phone: phone,
userId: userId,
logger: logger);
}
else
{
converted = await TelegramPanel.Core.Services.Telegram.SessionDataConverter.TryCreateWTelegramSessionFromTelethonSqliteFileAsync(
sqliteSessionPath: sessionPath,
apiId: apiId,
apiHash: apiHash,
targetSessionPath: targetSessionPath,
phone: phone,
userId: userId,
logger: logger);
}
}
else
{
File.Copy(sessionPath, targetSessionPath, overwrite: true);
converted = TelegramPanel.Core.Services.Telegram.SessionDataConverter.SessionConvertResult.Success();
}
if (converted.Ok)
Console.WriteLine($"[OK] {phone}: 可用(输出={targetSessionPath})");
else
Console.WriteLine($"[FAIL] {phone}: {converted.Reason}");
}
catch (Exception ex)
{
Console.WriteLine($"[FAIL] {Path.GetFileName(jsonPath)}: {ex.GetType().Name}: {ex.Message}");
}
}
return;
static bool TryGetInt(System.Text.Json.JsonElement root, out int value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out var prop))
{
if (prop.ValueKind == System.Text.Json.JsonValueKind.Number && prop.TryGetInt32(out var i))
{
value = i;
return true;
}
if (prop.ValueKind == System.Text.Json.JsonValueKind.String && int.TryParse(prop.GetString(), out var isv))
{
value = isv;
return true;
}
}
}
value = 0;
return false;
}
static bool TryGetLong(System.Text.Json.JsonElement root, out long? value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out var prop))
{
if (prop.ValueKind == System.Text.Json.JsonValueKind.Number && prop.TryGetInt64(out var l))
{
value = l;
return true;
}
if (prop.ValueKind == System.Text.Json.JsonValueKind.String && long.TryParse(prop.GetString(), out var ls))
{
value = ls;
return true;
}
}
}
value = null;
return false;
}
static bool TryGetString(System.Text.Json.JsonElement root, out string? value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String)
{
value = prop.GetString();
return true;
}
}
value = null;
return false;
}
}
var builder = WebApplication.CreateBuilder(args);
// 可选的本地覆盖配置(不要提交到仓库)
// Docker 部署时该文件通常是指向 /data/appsettings.local.json 的符号链接;首次启动可能是“悬空链接”,
// 直接加载会抛 FileNotFoundException 导致容器不断重启,因此这里先确保文件存在一个空 JSON。
try
{
var localOverridePath = LocalConfigFile.ResolvePath(builder.Configuration, builder.Environment);
if (!File.Exists(localOverridePath))
File.WriteAllText(localOverridePath, "{}", new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to ensure appsettings.local.json exists: {ex.Message}");
}
try
{
// 注意:如果该路径是“悬空符号链接”,配置系统可能会认为文件存在并尝试打开,从而抛 FileNotFoundException。
// 本地覆盖配置不应该阻塞启动,因此这里兜底吞掉 FileNotFoundException。
var localOverridePath = LocalConfigFile.ResolvePath(builder.Configuration, builder.Environment);
builder.Configuration.AddJsonFile(localOverridePath, optional: true, reloadOnChange: true);
}
catch (FileNotFoundException ex)
{
Console.Error.WriteLine($"Ignoring missing appsettings.local.json: {ex.Message}");
}
// 配置 Serilog
static int ReadRetainedFileCountLimit(IConfiguration configuration)
{
var raw = (configuration["Serilog:RetainedFileCountLimit"] ?? "").Trim();
if (!int.TryParse(raw, out var v))
return 30;
if (v < 1) return 1;
if (v > 3650) return 3650;
return v;
}
var retainedFileCountLimit = ReadRetainedFileCountLimit(builder.Configuration);
var serilogEnabled = builder.Configuration.GetValue("Serilog:Enabled", false);
var loggerConfig = new LoggerConfiguration()
.Enrich.FromLogContext();
if (serilogEnabled)
{
loggerConfig = loggerConfig
.ReadFrom.Configuration(builder.Configuration)
// buffered=true:降低磁盘抖动/IO 阻塞导致的请求卡顿风险(尤其是低配 VPS/挂载卷/杀软扫描场景)
.WriteTo.File(
"logs/telegram-panel-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: retainedFileCountLimit,
buffered: true,
flushToDiskInterval: TimeSpan.FromSeconds(1));
}
else
{
// 关闭“详细日志输出”时,仍保留 Error/Fatal:避免进程异常退出却完全看不到日志。
loggerConfig = loggerConfig.MinimumLevel.Error();
}
loggerConfig = loggerConfig.WriteTo.Console();
Log.Logger = loggerConfig.CreateLogger();
builder.Host.UseSerilog();
// 兜底:即使日志配置被关闭/过滤,也尽量把致命异常打到 stderr
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try
{
if (e.ExceptionObject is Exception ex)
Log.Fatal(ex, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
else
Log.Fatal("Unhandled exception: {ExceptionObject} (IsTerminating={IsTerminating})", e.ExceptionObject, e.IsTerminating);
}
catch
{
// ignore
}
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
try
{
Log.Error(e.Exception, "Unobserved task exception");
}
catch
{
// ignore
}
finally
{
e.SetObserved();
}
};
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// MudBlazor
builder.Services.AddMudServices();
builder.Services.AddMemoryCache();
// DataProtection keys 持久化:避免容器重建/重启后出现 antiforgery token 无法解密
try
{
var configuredKeysPath = (builder.Configuration["DataProtection:KeysPath"] ?? "").Trim();
var keysPath = configuredKeysPath;
if (string.IsNullOrWhiteSpace(keysPath))
{
keysPath = Directory.Exists("/data")
? "/data/keys"
: Path.Combine(builder.Environment.ContentRootPath, "data-protection-keys");
}
Directory.CreateDirectory(keysPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysPath));
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to configure DataProtection keys persistence: {ex.Message}");
}
// 反向代理支持(宝塔/Nginx/Caddy 等)
// 让应用正确识别外部访问的 Host/Proto,避免重定向到 http://localhost/...
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
// 适配宝塔等面板:上游代理 IP 不固定时不做白名单限制(由部署环境保证信任边界)。
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
// 数据库上下文
var configuredConnectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=telegram-panel.db";
// 统一 SQLite 数据库路径为 ContentRoot 下的文件,避免因工作目录不同导致连到错误的 db(从而出现 no such table)
var connectionString = configuredConnectionString;
try
{
var dataSourcePrefix = "Data Source=";
var trimmed = configuredConnectionString.Trim();
if (trimmed.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase))
{
var dataSource = trimmed.Substring(dataSourcePrefix.Length).Trim().Trim('"');
if (!Path.IsPathRooted(dataSource))
{
var absolute = Path.Combine(builder.Environment.ContentRootPath, dataSource);
connectionString = $"{dataSourcePrefix}{absolute}";
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to normalize sqlite connection string, using configured value");
connectionString = configuredConnectionString;
}
// 云端场景(容器/卷/后台任务)更容易出现 SQLite 写锁:这里统一增强连接参数,提升抗锁能力
try
{
var csb = new SqliteConnectionStringBuilder(connectionString)
{
// 等待写锁释放的最大秒数(映射/等价于 busy_timeout 行为)
DefaultTimeout = 30,
Pooling = true
};
connectionString = csb.ToString();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to enhance sqlite connection string, using current value");
}
builder.Services.AddTelegramPanelData(connectionString);
// Telegram Panel 核心服务
builder.Services.AddTelegramPanelCore();
builder.Services.AddScoped<AccountExportService>();
builder.Services.AddScoped<DataSyncService>();
builder.Services.AddScoped<UiPreferencesService>();
builder.Services.AddScoped<BotAdminPresetsService>();
builder.Services.AddScoped<BotChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminPresetsService>();
builder.Services.AddScoped<ChannelInvitePresetsService>();
builder.Services.Configure<UpdateCheckOptions>(builder.Configuration.GetSection("UpdateCheck"));
builder.Services.AddSingleton<UpdateCheckService>();
builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSection("System"));
builder.Services.AddSingleton<PanelTimeZoneService>();
builder.Services.AddHostedService<BatchTaskBackgroundService>();
builder.Services.AddHostedService<AccountDataAutoSyncBackgroundService>();
builder.Services.AddHostedService<BotAutoSyncBackgroundService>();
builder.Services.AddHostedService<TelegramPanel.Web.Services.WebhookRegistrationService>();
builder.Services.AddHttpClient<TelegramBotApiClient>();
builder.Services.AddHttpClient<TelegramPanel.Web.Services.CloudMailClient>();
builder.Services.AddScoped<TelegramPanel.Modules.ITelegramEmailCodeService, TelegramPanel.Web.Services.TelegramEmailCodeService>();
builder.Services.AddModuleSystem(builder.Configuration, builder.Environment);
builder.Services.AddSingleton<AppRestartService>();
// 后台账号密码验证(Cookie 登录)
builder.Services.Configure<AdminAuthOptions>(builder.Configuration.GetSection("AdminAuth"));
builder.Services.AddSingleton<AdminCredentialStore>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "TelegramPanel.Auth";
options.LoginPath = "/login";
options.AccessDeniedPath = "/login";
options.ReturnUrlParameter = "returnUrl";
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
// 反向代理(宝塔默认反代)可能会把 Host 透传为 127.0.0.1/localhost,导致框架生成绝对跳转到 http://localhost/login
// 这里强制使用“相对路径重定向”,不依赖 Host/Proto,就算反代没配 header 也能正常跳转。
var loginPathValue = options.LoginPath.HasValue ? options.LoginPath.Value : "/login";
var returnUrlParam = options.ReturnUrlParameter;
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = ctx =>
{
var returnUrl = (ctx.Request.PathBase + ctx.Request.Path + ctx.Request.QueryString).ToString();
var target = $"{loginPathValue}?{returnUrlParam}={Uri.EscapeDataString(returnUrl)}";
ctx.Response.Redirect(target);
return Task.CompletedTask;
},
OnRedirectToAccessDenied = ctx =>
{
var returnUrl = (ctx.Request.PathBase + ctx.Request.Path + ctx.Request.QueryString).ToString();
var target = $"{loginPathValue}?{returnUrlParam}={Uri.EscapeDataString(returnUrl)}";
ctx.Response.Redirect(target);
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// TODO: 添加 Hangfire
// builder.Services.AddHangfire(config => config.UseInMemoryStorage());
// builder.Services.AddHangfireServer();
var app = builder.Build();
// 确保数据库已创建并应用最新迁移
// 注意:SQLite 在首次连接时只会创建空文件,不会自动建表,因此需要显式执行迁移。
using (var scope = app.Services.CreateScope())
{
try
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Log.Information("Using sqlite connection string: {ConnectionString}", connectionString);
var migrations = db.Database.GetMigrations().ToList();
Log.Information("EF migrations discovered: {Count}", migrations.Count);
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
ConfigureSqliteConnection(conn);
List<string> tables;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
using var reader = cmd.ExecuteReader();
tables = new List<string>();
while (reader.Read())
tables.Add(reader.GetString(0));
}
var hasHistory = tables.Contains("__EFMigrationsHistory", StringComparer.Ordinal);
var hasAnyUserTables = tables.Any(t =>
!string.Equals(t, "__EFMigrationsHistory", StringComparison.Ordinal) &&
!t.StartsWith("sqlite_", StringComparison.OrdinalIgnoreCase));
if (migrations.Count > 0)
{
// 迁移策略:
// - 新库:直接 Migrate()
// - 已有迁移历史:直接 Migrate()
// - 已有表但无迁移历史:写入 baseline 到 __EFMigrationsHistory,再 Migrate()(避免永远无法升级)
if (!hasAnyUserTables)
{
db.Database.Migrate();
}
else if (hasHistory)
{
db.Database.Migrate();
}
else
{
var baseline = migrations.First(); // EF 返回的顺序为从旧到新
EnsureMigrationsHistoryBaseline(conn, baseline);
db.Database.Migrate();
}
}
else
{
db.Database.EnsureCreated();
}
// 刷新表清单(上面可能已创建/迁移)
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
using var reader = cmd.ExecuteReader();
tables = new List<string>();
while (reader.Read())
tables.Add(reader.GetString(0));
}
// 兜底:开发阶段允许轻量演进 schema(避免已有库无迁移历史时无法自动更新)
// 仅做“新增列”这种非破坏性变更。
if (tables.Contains("Accounts", StringComparer.Ordinal))
{
EnsureSqliteColumn(conn, tableName: "Accounts", columnName: "Nickname", columnType: "TEXT");
}
// 兜底:若 Accounts 仍不存在(常见于库被创建为空文件/仅有历史表),给出可恢复的自愈
if (!tables.Contains("Accounts", StringComparer.Ordinal))
{
hasHistory = tables.Contains("__EFMigrationsHistory", StringComparer.Ordinal);
if (tables.Count == 0)
{
Log.Warning("Database has no tables; calling EnsureCreated()");
db.Database.EnsureCreated();
}
else if (tables.Count == 1 && hasHistory)
{
Log.Warning("Database only contains __EFMigrationsHistory but no schema tables; recreating schema via EnsureCreated()");
db.Database.ExecuteSqlRaw("DROP TABLE IF EXISTS __EFMigrationsHistory;");
db.Database.EnsureCreated();
}
else
{
Log.Error("Accounts table missing. Existing tables: {Tables}", string.Join(", ", tables));
}
}
void ConfigureSqliteConnection(System.Data.Common.DbConnection connection)
{
// journal_mode=WAL 会持久化到库;busy_timeout 是连接级参数
// 这里提前设置,减少在云端并发写入时的 “database is locked” 概率
try
{
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA journal_mode=WAL;";
_ = cmd.ExecuteScalar();
}
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA synchronous=NORMAL;";
cmd.ExecuteNonQuery();
}
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "PRAGMA busy_timeout=5000;";
cmd.ExecuteNonQuery();
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to configure sqlite pragmas");
}
}
void EnsureSqliteColumn(System.Data.Common.DbConnection connection, string tableName, string columnName, string columnType)
{
try
{
var existingColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var pragma = connection.CreateCommand())
{
pragma.CommandText = $"PRAGMA table_info('{tableName}');";
using var r = pragma.ExecuteReader();
while (r.Read())
{
var name = r.GetString(1);
existingColumns.Add(name);
}
}
if (existingColumns.Contains(columnName))
return;
using (var alter = connection.CreateCommand())
{
alter.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnType};";
alter.ExecuteNonQuery();
}
Log.Information("Applied schema patch: {Table}.{Column} added", tableName, columnName);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to ensure sqlite column {Table}.{Column}", tableName, columnName);
}
}
void EnsureMigrationsHistoryBaseline(System.Data.Common.DbConnection connection, string baselineMigrationId)
{
try
{
// 创建历史表(若不存在)
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS __EFMigrationsHistory (
MigrationId TEXT NOT NULL CONSTRAINT PK___EFMigrationsHistory PRIMARY KEY,
ProductVersion TEXT NOT NULL
);";
cmd.ExecuteNonQuery();
}
// 若 baseline 已存在则跳过
using (var check = connection.CreateCommand())
{
check.CommandText = "SELECT COUNT(1) FROM __EFMigrationsHistory WHERE MigrationId = $id;";
var p = check.CreateParameter();
p.ParameterName = "$id";
p.Value = baselineMigrationId;
check.Parameters.Add(p);
var exists = Convert.ToInt32(check.ExecuteScalar()) > 0;
if (exists)
return;
}
var productVersion = typeof(Microsoft.EntityFrameworkCore.DbContext)
.Assembly
.GetName()
.Version?
.ToString(3) ?? "8.0.0";
using (var insert = connection.CreateCommand())
{
insert.CommandText = "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ($id, $v);";
var p1 = insert.CreateParameter();
p1.ParameterName = "$id";
p1.Value = baselineMigrationId;
insert.Parameters.Add(p1);
var p2 = insert.CreateParameter();
p2.ParameterName = "$v";
p2.Value = productVersion;
insert.Parameters.Add(p2);
insert.ExecuteNonQuery();
}
Log.Warning("Database has schema tables but no __EFMigrationsHistory; baselined migrations history with {MigrationId}", baselineMigrationId);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to baseline __EFMigrationsHistory; database might not auto-upgrade");
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Database migration failed");
throw;
}
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseForwardedHeaders();
// 仅在存在 HTTPS 端口/端点时启用重定向;否则会产生 “Failed to determine the https port for redirect.” 噪声
var httpsPort = app.Configuration["ASPNETCORE_HTTPS_PORT"];
var urls = app.Configuration["ASPNETCORE_URLS"] ?? "";
var hasHttpsEndpoint = !string.IsNullOrWhiteSpace(httpsPort)
|| urls.Contains("https://", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(app.Configuration["Kestrel:Endpoints:Https:Url"]);
if (hasHttpsEndpoint)
app.UseHttpsRedirection();
// 静态文件(包括 MudBlazor 等 NuGet 包的静态 Web 资源)
app.UseStaticFiles();
app.UseAntiforgery();
// Serilog 请求日志
if (serilogEnabled)
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
// Modules (built-in & installed): endpoints mapping
app.MapInstalledModules();
// 初始化后台登录凭据(首次启动会生成 admin_auth.json)
var adminCredentials = app.Services.GetRequiredService<AdminCredentialStore>();
await adminCredentials.EnsureInitializedAsync();
// 登录页(独立 endpoint,避免与 RequireAuthorization 的 Razor Components 冲突)
app.MapGet("/login", async (HttpContext http, IConfiguration configuration, AdminCredentialStore credentialStore) =>
{
var enabled = credentialStore.Enabled;
var configured = enabled;
if (configured && http.User.Identity?.IsAuthenticated == true)
return Results.Redirect("/");
var q = http.Request.Query;
var error = q.TryGetValue("error", out var e) ? e.ToString() : "";
var returnUrl = q.TryGetValue("returnUrl", out var r) ? r.ToString() : "";
if (string.IsNullOrWhiteSpace(returnUrl))
returnUrl = q.TryGetValue("ReturnUrl", out var r2) ? r2.ToString() : "/";
if (!AdminAuthHelpers.IsLocalReturnUrl(returnUrl))
returnUrl = "/";
var title = "Telegram Panel 登录";
var msg = error == "1" ? "<div class=\"mud-alert mud-alert-filled mud-alert-filled-error\" style=\"margin-bottom:12px;\">账号或密码错误</div>" : "";
var disabledMsg = configured ? "" : "<div class=\"mud-alert mud-alert-filled mud-alert-filled-warning\" style=\"margin-bottom:12px;\">后台验证未启用</div>";
var initialUsername = System.Net.WebUtility.HtmlEncode((configuration["AdminAuth:InitialUsername"] ?? "admin").Trim());
var initialPassword = System.Net.WebUtility.HtmlEncode((configuration["AdminAuth:InitialPassword"] ?? "admin123").Trim());
var initialHint = configured
? $"<div class=\"mud-alert mud-alert-filled mud-alert-filled-info\" style=\"margin-bottom:12px;\">初始账号:<b>{initialUsername}</b>,初始密码:<b>{initialPassword}</b>(首次登录后请立即修改)</div>"
: "";
var html = $@"
<!DOCTYPE html>
<html lang=""zh-CN"">
<head>
<meta charset=""utf-8"" />
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"" />
<title>{title}</title>
<link href=""_content/MudBlazor/MudBlazor.min.css"" rel=""stylesheet"" />
<style>
body {{ background:#121212; color:#fff; font-family:Roboto,Arial; }}
.wrap {{ max-width:420px; margin:10vh auto; padding:24px; background:#1e1e2d; border-radius:12px; }}
.field {{ width:100%; padding:12px 14px; border-radius:8px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); color:#fff; }}
.label {{ font-size:12px; opacity:0.8; margin:10px 0 6px; }}
.btn {{ width:100%; margin-top:14px; padding:10px 14px; border-radius:10px; border:0; background:#1976d2; color:#fff; font-weight:600; cursor:pointer; }}
.btn:disabled {{ opacity:0.5; cursor:not-allowed; }}
</style>
</head>
<body>
<div class=""wrap"">
<h2 style=""margin:0 0 8px;"">Telegram Panel</h2>
<div style=""opacity:0.8; margin-bottom:16px;"">后台登录</div>
{disabledMsg}
{initialHint}
{msg}
<form method=""post"" action=""/login"">
<input type=""hidden"" name=""returnUrl"" value=""{System.Net.WebUtility.HtmlEncode(returnUrl)}"" />
<div class=""label"">账号</div>
<input class=""field"" name=""username"" autocomplete=""username"" />
<div class=""label"">密码</div>
<input class=""field"" type=""password"" name=""password"" autocomplete=""current-password"" />
<button class=""btn"" type=""submit"" {(configured ? "" : "disabled")}>登录</button>
</form>
</div>
</body>
</html>";
return Results.Content(html, "text/html; charset=utf-8");
}).AllowAnonymous();
app.MapPost("/login", async (HttpContext http, AdminCredentialStore credentialStore) =>
{
if (!credentialStore.Enabled)
return Results.Redirect("/login");
var form = await http.Request.ReadFormAsync();
var u = (form["username"].ToString() ?? "").Trim();
var p = (form["password"].ToString() ?? "").Trim();
var returnUrl = (form["returnUrl"].ToString() ?? "/").Trim();
if (!AdminAuthHelpers.IsLocalReturnUrl(returnUrl))
returnUrl = "/";
var ok = await credentialStore.ValidateAsync(u, p);
if (!ok)
return Results.Redirect($"/login?error=1&returnUrl={Uri.EscapeDataString(returnUrl)}");
var claims = new List<Claim>
{
new(ClaimTypes.Name, u),
new(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await http.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30) });
if (credentialStore.MustChangePassword)
return Results.Redirect($"/admin/password?returnUrl={Uri.EscapeDataString(returnUrl)}");
return Results.Redirect(returnUrl);
}).DisableAntiforgery().AllowAnonymous();
app.MapGet("/logout", async (HttpContext http) =>
{
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
}).AllowAnonymous();
var adminAuthEnabled = adminCredentials.Enabled;
var razor = app.MapRazorComponents<TelegramPanel.Web.Components.App>()
.AddInteractiveServerRenderMode();
if (adminAuthEnabled)
razor.RequireAuthorization();
// 下载:导出账号 Zip(用于备份/迁移)
var accountsZipDownload = app.MapGet("/downloads/accounts.zip", async (
HttpContext http,
AccountManagementService accountManagement,
AccountExportService exporter,
CancellationToken cancellationToken) =>
{
var idsRaw = http.Request.Query["ids"].ToString();
HashSet<int>? ids = null;
if (!string.IsNullOrWhiteSpace(idsRaw))
{
ids = idsRaw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var x) ? x : 0)
.Where(x => x > 0)
.ToHashSet();
}
var all = (await accountManagement.GetAllAccountsAsync()).ToList();
var accounts = ids == null ? all : all.Where(a => ids.Contains(a.Id)).ToList();
var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken);
var fileName = $"telegram-panel-accounts-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip";
return Results.File(zipBytes, "application/zip", fileName);
}).DisableAntiforgery();
if (adminAuthEnabled)
accountsZipDownload.RequireAuthorization();
// 下载:导出 Bot 邀请链接(文本)
var botInvitesDownload = app.MapGet("/downloads/bots/{botId:int}/invites.txt", async (
HttpContext http,
int botId,
BotManagementService botManagement,
BotTelegramService botTelegram,
CancellationToken cancellationToken) =>
{
var bot = await botManagement.GetBotAsync(botId)
?? throw new InvalidOperationException($"机器人不存在:{botId}");
var idsRaw = http.Request.Query["ids"].ToString();
IReadOnlyList<long> telegramIds;
if (string.IsNullOrWhiteSpace(idsRaw))
{
telegramIds = (await botManagement.GetChannelsAsync(botId)).Select(x => x.TelegramId).ToList();
}
else
{
telegramIds = idsRaw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var x) ? x : 0)
.Where(x => x > 0)
.Distinct()
.ToList();
}
var links = await botTelegram.ExportInviteLinksAsync(botId, telegramIds, cancellationToken);
var lines = new List<string>
{
$"# Bot: {bot.Name}",
$"# ExportedAtUtc: {DateTime.UtcNow:O}",
"# Format: <TelegramId>\\t<Link>",
""
};
foreach (var id in telegramIds)
{
if (links.TryGetValue(id, out var link))
lines.Add($"{id}\t{link}");
else
lines.Add($"{id}\t(无法生成/不可见/无权限)");
}
var text = string.Join(Environment.NewLine, lines);
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
var fileName = $"telegram-panel-bot-invites-{botId}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.txt";
return Results.File(bytes, "text/plain; charset=utf-8", fileName);
}).DisableAntiforgery();
if (adminAuthEnabled)
botInvitesDownload.RequireAuthorization();
// 下载:导出频道邀请链接(文本)
var channelInvitesDownload = app.MapGet("/downloads/channels/invites.txt", async (
HttpContext http,
ChannelManagementService channelManagement,
IChannelService channelService,
CancellationToken cancellationToken) =>
{
var idsRaw = http.Request.Query["ids"].ToString();
IReadOnlyList<long> telegramIds;
if (string.IsNullOrWhiteSpace(idsRaw))
{
telegramIds = (await channelManagement.GetAllChannelsAsync()).Select(x => x.TelegramId).Where(x => x > 0).Distinct().ToList();
}
else
{
telegramIds = idsRaw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var x) ? x : 0)
.Where(x => x > 0)
.Distinct()
.ToList();
}
var preferredAccountIdRaw = http.Request.Query["accountId"].ToString();
var preferredAccountId = int.TryParse(preferredAccountIdRaw, out var x) ? x : 0;
if (preferredAccountId <= 0)
preferredAccountId = 0;
var lines = new List<string>
{
$"# ExportedAtUtc: {DateTime.UtcNow:O}",
"# Format: <TelegramId>\\t<Title>\\t<Link>",
""
};
foreach (var telegramId in telegramIds)
{
cancellationToken.ThrowIfCancellationRequested();
var ch = await channelManagement.GetChannelByTelegramIdAsync(telegramId);
if (ch == null)
{
lines.Add($"{telegramId}\t(unknown)\t(频道不存在)");
continue;
}
var executeAccountId = await channelManagement.ResolveExecuteAccountIdAsync(ch, preferredAccountId: preferredAccountId);
if (executeAccountId is not > 0)
{
lines.Add($"{telegramId}\t{ch.Title}\t(无可用执行账号)");
continue;
}
try
{
var link = await channelService.ExportJoinLinkAsync(executeAccountId.Value, telegramId);
lines.Add($"{telegramId}\t{ch.Title}\t{link}");
}
catch
{
lines.Add($"{telegramId}\t{ch.Title}\t(无法生成/不可见/无权限)");
}
}
var text = string.Join(Environment.NewLine, lines);
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
var fileName = $"telegram-panel-channel-invites-{DateTime.UtcNow:yyyyMMdd-HHmmss}.txt";
return Results.File(bytes, "text/plain; charset=utf-8", fileName);
}).DisableAntiforgery();
if (adminAuthEnabled)
channelInvitesDownload.RequireAuthorization();
// Telegram Bot Webhook 端点
// 接收 Telegram 服务器推送的更新,用于 Webhook 模式
app.MapPost("/api/bot/webhook/{secretToken}", async (
HttpContext http,
string secretToken,
BotUpdateHub updateHub,
IConfiguration configuration,
ILogger<Program> logger,
CancellationToken cancellationToken) =>
{
// 验证 secret token
var configuredSecret = configuration["Telegram:WebhookSecretToken"];
if (!string.IsNullOrWhiteSpace(configuredSecret))
{
// 检查 Telegram 发送的 X-Telegram-Bot-Api-Secret-Token header
var headerSecret = http.Request.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString();
if (!string.Equals(headerSecret, configuredSecret, StringComparison.Ordinal))
{
logger.LogWarning("Webhook request rejected: invalid secret token");
return Results.Unauthorized();
}
}
// 读取请求体
using var reader = new System.IO.StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
logger.LogWarning("Webhook request rejected: empty body");
return Results.BadRequest();
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
var update = doc.RootElement;
var botToken = await updateHub.ResolveBotTokenFromWebhookPathAsync(secretToken, cancellationToken);
if (string.IsNullOrWhiteSpace(botToken))
{
logger.LogWarning("Webhook update rejected: unknown or inactive bot");
return Results.NotFound();
}
var sw = Stopwatch.StartNew();
var success = await updateHub.InjectWebhookUpdateAsync(botToken, update.Clone(), cancellationToken);
sw.Stop();
if (!success)
{
logger.LogWarning("Webhook update rejected: unknown or inactive bot");
return Results.NotFound();
}
var updateId = update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0;
if (sw.Elapsed > TimeSpan.FromSeconds(2))
logger.LogWarning("Webhook update processed slowly: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
else
logger.LogDebug("Webhook update processed: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
return Results.Ok();
}
catch (System.Text.Json.JsonException ex)
{
logger.LogWarning(ex, "Webhook request rejected: invalid JSON");
return Results.BadRequest();
}
}).AllowAnonymous().DisableAntiforgery();
// TODO: Hangfire Dashboard
// app.MapHangfireDashboard("/hangfire");
Log.Information("Telegram Panel started");
try
{
app.Run();
}
finally
{
Log.CloseAndFlush();
}