Files
2026-07-05 22:16:30 +03:00

341 lines
12 KiB
C#

using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using QMax.Api.Configuration;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<QMaxOptions>(builder.Configuration.GetSection("QMax"));
var configuredOptions = builder.Configuration.GetSection("QMax").Get<QMaxOptions>() ?? new QMaxOptions();
var databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(configuredOptions.DatabasePath));
if (!string.IsNullOrWhiteSpace(databaseDirectory))
{
Directory.CreateDirectory(databaseDirectory);
}
builder.Services.AddDbContext<QMaxDbContext>(options =>
{
options.UseSqlite($"Data Source={configuredOptions.DatabasePath}");
});
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = configuredOptions.CorsAllowedOrigins
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (origins.Length > 0)
{
policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}
else
{
policy.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials();
}
});
});
var jwtSecret = !string.IsNullOrWhiteSpace(configuredOptions.JwtSecret) && configuredOptions.JwtSecret.Length >= 32
? configuredOptions.JwtSecret
: builder.Environment.IsDevelopment()
? "development-only-qmax-secret-change-before-production"
: throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuredOptions.JwtIssuer,
ValidAudience = configuredOptions.JwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ClockSkew = TimeSpan.FromMinutes(1)
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/qmax"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
builder.Services.AddSingleton<IAttachmentStorageService, AttachmentStorageService>();
builder.Services.AddSingleton<MaxBridgeSyncService>();
builder.Services.AddScoped<MaxOutboxService>();
builder.Services.AddHostedService<MaxSyncWorker>();
builder.Services.AddHostedService<MaxOutboxWorker>();
if (string.Equals(configuredOptions.MaxMode, "Mock", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IMaxBridgeClient, MockMaxBridgeClient>();
}
else
{
builder.Services.AddHttpClient<IMaxBridgeClient, WorkerMaxBridgeClient>();
}
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax");
using (var scope = app.Services.CreateScope())
{
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
}
app.Run();
static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
{
var connection = db.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync();
}
var chatColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(Chats);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
chatColumns.Add(reader.GetString(1));
}
}
if (!chatColumns.Contains("WebUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;");
}
if (!chatColumns.Contains("DeletedAt"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN DeletedAt TEXT;");
}
if (!chatColumns.Contains("HistoryClearedAt"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN HistoryClearedAt TEXT;");
}
if (!chatColumns.Contains("PendingMaxAction"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxAction TEXT;");
}
if (!chatColumns.Contains("PendingMaxActionRequestedAt"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionRequestedAt TEXT;");
}
if (!chatColumns.Contains("PendingMaxActionLastAttemptAt"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionLastAttemptAt TEXT;");
}
if (!chatColumns.Contains("PendingMaxActionAttempts"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionAttempts INTEGER NOT NULL DEFAULT 0;");
}
if (!chatColumns.Contains("PendingMaxActionError"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;");
}
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MessageAttachments);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
attachmentColumns.Add(reader.GetString(1));
}
}
if (!attachmentColumns.Contains("RemoteUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN RemoteUrl TEXT;");
}
if (!attachmentColumns.Contains("ExternalId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN ExternalId TEXT;");
}
await RemoveDuplicateMessageAttachmentsAsync(db);
await QMaxDatabaseCleanup.RemoveLegacyWebFileAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db);
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId)
WHERE ExternalId IS NOT NULL AND ExternalId <> '';
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_Chats_DeletedAt
ON Chats (DeletedAt);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_Chats_PendingMaxAction_PendingMaxActionRequestedAt
ON Chats (PendingMaxAction, PendingMaxActionRequestedAt);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_Messages_ChatId_ExternalId
ON Messages (ChatId, ExternalId);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_Messages_DeliveryState_SentAt
ON Messages (DeliveryState, SentAt);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MessageReactions (
Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY,
MessageId TEXT NOT NULL,
Emoji TEXT NOT NULL,
ActorKey TEXT NOT NULL,
ActorName TEXT NULL,
CreatedAt TEXT NOT NULL,
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey);
""");
await RemoveGenericMediaLabelsAsync(db);
}
static async Task RemoveDuplicateMessageAttachmentsAsync(QMaxDbContext db)
{
var attachments = await db.MessageAttachments
.Where(attachment => attachment.ExternalId != null && attachment.ExternalId != "")
.ToListAsync();
var duplicates = attachments
.OrderBy(attachment => attachment.MessageId)
.ThenBy(attachment => attachment.ExternalId)
.ThenByDescending(attachment => attachment.FileSizeBytes)
.ThenBy(attachment => attachment.CreatedAt)
.GroupBy(attachment => new { attachment.MessageId, attachment.ExternalId })
.SelectMany(group => group.Skip(1))
.ToList();
if (duplicates.Count == 0)
{
return;
}
db.MessageAttachments.RemoveRange(duplicates);
await db.SaveChangesAsync();
}
static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
{
var placeholderMessages = await db.Messages
.Where(message => !db.MessageAttachments.Any(attachment => attachment.MessageId == message.Id))
.ToListAsync();
foreach (var message in placeholderMessages.Where(message => MessageTextSanitizer.IsGenericMediaLabel(message.Text)))
{
db.Messages.Remove(message);
}
var messages = await db.Messages.ToListAsync();
foreach (var message in messages)
{
var cleanText = MessageTextSanitizer.CleanMessageText(
message.Text,
await db.MessageAttachments.AnyAsync(attachment => attachment.MessageId == message.Id));
if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal))
{
message.Text = cleanText;
}
}
var chats = await db.Chats.ToListAsync();
foreach (var chat in chats)
{
var cleanPreview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
if (!string.Equals(chat.LastMessagePreview, cleanPreview, StringComparison.Ordinal))
{
chat.LastMessagePreview = cleanPreview;
}
}
var attachments = await db.MessageAttachments.ToListAsync();
foreach (var attachment in attachments)
{
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
attachment.OriginalFileName,
attachment.ContentType,
attachment.Kind);
if (!string.Equals(attachment.OriginalFileName, normalizedFileName, StringComparison.Ordinal))
{
attachment.OriginalFileName = normalizedFileName;
}
}
await db.SaveChangesAsync();
}
public partial class Program;