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(builder.Configuration.GetSection("QMax")); var configuredOptions = builder.Configuration.GetSection("QMax").Get() ?? new QMaxOptions(); var databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(configuredOptions.DatabasePath)); if (!string.IsNullOrWhiteSpace(databaseDirectory)) { Directory.CreateDirectory(databaseDirectory); } builder.Services.AddDbContext(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); if (string.Equals(configuredOptions.MaxMode, "Mock", StringComparison.OrdinalIgnoreCase)) { builder.Services.AddSingleton(); } else { builder.Services.AddHttpClient(); } var app = builder.Build(); app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHub("/hubs/qmax"); using (var scope = app.Services.CreateScope()) { var options = scope.ServiceProvider.GetRequiredService>().Value; Directory.CreateDirectory(options.StoragePath); Directory.CreateDirectory(options.ReleasesPath); var db = scope.ServiceProvider.GetRequiredService(); 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 attachmentColumns = new HashSet(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 db.Database.ExecuteSqlRawAsync(""" CREATE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId ON MessageAttachments (MessageId, ExternalId); """); 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 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;