Files
QMAX/server/QMax.Api/Program.cs
T

441 lines
17 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.Data.Entities;
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<ICurrentUserAccessor, CurrentUserAccessor>();
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.Use(async (context, next) =>
{
var currentUser = context.RequestServices.GetRequiredService<ICurrentUserAccessor>();
var userId = context.User.Identity?.IsAuthenticated == true ? context.User.GetUserId() : (Guid?)null;
using (currentUser.Push(userId))
{
await next(context);
}
});
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>();
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
using (currentUser.Push(null, bypassTenantFilter: true))
{
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("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN UserId TEXT NULL;");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE Chats SET UserId = {legacyUserId} WHERE UserId IS NULL;");
}
}
var maxStateColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MaxAccountStates);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync()) maxStateColumns.Add(reader.GetString(1));
}
if (maxStateColumns.Count > 0 && !maxStateColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MaxAccountStates ADD COLUMN UserId TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE MaxAccountStates SET UserId = {legacyUserId} WHERE UserId = '00000000-0000-0000-0000-000000000000';");
}
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxAccountStates_UserId ON MaxAccountStates (UserId);");
}
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 messageColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(Messages);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
messageColumns.Add(reader.GetString(1));
}
}
if (!messageColumns.Contains("ForwardedFromExternalChatId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalChatId TEXT;");
}
if (!messageColumns.Contains("ForwardedFromExternalMessageId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalMessageId 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("DROP INDEX IF EXISTS IX_Chats_ExternalId;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Chats_UserId_ExternalId ON Chats (UserId, ExternalId);");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Users_PhoneNumber;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_PhoneNumber ON Users (PhoneNumber) WHERE PhoneNumber IS NOT NULL AND PhoneNumber <> '';");
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 TABLE IF NOT EXISTS MaxLoginChallenges (
Id TEXT NOT NULL CONSTRAINT PK_MaxLoginChallenges PRIMARY KEY,
UserId TEXT NOT NULL,
SecretHash TEXT NOT NULL,
DeviceName TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
ExpiresAt TEXT NOT NULL,
CompletedAt TEXT NULL,
FailedAttempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT FK_MaxLoginChallenges_Users_UserId FOREIGN KEY (UserId) REFERENCES Users (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxLoginChallenges_SecretHash ON MaxLoginChallenges (SecretHash);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_MaxLoginChallenges_UserId ON MaxLoginChallenges (UserId);");
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)
{
if (attachment.Kind == AttachmentKind.File)
{
var inferredKind = AttachmentStorageService.GuessKind(
attachment.ContentType,
Path.GetExtension(attachment.OriginalFileName));
if (inferredKind != AttachmentKind.File)
{
attachment.Kind = inferredKind;
}
}
attachment.ContentType = AttachmentStorageService.NormalizeContentType(
attachment.ContentType,
attachment.OriginalFileName,
attachment.Kind);
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;