Initial QMAX production app
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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.AddHostedService<MaxSyncWorker>();
|
||||
|
||||
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 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 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;
|
||||
Reference in New Issue
Block a user