270 lines
9.4 KiB
C#
270 lines
9.4 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.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 RemoveDuplicateMessageAttachmentsAsync(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_Messages_ChatId_ExternalId
|
|
ON Messages (ChatId, 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 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;
|