DB migrations: bundled SQL applied automatically after install
Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL scripts. The launcher applies them right after extraction, in the same install session as the binary copy, so the user never lands on a PROSERVE build whose schema doesn't match its code. Service (PSLauncher.Core/Migrations) - IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector, BSD-licensed). Each .sql runs in a transaction; on failure the DB state is rolled back and the install completes (files only) but the user is warned to fix the connection / replay later. - Tracking table _launcher_migrations (filename, applied_at, checksum, duration_ms) — same model as Flyway / Doctrine. Already-applied scripts are skipped on subsequent installs. Modified scripts trigger a warning log without blocking. - Custom SQL splitter that respects strings/comments/backticks so a single .sql file can contain multiple statements separated by `;`. - DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password in config.json (same protection as the license key). Config (PSLauncher.Models/LocalConfig.cs) - New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty password, Database=proserve, AutoApplyMigrations=true. Defaults match a fresh XAMPP install. Override via Settings (next commit). Install pipeline (MainViewModel) - After ZipInstaller.InstallAsync and before declaring the install complete, if AutoApplyMigrations and a _migrations/ folder exists, run ApplyMigrationsAsync with progress reporting (per-file %, filename in footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must be running and pointing to Settings → Database for connection params. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
@@ -162,6 +163,17 @@ public partial class App : Application
|
|||||||
|
|
||||||
services.AddSingleton<IToastService, ToastService>();
|
services.AddSingleton<IToastService, ToastService>();
|
||||||
|
|
||||||
|
// Migration DB MySQL : appliquée juste après extraction d'un build PROSERVE
|
||||||
|
// si le ZIP contient un sous-répertoire _migrations/. La config DB et le
|
||||||
|
// password (DPAPI) sont lus à la demande pour refléter les changements
|
||||||
|
// utilisateur dans Settings sans recréer le service.
|
||||||
|
services.AddSingleton<IDatabaseMigrationService>(sp =>
|
||||||
|
new DatabaseMigrationService(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().Database,
|
||||||
|
passwordProvider: () => DatabasePasswordProtector.Unprotect(
|
||||||
|
sp.GetRequiredService<LocalConfig>().Database.EncryptedPassword),
|
||||||
|
sp.GetRequiredService<ILogger<DatabaseMigrationService>>()));
|
||||||
|
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
services.AddSingleton<MainViewModel>();
|
services.AddSingleton<MainViewModel>();
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using PSLauncher.Core.Installations;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
@@ -33,6 +34,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly ILicenseService _licenseService;
|
private readonly ILicenseService _licenseService;
|
||||||
private readonly ILauncherSelfUpdater _selfUpdater;
|
private readonly ILauncherSelfUpdater _selfUpdater;
|
||||||
private readonly IToastService _toastService;
|
private readonly IToastService _toastService;
|
||||||
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<MainViewModel> _logger;
|
private readonly ILogger<MainViewModel> _logger;
|
||||||
|
|
||||||
@@ -173,6 +175,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
ILicenseService licenseService,
|
ILicenseService licenseService,
|
||||||
ILauncherSelfUpdater selfUpdater,
|
ILauncherSelfUpdater selfUpdater,
|
||||||
IToastService toastService,
|
IToastService toastService,
|
||||||
|
IDatabaseMigrationService migrationService,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<MainViewModel> logger)
|
ILogger<MainViewModel> logger)
|
||||||
{
|
{
|
||||||
@@ -187,6 +190,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_licenseService = licenseService;
|
_licenseService = licenseService;
|
||||||
_selfUpdater = selfUpdater;
|
_selfUpdater = selfUpdater;
|
||||||
_toastService = toastService;
|
_toastService = toastService;
|
||||||
|
_migrationService = migrationService;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -655,6 +659,45 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||||
|
|
||||||
|
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||||
|
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||||
|
if (_config.Database.AutoApplyMigrations)
|
||||||
|
{
|
||||||
|
StatusMessage = Strings.StatusApplyingMigrations(row.Version);
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
var migProgress = new Progress<MigrationProgress>(mp =>
|
||||||
|
{
|
||||||
|
if (mp.Total > 0)
|
||||||
|
{
|
||||||
|
var pct = (double)mp.Done / mp.Total * 100.0;
|
||||||
|
ProgressPercent = pct;
|
||||||
|
row.ProgressPercent = pct;
|
||||||
|
}
|
||||||
|
var label = string.IsNullOrEmpty(mp.CurrentFilename)
|
||||||
|
? Strings.StatusApplyingMigrations(row.Version)
|
||||||
|
: Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
|
||||||
|
ProgressDetail = label;
|
||||||
|
row.ProgressDetail = label;
|
||||||
|
});
|
||||||
|
var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
|
||||||
|
if (!migResult.AllSucceeded)
|
||||||
|
{
|
||||||
|
// L'install a réussi côté fichiers ; on garde la version installée mais on
|
||||||
|
// notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
|
||||||
|
// de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
|
||||||
|
_logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
|
||||||
|
MessageBox.Show(
|
||||||
|
Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
|
||||||
|
Strings.MsgBoxMigrationFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
else if (migResult.AppliedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
|
|||||||
@@ -401,6 +401,30 @@ public static class Strings
|
|||||||
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
|
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ==================== DB MIGRATIONS ====================
|
||||||
|
public static string StatusApplyingMigrations(string version) => T(
|
||||||
|
$"Application des migrations base de données v{version}…",
|
||||||
|
$"Applying database migrations for v{version}…",
|
||||||
|
$"正在应用 v{version} 的数据库迁移…",
|
||||||
|
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
|
||||||
|
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
|
||||||
|
);
|
||||||
|
public static string ProgressMigration(int done, int total, string filename) => T(
|
||||||
|
$"🗄 Migration {done}/{total} : {filename}",
|
||||||
|
$"🗄 Migration {done}/{total}: {filename}",
|
||||||
|
$"🗄 迁移 {done}/{total}:{filename}",
|
||||||
|
$"🗄 การโยกย้าย {done}/{total}: {filename}",
|
||||||
|
$"🗄 الترقية {done}/{total}: {filename}"
|
||||||
|
);
|
||||||
|
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
|
||||||
|
public static string MsgMigrationFailed(string detail) => T(
|
||||||
|
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
|
||||||
|
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
|
||||||
|
$"数据库迁移失败:\n\n{detail}\n\n版本已解压,但数据库未被修改(自动回滚)。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
|
||||||
|
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
|
||||||
|
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
|
||||||
|
);
|
||||||
|
|
||||||
public static string StatusPreparingDownload(string version) => T(
|
public static string StatusPreparingDownload(string version) => T(
|
||||||
$"Préparation du téléchargement v{version}…",
|
$"Préparation du téléchargement v{version}…",
|
||||||
$"Preparing download for v{version}…",
|
$"Preparing download for v{version}…",
|
||||||
|
|||||||
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MySqlConnector;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation MySQL/MariaDB du service de migration.
|
||||||
|
/// Workflow :
|
||||||
|
/// <code>
|
||||||
|
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
|
||||||
|
/// 2. SELECT déjà-appliquées
|
||||||
|
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
|
||||||
|
/// 4. Pour chaque non-appliquée :
|
||||||
|
/// START TRANSACTION
|
||||||
|
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
|
||||||
|
/// INSERT INTO _launcher_migrations
|
||||||
|
/// COMMIT
|
||||||
|
/// Si exception → ROLLBACK + abort + reporte l'erreur
|
||||||
|
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
||||||
|
{
|
||||||
|
private const string TrackingTable = "_launcher_migrations";
|
||||||
|
|
||||||
|
private readonly Func<DatabaseConfig> _configProvider;
|
||||||
|
private readonly Func<string?> _passwordProvider;
|
||||||
|
private readonly ILogger<DatabaseMigrationService> _logger;
|
||||||
|
|
||||||
|
public DatabaseMigrationService(
|
||||||
|
Func<DatabaseConfig> configProvider,
|
||||||
|
Func<string?> passwordProvider,
|
||||||
|
ILogger<DatabaseMigrationService> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_passwordProvider = passwordProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> TestConnectionAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var cmd = new MySqlCommand("SELECT 1", conn);
|
||||||
|
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MigrationResult> ApplyMigrationsAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<MigrationProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var migrationsDir = Path.Combine(installFolder, "_migrations");
|
||||||
|
if (!Directory.Exists(migrationsDir))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
|
||||||
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste les .sql, ignore les autres extensions (README, etc.)
|
||||||
|
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
|
||||||
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
var outcomes = new List<MigrationOutcome>();
|
||||||
|
|
||||||
|
for (int i = 0; i < files.Count; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var filepath = files[i];
|
||||||
|
var filename = Path.GetFileName(filepath);
|
||||||
|
progress?.Report(new MigrationProgress(i, files.Count, filename));
|
||||||
|
|
||||||
|
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||||
|
var checksum = ComputeSha256(sql);
|
||||||
|
|
||||||
|
if (applied.TryGetValue(filename, out var prevChecksum))
|
||||||
|
{
|
||||||
|
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Détection d'une modif après-publication : on skip mais on log fort.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
|
||||||
|
"Ignored to preserve historical truth — write a NEW migration file instead.",
|
||||||
|
filename, prevChecksum[..12], checksum[..12]);
|
||||||
|
}
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
|
||||||
|
await using var insertCmd = new MySqlCommand(
|
||||||
|
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
|
||||||
|
conn, tx);
|
||||||
|
insertCmd.Parameters.AddWithValue("@f", filename);
|
||||||
|
insertCmd.Parameters.AddWithValue("@c", checksum);
|
||||||
|
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
|
||||||
|
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||||
|
sw.Stop();
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
|
||||||
|
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
|
||||||
|
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
|
||||||
|
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
|
||||||
|
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var list = new List<AppliedMigration>();
|
||||||
|
await using var cmd = new MySqlCommand(
|
||||||
|
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
|
||||||
|
conn);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
list.Add(new AppliedMigration(
|
||||||
|
reader.GetString(0),
|
||||||
|
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetInt64(3)));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
|
||||||
|
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var pwd = _passwordProvider() ?? string.Empty;
|
||||||
|
var csb = new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = cfg.Host,
|
||||||
|
Port = cfg.Port,
|
||||||
|
UserID = cfg.User,
|
||||||
|
Password = pwd,
|
||||||
|
Database = cfg.Database,
|
||||||
|
// Désactive le pooling : on ouvre une connexion par opération courte,
|
||||||
|
// pas besoin de garder un pool ouvert sur la machine du user.
|
||||||
|
Pooling = false,
|
||||||
|
ConnectionTimeout = 10,
|
||||||
|
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
|
||||||
|
AllowUserVariables = true,
|
||||||
|
};
|
||||||
|
var conn = new MySqlConnection(csb.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
|
{
|
||||||
|
const string ddl = $"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {TrackingTable} (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at DATETIME NOT NULL,
|
||||||
|
checksum CHAR(64) NOT NULL,
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
await using var cmd = new MySqlCommand(ddl, conn);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
map[reader.GetString(0)] = reader.GetString(1);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
|
||||||
|
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
|
||||||
|
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
|
||||||
|
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
|
||||||
|
/// supporter <see cref="CancellationToken"/> proprement.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var stmt in SplitStatements(sql))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var trimmed = stmt.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
// Skip si juste un commentaire ou une ligne blanche
|
||||||
|
if (IsAllCommentsOrWhitespace(trimmed)) continue;
|
||||||
|
await using var cmd = new MySqlCommand(trimmed, conn, tx);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllCommentsOrWhitespace(string s)
|
||||||
|
{
|
||||||
|
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
|
||||||
|
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
|
||||||
|
return string.IsNullOrWhiteSpace(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
|
||||||
|
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
|
||||||
|
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
|
||||||
|
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> SplitStatements(string sql)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int i = 0;
|
||||||
|
char? quote = null;
|
||||||
|
bool inLineComment = false;
|
||||||
|
bool inBlockComment = false;
|
||||||
|
|
||||||
|
while (i < sql.Length)
|
||||||
|
{
|
||||||
|
char c = sql[i];
|
||||||
|
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||||
|
|
||||||
|
if (inLineComment)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '\n') inLineComment = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBlockComment)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote is not null)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
|
||||||
|
if (c == quote) quote = null;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hors-quote, hors-commentaire
|
||||||
|
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
|
||||||
|
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
|
||||||
|
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
|
||||||
|
|
||||||
|
if (c == ';')
|
||||||
|
{
|
||||||
|
yield return sb.ToString();
|
||||||
|
sb.Clear();
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.Append(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (sb.Length > 0)
|
||||||
|
{
|
||||||
|
var tail = sb.ToString().Trim();
|
||||||
|
if (tail.Length > 0) yield return tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256(string text)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(text);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
|
||||||
|
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
|
||||||
|
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
|
||||||
|
/// </summary>
|
||||||
|
public static class DatabasePasswordProtector
|
||||||
|
{
|
||||||
|
public static string? Protect(string? clearPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(clearPassword)) return null;
|
||||||
|
var data = Encoding.UTF8.GetBytes(clearPassword);
|
||||||
|
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||||
|
return Convert.ToBase64String(protectedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Unprotect(string? base64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(base64)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var protectedBytes = Convert.FromBase64String(base64);
|
||||||
|
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||||
|
return Encoding.UTF8.GetString(data);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Cache déplacé sur autre machine / autre user → non déchiffrable
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
|
||||||
|
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
|
||||||
|
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
|
||||||
|
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
|
||||||
|
/// </summary>
|
||||||
|
public interface IDatabaseMigrationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
|
||||||
|
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
|
||||||
|
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
|
||||||
|
/// </summary>
|
||||||
|
Task<MigrationResult> ApplyMigrationsAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<MigrationProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> TestConnectionAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
|
||||||
|
|
||||||
|
public sealed record MigrationResult(
|
||||||
|
IReadOnlyList<MigrationOutcome> Outcomes,
|
||||||
|
bool AllSucceeded,
|
||||||
|
string? FailureMessage)
|
||||||
|
{
|
||||||
|
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
|
||||||
|
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
|
||||||
|
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
|
||||||
|
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MigrationOutcome(
|
||||||
|
string Filename,
|
||||||
|
MigrationStatus Status,
|
||||||
|
long DurationMs,
|
||||||
|
string? Error);
|
||||||
|
|
||||||
|
public enum MigrationStatus { Skipped, Applied, Failed }
|
||||||
|
|
||||||
|
public sealed record AppliedMigration(
|
||||||
|
string Filename,
|
||||||
|
DateTime AppliedAt,
|
||||||
|
string Checksum,
|
||||||
|
long DurationMs);
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="Polly" Version="8.4.2" />
|
<PackageReference Include="Polly" Version="8.4.2" />
|
||||||
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
|
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.3.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -29,6 +29,45 @@ public sealed class LocalConfig
|
|||||||
public bool VerifyDownloadHash { get; set; } = true;
|
public bool VerifyDownloadHash { get; set; } = true;
|
||||||
|
|
||||||
public LicenseConfig License { get; set; } = new();
|
public LicenseConfig License { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
||||||
|
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
|
||||||
|
/// dans <c>_migrations/</c> au moment de l'install d'une nouvelle version.
|
||||||
|
/// </summary>
|
||||||
|
public DatabaseConfig Database { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paramètres de connexion à la base MySQL locale. Les valeurs par défaut
|
||||||
|
/// correspondent à un XAMPP standard fraîchement installé. Override possible
|
||||||
|
/// via Settings → Base de données.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatabaseConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "localhost";
|
||||||
|
public uint Port { get; set; } = 3306;
|
||||||
|
public string User { get; set; } = "root";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mot de passe MySQL stocké chiffré DPAPI (CurrentUser scope) en base64.
|
||||||
|
/// Vide = pas de password (config XAMPP par défaut). NE PAS écrire en clair.
|
||||||
|
/// </summary>
|
||||||
|
public string? EncryptedPassword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nom de la base de données qui contient les tables de PROSERVE.
|
||||||
|
/// Default "proserve" — change si ton install crée la DB sous un autre nom.
|
||||||
|
/// </summary>
|
||||||
|
public string Database { get; set; } = "proserve";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Si true, le launcher applique automatiquement les migrations SQL bundled
|
||||||
|
/// dans <c>_migrations/</c> de chaque ZIP, juste après l'extraction.
|
||||||
|
/// Sécurité : on n'applique JAMAIS sans cette opt-in (au cas où certains users
|
||||||
|
/// auraient une config DB exotique non gérée par le defaut).
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApplyMigrations { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class LicenseConfig
|
public sealed class LicenseConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user