< Summary

Information
Class: AspxLint.Server.ServerHost
Assembly: AspxLint.Server
File(s): D:\a\claude-aspx-lint\claude-aspx-lint\src\AspxLint.Server\ServerHost.cs
Line coverage
93%
Covered lines: 440
Uncovered lines: 32
Coverable lines: 472
Total lines: 695
Line coverage: 93.2%
Branch coverage
72%
Covered branches: 49
Total branches: 68
Branch coverage: 72%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Configure(...)100%11100%
Configure(...)100%11100%
MapRoutes(...)100%11100%
ResolveUrls(...)100%11100%
PrintBannerAndQr(...)100%22100%
Start(...)100%210%
ResolveProjectRoot(...)0%4260%
PrintBannerAndQr(...)100%210%
CreateSession(...)87.5%88100%
ResolveDashboard()33.33%13642.85%
ResolveLogDir(...)50%3250%
FindUpwards(...)66.66%6687.5%
ResolveLocalIPv4(...)86.84%393890.9%

File(s)

D:\a\claude-aspx-lint\claude-aspx-lint\src\AspxLint.Server\ServerHost.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.NetworkInformation;
 3using System.Net.Sockets;
 4using System.Reflection;
 5using System.Security.Cryptography;
 6using System.Text;
 7using AspxLint.Core;
 8using QRCoder;
 9
 10namespace AspxLint.Server;
 11
 12public sealed record ServerStartOptions(
 13    int Port = 5173,
 14    string? PreferredInterface = null,
 15    /// <summary>API key fixe a accepter. Si null, un token aleatoire est genere
 16    /// au demarrage (mode "personnel" / Desktop). Posez ASPXLINT_API_KEY pour
 17    /// un serveur heberge avec une cle stable.</summary>
 18    string? ApiKey = null,
 19    /// <summary>Si pose, scope tous les chemins manipules (scan/save/restore) a
 20    /// ce dossier racine. Hors-scope = 403. Indispensable en hosting public.</summary>
 21    string? AllowedRoot = null,
 22    /// <summary>Si true, /api/save et /api/restore renvoient 403. Mode lecture
 23    /// seule pour les deploiements ou seul le linting est autorise.</summary>
 24    bool ReadOnly = false
 25);
 26
 27public sealed record StartedServer(
 28    WebApplication App,
 29    string BuildId,
 30    string Token,
 31    string LocalUrl,
 32    string LanUrl,
 33    string LogFile,
 34    string DashboardSource,
 35    string? ProjectRoot
 36);
 37
 38public sealed record ScanRequest(string Path);
 39public sealed record SaveRequest(string Path, string Content);
 40public sealed record RestoreRequest(string Path);
 41public sealed record AnalyzeRequest(string Content, string Ext);
 42public sealed record FixRequest(string Content, string Ext, string RuleId);
 43public sealed record FixAllRequest(string Content, string Ext);
 44
 45public static class ServerHost
 46{
 47    /// <summary>
 48    /// Configure le builder (URLs, logging, services), cree la ServerSession
 49    /// et l'enregistre comme singleton DI. A appeler avant builder.Build().
 50    /// </summary>
 51    public static ServerSession Configure(WebApplicationBuilder builder, int port)
 2852        => Configure(builder, new ServerStartOptions(port));
 53
 54    public static ServerSession Configure(WebApplicationBuilder builder, ServerStartOptions opt)
 55    {
 2856        var session = CreateSession(opt);
 57
 2858        builder.WebHost.UseUrls($"http://0.0.0.0:{opt.Port}");
 2859        builder.Logging.ClearProviders();
 2860        builder.Services.AddSingleton(session);
 61
 62        // CORS : ouvert pour permettre les frontends multi-origines (Web hostee
 63        // ailleurs que le Server, extensions Chrome, etc.). On autorise les
 64        // credentials (cookie aspx_lint_token) avec un origin reflexif au lieu
 65        // de "*", parce que la spec CORS interdit "*" + AllowCredentials.
 2866        builder.Services.AddCors(options =>
 2867        {
 2868            options.AddDefaultPolicy(policy =>
 2869            {
 2870                policy.SetIsOriginAllowed(_ => true)
 2871                      .AllowCredentials()
 2872                      .AllowAnyMethod()
 2873                      .AllowAnyHeader();
 2874            });
 2875        });
 76
 77        // OpenAPI / Swagger : auto-decouverte des minimal API endpoints.
 78        // Specifie "Bearer" pour que les futurs frontends puissent tester depuis
 79        // le UI Swagger.
 2880        builder.Services.AddEndpointsApiExplorer();
 2881        builder.Services.AddSwaggerGen(options =>
 2882        {
 2883            options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
 2884            {
 2885                Title = "aspx-lint API",
 2886                Version = "v1",
 2887                Description = "Linter et auto-fixer pour ASP.NET Web Forms (.aspx, .ascx, .master).",
 2888                License = new Microsoft.OpenApi.Models.OpenApiLicense { Name = "MIT" }
 2889            });
 2890            options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
 2891            {
 2892                Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
 2893                Scheme = "bearer",
 2894                Description = "Token affiche en console au demarrage du serveur."
 2895            });
 2896            options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
 2897            {
 2898                {
 2899                    new Microsoft.OpenApi.Models.OpenApiSecurityScheme
 28100                    {
 28101                        Reference = new Microsoft.OpenApi.Models.OpenApiReference
 28102                        {
 28103                            Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
 28104                            Id = "Bearer"
 28105                        }
 28106                    },
 28107                    Array.Empty<string>()
 28108                }
 28109            });
 28110        });
 111
 28112        session.Log("INFO", $"server starting, dashboard={session.DashboardSource}");
 28113        return session;
 114    }
 115
 116    /// <summary>
 117    /// Branche les middlewares + routes sur l'application construite.
 118    /// A appeler apres builder.Build() et avant app.Run().
 119    /// </summary>
 120    public static void MapRoutes(WebApplication app)
 121    {
 28122        var session = app.Services.GetRequiredService<ServerSession>();
 123
 28124        app.UseCors();
 125
 126        // Swagger UI a /swagger, spec OpenAPI a /swagger/v1/swagger.json.
 127        // Sans auth pour faciliter l'integration externe (les frontends peuvent
 128        // pomper le contrat sans avoir le token).
 28129        app.UseSwagger();
 28130        app.UseSwaggerUI(options =>
 28131        {
 28132            options.SwaggerEndpoint("/swagger/v1/swagger.json", "aspx-lint v1");
 28133            options.RoutePrefix = "swagger";
 28134            options.DocumentTitle = "aspx-lint API";
 28135        });
 136
 137        // Auth : token accepte via 3 canaux, dans cet ordre :
 138        //   1. ?token=...                       (URL, premiere visite navigateur)
 139        //   2. Cookie `aspx_lint_token`         (collant pour navigateur)
 140        //   3. Header `Authorization: Bearer X` (extensions, CI, frontends remote)
 28141        app.Use(async (ctx, next) =>
 28142        {
 28143            if (ctx.Request.Path.StartsWithSegments("/healthz") ||
 28144                ctx.Request.Path.StartsWithSegments("/swagger"))
 28145            { await next(); return; }
 28146
 28147            var supplied = ctx.Request.Query["token"].ToString();
 28148            if (string.IsNullOrEmpty(supplied))
 28149                supplied = ctx.Request.Cookies["aspx_lint_token"] ?? "";
 28150            if (string.IsNullOrEmpty(supplied))
 28151            {
 28152                var auth = ctx.Request.Headers.Authorization.ToString();
 28153                if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
 28154                    supplied = auth["Bearer ".Length..];
 28155            }
 28156
 28157            var ok = supplied.Length == session.Token.Length &&
 28158                     CryptographicOperations.FixedTimeEquals(
 28159                         Encoding.ASCII.GetBytes(supplied),
 28160                         Encoding.ASCII.GetBytes(session.Token));
 28161
 28162            if (!ok)
 28163            {
 28164                session.Log("WARN", $"auth refused from {ctx.Connection.RemoteIpAddress} path={ctx.Request.Path}");
 28165                ctx.Response.StatusCode = 401;
 28166                await ctx.Response.WriteAsync("Token requis ou invalide.");
 28167                return;
 28168            }
 28169
 28170            ctx.Response.Cookies.Append("aspx_lint_token", session.Token, new CookieOptions
 28171            {
 28172                HttpOnly = true,
 28173                SameSite = SameSiteMode.Lax,
 28174                MaxAge = TimeSpan.FromHours(12)
 28175            });
 28176            await next();
 28177        });
 178
 28179        app.MapGet("/healthz", () => Results.Ok(new { ok = true, buildId = session.BuildId }));
 180
 28181        app.MapGet("/", async (HttpContext ctx) =>
 28182        {
 28183            session.Log("INFO", $"dashboard served to {ctx.Connection.RemoteIpAddress}");
 28184            var html = await session.LoadDashboardHtml();
 28185            ctx.Response.ContentType = "text/html; charset=utf-8";
 28186            await ctx.Response.WriteAsync(html);
 28187        });
 188
 28189        app.MapGet("/api/rules", () => Results.Ok(
 28190            RuleRegistry.All.Select(r => new
 28191            {
 28192                id = r.Id,
 28193                name = r.Name,
 28194                severity = r.Severity.ToString().ToLowerInvariant(),
 28195                desc = r.Description,
 28196                hasFix = r.HasFix
 28197            })
 28198        ));
 199
 200        // ------------------------------------------------------------------
 201        // Endpoints inline (path-less). Le client envoie le contenu brut, le
 202        // serveur applique le moteur Core et renvoie issues / corrections,
 203        // sans toucher au disque. Utilises par la dashboard pour analyser
 204        // les fichiers droppes / colles, et par les frontends futurs (Chrome,
 205        // VS extension, etc.) qui n'ont pas de chemin a fournir.
 206        // ------------------------------------------------------------------
 207
 28208        app.MapPost("/api/analyze", (AnalyzeRequest req) =>
 28209        {
 28210            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 28211            var lines = (req.Content ?? "").Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
 28212            var issues = RuleRegistry.All
 28213                .SelectMany(r => r.Detect(req.Content ?? "", lines, ctx))
 28214                .Select(i => new
 28215                {
 28216                    ruleId = i.RuleId,
 28217                    ruleName = i.RuleName,
 28218                    severity = i.Severity.ToString().ToLowerInvariant(),
 28219                    line = i.Line,
 28220                    col = i.Col,
 28221                    snippet = i.Snippet,
 28222                    hint = i.Hint
 28223                })
 28224                .ToList();
 28225            return Results.Ok(new { issues });
 28226        });
 227
 28228        app.MapPost("/api/fix", (FixRequest req) =>
 28229        {
 28230            var rule = RuleRegistry.All.FirstOrDefault(r =>
 28231                r.Id.Equals(req.RuleId, StringComparison.OrdinalIgnoreCase));
 28232            if (rule is null)
 28233                return Results.NotFound(new { error = $"Regle inconnue : {req.RuleId}" });
 28234            if (!rule.HasFix)
 28235                return Results.BadRequest(new { error = $"Regle {req.RuleId} n'a pas d'auto-fix" });
 28236
 28237            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 28238            var content = req.Content ?? "";
 28239            var beforeCount = CountRuleIssues(rule, content, ctx);
 28240            var fixedContent = rule.Fix(content, ctx) ?? content;
 28241            var afterCount = CountRuleIssues(rule, fixedContent, ctx);
 28242            return Results.Ok(new
 28243            {
 28244                content = fixedContent,
 28245                applied = Math.Max(0, beforeCount - afterCount)
 28246            });
 28247        });
 248
 28249        app.MapPost("/api/fix-all", (FixAllRequest req) =>
 28250        {
 28251            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 28252            var content = req.Content ?? "";
 28253            var history = new List<object>();
 28254            var fixableRules = RuleRegistry.All.Where(r => r.HasFix).ToList();
 28255
 28256            // Matche le comportement JS d'origine : jusqu'a 5 passes pour
 28257            // convergence (un fix peut creer un nouveau probleme detectable).
 28258            for (int pass = 0; pass < 5; pass++)
 28259            {
 28260                var before = content;
 28261                foreach (var rule in fixableRules)
 28262                {
 28263                    var beforeCount = CountRuleIssues(rule, content, ctx);
 28264                    if (beforeCount == 0) continue;
 28265
 28266                    var attempted = rule.Fix(content, ctx);
 28267                    if (attempted is null || attempted == content) continue;
 28268
 28269                    var afterCount = CountRuleIssues(rule, attempted, ctx);
 28270                    var resolved = beforeCount - afterCount;
 28271                    content = attempted;
 28272                    if (resolved > 0)
 28273                        history.Add(new { ruleId = rule.Id, count = resolved });
 28274                }
 28275                if (content == before) break;
 28276            }
 28277
 28278            return Results.Ok(new { content, history });
 28279        });
 280
 281        static int CountRuleIssues(IRule rule, string content, RuleContext ctx)
 282        {
 283            var lines = content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
 284            return rule.Detect(content, lines, ctx).Count();
 285        }
 286
 28287        app.MapPost("/api/scan", (ScanRequest req) =>
 28288        {
 28289            session.Log("INFO", $"scan requested path={req.Path}");
 28290
 28291            // Scoping : si AllowedRoot pose, le chemin demande doit etre dessous.
 28292            string requestedFull;
 28293            try { requestedFull = Path.GetFullPath(req.Path); }
 28294            catch (Exception ex)
 28295            {
 28296                session.Log("WARN", $"scan rejected (bad path): {ex.Message}");
 28297                return Results.BadRequest(new { error = "Chemin invalide." });
 28298            }
 28299            if (!session.IsUnderAllowedRoot(requestedFull))
 28300            {
 28301                session.Log("WARN", $"scan refused (out of allowed root) path={requestedFull}");
 28302                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28303            }
 28304
 28305            try
 28306            {
 28307                var scanned = ProjectScanner.Scan(req.Path, RuleRegistry.All).ToList();
 28308                var files = scanned.Select(f => new
 28309                {
 28310                    path = f.AbsolutePath,
 28311                    relativePath = f.RelativePath,
 28312                    lineCount = f.LineCount,
 28313                    content = f.Content,
 28314                    issues = f.Issues.Select(i => new
 28315                    {
 28316                        ruleId = i.RuleId,
 28317                        ruleName = i.RuleName,
 28318                        severity = i.Severity.ToString().ToLowerInvariant(),
 28319                        line = i.Line,
 28320                        col = i.Col,
 28321                        snippet = i.Snippet,
 28322                        hint = i.Hint
 28323                    }).ToList()
 28324                }).ToList();
 28325
 28326                var totalIssues = files.Sum(f => f.issues.Count);
 28327
 28328                foreach (var f in scanned)
 28329                    session.AddWritable(Path.GetFullPath(f.AbsolutePath));
 28330
 28331                session.Log("INFO", $"scan done path={req.Path} files={files.Count} issues={totalIssues}");
 28332
 28333                return Results.Ok(new
 28334                {
 28335                    scannedAt = DateTime.UtcNow,
 28336                    buildId = session.BuildId,
 28337                    path = req.Path,
 28338                    fileCount = files.Count,
 28339                    issueCount = totalIssues,
 28340                    files
 28341                });
 28342            }
 28343            catch (DirectoryNotFoundException ex)
 28344            {
 28345                session.Log("WARN", $"scan failed: {ex.Message}");
 28346                return Results.NotFound(new { error = ex.Message });
 28347            }
 28348            catch (Exception ex)
 28349            {
 28350                session.Log("ERROR", $"scan crashed: {ex}");
 28351                return Results.Problem(ex.Message);
 28352            }
 28353        });
 354
 28355        app.MapPost("/api/save", (SaveRequest req) =>
 28356        {
 28357            if (session.ReadOnly)
 28358            {
 28359                session.Log("WARN", "save refused (read-only mode)");
 28360                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28361            }
 28362
 28363            string full;
 28364            try { full = Path.GetFullPath(req.Path); }
 28365            catch (Exception ex)
 28366            {
 28367                session.Log("WARN", $"save rejected (bad path): {ex.Message}");
 28368                return Results.BadRequest(new { error = "Chemin invalide." });
 28369            }
 28370
 28371            if (!session.IsUnderAllowedRoot(full))
 28372            {
 28373                session.Log("WARN", $"save refused (out of allowed root) path={full}");
 28374                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28375            }
 28376            if (!session.IsWritable(full))
 28377            {
 28378                session.Log("WARN", $"save refused (not scanned) path={full}");
 28379                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28380            }
 28381
 28382            try
 28383            {
 28384                var backupPath = full + ".bak";
 28385                var backedUp = false;
 28386                if (!File.Exists(backupPath) && File.Exists(full))
 28387                {
 28388                    File.Copy(full, backupPath);
 28389                    backedUp = true;
 28390                }
 28391
 28392                var bytes = Encoding.UTF8.GetBytes(req.Content);
 28393                File.WriteAllBytes(full, bytes);
 28394
 28395                session.Log("INFO", $"saved path={full} bytes={bytes.Length} backup={backedUp}");
 28396                return Results.Ok(new { ok = true, path = full, bytes = bytes.Length, backedUp });
 28397            }
 28398            catch (Exception ex)
 28399            {
 28400                session.Log("ERROR", $"save crashed: {ex}");
 28401                return Results.Problem(ex.Message);
 28402            }
 28403        });
 404
 28405        app.MapPost("/api/restore", (RestoreRequest req) =>
 28406        {
 28407            if (session.ReadOnly)
 28408            {
 28409                session.Log("WARN", "restore refused (read-only mode)");
 28410                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28411            }
 28412
 28413            string full;
 28414            try { full = Path.GetFullPath(req.Path); }
 28415            catch (Exception ex)
 28416            {
 28417                session.Log("WARN", $"restore rejected (bad path): {ex.Message}");
 28418                return Results.BadRequest(new { error = "Chemin invalide." });
 28419            }
 28420
 28421            if (!session.IsUnderAllowedRoot(full))
 28422            {
 28423                session.Log("WARN", $"restore refused (out of allowed root) path={full}");
 28424                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28425            }
 28426            if (!session.IsWritable(full))
 28427            {
 28428                session.Log("WARN", $"restore refused (not scanned) path={full}");
 28429                return Results.StatusCode(StatusCodes.Status403Forbidden);
 28430            }
 28431
 28432            var backupPath = full + ".bak";
 28433            if (!File.Exists(backupPath))
 28434            {
 28435                session.Log("INFO", $"restore failed (no .bak) path={full}");
 28436                return Results.NotFound(new { error = "Aucun .bak pour ce fichier (jamais sauvegarde via /api/save)." })
 28437            }
 28438
 28439            try
 28440            {
 28441                var bytes = File.ReadAllBytes(backupPath);
 28442                File.WriteAllBytes(full, bytes);
 28443
 28444                var hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
 28445                var decoded = hasBom
 28446                    ? "" + Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3)
 28447                    : Encoding.UTF8.GetString(bytes);
 28448
 28449                session.Log("INFO", $"restored from .bak path={full} bytes={bytes.Length}");
 28450                return Results.Ok(new { ok = true, path = full, bytes = bytes.Length, content = decoded });
 28451            }
 28452            catch (Exception ex)
 28453            {
 28454                session.Log("ERROR", $"restore crashed: {ex}");
 28455                return Results.Problem(ex.Message);
 28456            }
 28457        });
 28458    }
 459
 460    /// <summary>
 461    /// Calcule l'URL locale et l'URL LAN pour le banner / QR code.
 462    /// </summary>
 463    public static (string LocalUrl, string LanUrl) ResolveUrls(int port, string token, string? preferredInterface = null
 464    {
 28465        var ip = ResolveLocalIPv4(preferredInterface);
 28466        return ($"http://localhost:{port}/?token={token}",
 28467                $"http://{ip}:{port}/?token={token}");
 468    }
 469
 470    public static void PrintBannerAndQr(string buildId, string localUrl, string lanUrl, string logFile)
 471    {
 28472        Console.WriteLine();
 28473        Console.WriteLine($"  ASPX-LINT  build {buildId}");
 28474        Console.WriteLine($"  ----------------------------------------------------");
 28475        Console.WriteLine($"  Local : {localUrl}");
 28476        Console.WriteLine($"  LAN   : {lanUrl}");
 28477        Console.WriteLine($"  Logs  : {logFile}");
 28478        Console.WriteLine();
 28479        Console.WriteLine("  Scan ce QR depuis ton telephone (meme Wi-Fi) :");
 28480        Console.WriteLine();
 481
 28482        using var qrGen = new QRCodeGenerator();
 28483        using var data = qrGen.CreateQrCode(lanUrl, QRCodeGenerator.ECCLevel.M);
 28484        var ascii = new AsciiQRCode(data).GetGraphic(1, drawQuietZones: true);
 2480485        foreach (var line in ascii.Split('\n'))
 1212486            Console.WriteLine("  " + line.TrimEnd('\r'));
 28487        Console.WriteLine();
 56488    }
 489
 490    /// <summary>
 491    /// Back-compat pour AspxLint.Desktop : configure, build, MapRoutes,
 492    /// StartAsync synchrone, retourne StartedServer pret a l'emploi.
 493    /// </summary>
 494    public static StartedServer Start(ServerStartOptions opt)
 495    {
 0496        var builder = WebApplication.CreateBuilder();
 0497        var session = Configure(builder, opt);
 0498        var app = builder.Build();
 0499        MapRoutes(app);
 0500        app.StartAsync().GetAwaiter().GetResult();
 501
 0502        var (localUrl, lanUrl) = ResolveUrls(opt.Port, session.Token, opt.PreferredInterface);
 0503        session.Log("INFO", $"server listening on :{opt.Port}, lanUrl={lanUrl}");
 504
 0505        return new StartedServer(
 0506            app, session.BuildId, session.Token,
 0507            localUrl, lanUrl, session.LogFile, session.DashboardSource,
 0508            ResolveProjectRoot(session.DashboardSource));
 509    }
 510
 511    /// <summary>
 512    /// Si DashboardSource est un chemin disque ("disk:..."), renvoie le
 513    /// dossier parent (le project root). Sinon (mode embedded), null.
 514    /// </summary>
 515    private static string? ResolveProjectRoot(string dashboardSource)
 516    {
 517        const string prefix = "disk:";
 0518        if (!dashboardSource.StartsWith(prefix)) return null;
 0519        var path = dashboardSource[prefix.Length..];
 520        // Le HTML est a src/AspxLint.Web/index.html â†’ on remonte 2 dossiers
 521        // pour avoir la racine du repo. Heuristique simple suffisante en dev.
 0522        var dir = Path.GetDirectoryName(path);
 0523        if (dir != null) dir = Path.GetDirectoryName(dir);   // src/AspxLint.Web
 0524        if (dir != null) dir = Path.GetDirectoryName(dir);   // src
 0525        return dir;
 526    }
 527
 528    public static void PrintBannerAndQr(StartedServer s) =>
 0529        PrintBannerAndQr(s.BuildId, s.LocalUrl, s.LanUrl, s.LogFile);
 530
 531    private static ServerSession CreateSession(ServerStartOptions opt)
 532    {
 28533        var buildId = $"b-{DateTime.UtcNow:yyyyMMdd-HHmmss}-" +
 28534                      Convert.ToHexString(RandomNumberGenerator.GetBytes(2)).ToLowerInvariant();
 535
 536        // Token : prefere l'API key explicite, puis l'env var, puis aleatoire.
 28537        var token = opt.ApiKey
 28538                    ?? Environment.GetEnvironmentVariable("ASPXLINT_API_KEY")
 28539                    ?? Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
 540
 28541        var allowedRoot = opt.AllowedRoot
 28542                          ?? Environment.GetEnvironmentVariable("ASPXLINT_ALLOWED_ROOT");
 28543        var readOnly = opt.ReadOnly
 28544                       || string.Equals(Environment.GetEnvironmentVariable("ASPXLINT_READ_ONLY"),
 28545                                        "true", StringComparison.OrdinalIgnoreCase);
 546
 28547        var (loadDashboard, dashboardSource, projectRoot) = ResolveDashboard();
 28548        var logsDir = ResolveLogDir(projectRoot);
 28549        Directory.CreateDirectory(logsDir);
 28550        var logFile = Path.Combine(logsDir, $"{buildId}.log");
 551
 28552        return new ServerSession
 28553        {
 28554            BuildId = buildId,
 28555            Token = token,
 28556            LogFile = logFile,
 28557            DashboardSource = dashboardSource,
 28558            LoadDashboardHtml = loadDashboard,
 28559            AllowedRoot = allowedRoot,
 28560            ReadOnly = readOnly
 28561        };
 562    }
 563
 564    /// <summary>
 565    /// Resout la source de la dashboard HTML, par ordre de preference :
 566    ///   1. src/AspxLint.Web/index.html sur disque (mode dev, hot-reload)
 567    ///   2. AspxLint.Web/index.html (si on tourne depuis src/)
 568    ///   3. Ressource embarquee "AspxLint.Web.index.html" dans le .dll
 569    ///      (mode .exe self-contained, conteneur Docker, etc.)
 570    /// </summary>
 571    private static (Func<Task<string>> Load, string Source, string? ProjectRoot) ResolveDashboard()
 572    {
 573        // Disk d'abord pour le hot-reload en dev.
 28574        var diskCandidate = FindUpwards(Path.Combine("src", "AspxLint.Web", "index.html"))
 28575                         ?? FindUpwards(Path.Combine("AspxLint.Web", "index.html"));
 28576        if (diskCandidate != null)
 577        {
 28578            var path = diskCandidate;
 28579            var root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(path)));
 28580            return (() => File.ReadAllTextAsync(path), $"disk:{path}", root);
 581        }
 582
 583        // Fallback : ressource embarquee.
 0584        var asm = typeof(ServerHost).Assembly;
 585        const string resourceName = "AspxLint.Web.index.html";
 0586        using (var probe = asm.GetManifestResourceStream(resourceName))
 587        {
 0588            if (probe == null)
 589            {
 0590                var available = string.Join(", ", asm.GetManifestResourceNames());
 0591                throw new FileNotFoundException(
 0592                    $"Dashboard HTML introuvable. Ressources embarquees disponibles : [{available}]");
 593            }
 0594        }
 595
 0596        return (LoadEmbedded, $"embedded:{resourceName}", null);
 597
 598        static async Task<string> LoadEmbedded()
 599        {
 600            var asm = typeof(ServerHost).Assembly;
 601            using var stream = asm.GetManifestResourceStream("AspxLint.Web.index.html")!;
 602            using var reader = new StreamReader(stream);
 603            return await reader.ReadToEndAsync();
 604        }
 605    }
 606
 607    /// <summary>
 608    /// Logs : a cote du repo en dev (logs/), dans %LOCALAPPDATA%\AspxLint\logs
 609    /// quand on est en mode embedded (pas de repo a cote du .exe).
 610    /// </summary>
 611    private static string ResolveLogDir(string? projectRoot)
 612    {
 28613        if (projectRoot != null)
 28614            return Path.Combine(projectRoot, "logs");
 615
 0616        var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
 0617        return Path.Combine(appData, "AspxLint", "logs");
 618    }
 619
 620    static string? FindUpwards(string relativePath)
 621    {
 28622        var dir = AppContext.BaseDirectory;
 392623        for (int i = 0; i < 10; i++)
 624        {
 196625            var candidate = Path.Combine(dir, relativePath);
 224626            if (File.Exists(candidate)) return candidate;
 168627            var parent = Directory.GetParent(dir);
 168628            if (parent == null) return null;
 168629            dir = parent.FullName;
 630        }
 0631        return null;
 632    }
 633
 634    /// <summary>
 635    /// Choisit l'interface IPv4 la plus probable pour servir le LAN domestique.
 636    /// Penalite forte sur switches virtuels (Hyper-V, WSL, Docker, VirtualBox),
 637    /// bonus sur ranges RFC1918 192.168.* / 10.*, bonus sur Wi-Fi et Ethernet physiques.
 638    /// L'option --interface (substring case-insensitive) ecrase tout.
 639    /// </summary>
 640    public static string ResolveLocalIPv4(string? preferredInterface = null)
 641    {
 642        try
 643        {
 28644            var candidates = new List<(int score, string ip, string name)>();
 28645            string[] virtualMarkers =
 28646            {
 28647                "vethernet", "wsl", "virtualbox", "vmware",
 28648                "docker", "hyper-v", "tap", "tun", "loopback"
 28649            };
 650
 1064651            foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
 652            {
 504653                if (ni.OperationalStatus != OperationalStatus.Up) continue;
 336654                if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
 655
 840656                foreach (var addr in ni.GetIPProperties().UnicastAddresses)
 657                {
 112658                    if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
 56659                    if (IPAddress.IsLoopback(addr.Address)) continue;
 660
 56661                    var ip = addr.Address.ToString();
 56662                    var name = (ni.Name + " " + ni.Description).ToLowerInvariant();
 56663                    int score = 0;
 664
 56665                    if (!string.IsNullOrEmpty(preferredInterface) &&
 56666                        name.Contains(preferredInterface.ToLowerInvariant()))
 0667                        score += 10000;
 668
 112669                    if (virtualMarkers.Any(m => name.Contains(m))) score -= 500;
 670
 56671                    if (ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211) score += 100;
 112672                    else if (ni.NetworkInterfaceType == NetworkInterfaceType.Ethernet) score += 80;
 673
 56674                    if (ip.StartsWith("192.168.")) score += 200;
 84675                    else if (ip.StartsWith("10.")) score += 150;
 28676                    else if (ip.StartsWith("172."))
 677                    {
 28678                        var parts = ip.Split('.');
 28679                        if (parts.Length > 1 && int.TryParse(parts[1], out var b) && b >= 16 && b <= 31)
 28680                            score += 50;
 681                    }
 682
 56683                    candidates.Add((score, ip, ni.Name));
 684                }
 685            }
 686
 28687            if (candidates.Count == 0) return "127.0.0.1";
 28688            return candidates.OrderByDescending(c => c.score).First().ip;
 689        }
 0690        catch
 691        {
 0692            return "127.0.0.1";
 693        }
 28694    }
 695}