refactor: resolve launcher paths and isolate batch automation
This commit is contained in:
@@ -28,7 +28,8 @@ public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(strin
|
||||
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
||||
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
||||
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(string validatedPackageRoot, string entryAssembly, string fixtureOrthoDwg); }
|
||||
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string AutomationScriptPath);
|
||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Launcher.Next.Core;
|
||||
|
||||
public sealed record LauncherPathVariables(IReadOnlyDictionary<string, string?> Values)
|
||||
{
|
||||
public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public sealed record ResolvedLauncherPaths(IReadOnlyDictionary<string, string?> Values)
|
||||
{
|
||||
public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
|
||||
public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null;
|
||||
public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot));
|
||||
public string AcadExecutable => Require(nameof(AcadExecutable));
|
||||
public string? AutomationScriptPath => Get(nameof(AutomationScriptPath));
|
||||
public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg));
|
||||
}
|
||||
|
||||
public interface IPathResolver
|
||||
{
|
||||
ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null);
|
||||
string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null);
|
||||
}
|
||||
|
||||
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
|
||||
{
|
||||
private static readonly Regex Token = new(@"\$\{(?<name>[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
|
||||
private readonly IReadOnlyDictionary<string, string?> environment = environment ?? Environment.GetEnvironmentVariables()
|
||||
.Cast<System.Collections.DictionaryEntry>().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null)
|
||||
{
|
||||
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in variables.Values.Keys) resolved[name] = ResolveVariable(name, variables, resolved, []);
|
||||
foreach (var name in required ?? [])
|
||||
if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
|
||||
return new(resolved);
|
||||
}
|
||||
|
||||
public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null)
|
||||
{
|
||||
var resolved = Expand(value, variables, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), []);
|
||||
if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved))
|
||||
throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED");
|
||||
var full = Path.GetFullPath(resolved);
|
||||
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var corporate = Resolve(variables, ["CorporateDfsRoot"]).Require("CorporateDfsRoot");
|
||||
full = Path.Combine(corporate, full[3..]);
|
||||
}
|
||||
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED");
|
||||
if (!string.IsNullOrWhiteSpace(constrainedRoot))
|
||||
{
|
||||
var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables));
|
||||
if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT");
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack)
|
||||
{
|
||||
if (resolved.TryGetValue(name, out var cached)) return cached;
|
||||
if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}");
|
||||
try
|
||||
{
|
||||
var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null);
|
||||
if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}");
|
||||
var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
|
||||
expanded = expanded.Replace('/', Path.DirectorySeparatorChar);
|
||||
resolved[name] = expanded;
|
||||
return expanded;
|
||||
}
|
||||
finally { stack.Remove(name); }
|
||||
}
|
||||
|
||||
private string Expand(string value, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack) =>
|
||||
Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
|
||||
}
|
||||
|
||||
public static class LauncherPathVariableNames
|
||||
{
|
||||
public const string CorporateDfsRoot = "CorporateDfsRoot";
|
||||
public const string CorporateLauncherRoot = "CorporateLauncherRoot";
|
||||
public const string CorporateCatalogRoot = "CorporateCatalogRoot";
|
||||
public const string CorporatePackagesRoot = "CorporatePackagesRoot";
|
||||
public const string CorporateProfilesRoot = "CorporateProfilesRoot";
|
||||
public const string CorporatePalettesRoot = "CorporatePalettesRoot";
|
||||
public const string CorporateFontsRoot = "CorporateFontsRoot";
|
||||
public const string CorporateTemplatesRoot = "CorporateTemplatesRoot";
|
||||
public const string CorporateScriptsRoot = "CorporateScriptsRoot";
|
||||
public const string ProjectName = "ProjectName";
|
||||
public const string DisciplineName = "DisciplineName";
|
||||
public const string ProjectRoot = "ProjectRoot";
|
||||
public const string DisciplineRoot = "DisciplineRoot";
|
||||
public const string PlantProjectFile = "PlantProjectFile";
|
||||
public const string ProjectRosterPath = "ProjectRosterPath";
|
||||
public const string ProjectApplicationsRoot = "ProjectApplicationsRoot";
|
||||
public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot";
|
||||
public const string LocalLauncherRoot = "LocalLauncherRoot";
|
||||
public const string LocalPackageCache = "LocalPackageCache";
|
||||
public const string LocalActiveRoot = "LocalActiveRoot";
|
||||
public const string LocalSessionRoot = "LocalSessionRoot";
|
||||
public const string LocalEvidenceRoot = "LocalEvidenceRoot";
|
||||
public const string LocalLogsRoot = "LocalLogsRoot";
|
||||
public const string LocalRollbackRoot = "LocalRollbackRoot";
|
||||
public const string AcadExecutable = "AcadExecutable";
|
||||
public const string PRecEntryAssembly = "PRecEntryAssembly";
|
||||
public const string FixtureOrthoDwg = "FixtureOrthoDwg";
|
||||
public const string AutomationScriptPath = "AutomationScriptPath";
|
||||
public const string AutomationConfigPath = "AutomationConfigPath";
|
||||
public const string AutomationEvidencePath = "AutomationEvidencePath";
|
||||
public const string AutomationPermitPath = "AutomationPermitPath";
|
||||
}
|
||||
|
||||
public static class LauncherConfigurationLayers
|
||||
{
|
||||
private static readonly HashSet<string> Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot];
|
||||
|
||||
public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var merged = Parse(corporateDefaultsJson);
|
||||
Merge(merged, Parse(machineJson), false);
|
||||
Merge(merged, Parse(sessionJson), false);
|
||||
return PilotConfigurationParser.Parse(merged.ToJsonString());
|
||||
}
|
||||
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
|
||||
}
|
||||
|
||||
public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath)
|
||||
{
|
||||
var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null;
|
||||
var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null;
|
||||
var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null;
|
||||
return Load(defaults, machine, session);
|
||||
}
|
||||
|
||||
private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? [];
|
||||
private static void Merge(JsonObject target, JsonObject overlay, bool roster)
|
||||
{
|
||||
foreach (var entry in overlay)
|
||||
{
|
||||
if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths)
|
||||
{
|
||||
var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths;
|
||||
foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone();
|
||||
}
|
||||
else target[entry.Key] = entry.Value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public sealed record PilotLocalConfiguration
|
||||
public string? ApplyPermitPath { get; init; }
|
||||
public string? AutomationMode { get; init; }
|
||||
public string? AutomationConfigurationPath { get; init; }
|
||||
public string? FixtureMarkerPath { get; init; }
|
||||
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||
// persisted completed automation evidence. It never represents graceful exit.
|
||||
public bool ForceAfterEvidence { get; init; }
|
||||
@@ -27,8 +28,12 @@ public sealed record PilotLocalConfiguration
|
||||
public string? SelectedProductId { get; init; }
|
||||
public string? SelectedProfileName { get; init; }
|
||||
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
|
||||
public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production;
|
||||
public bool ProductionDeploy { get; init; }
|
||||
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
|
||||
public static PilotLocalConfiguration SafeDefault { get; } = new();
|
||||
}
|
||||
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||
{ public bool IsValid => Errors.Count == 0; }
|
||||
public static class PilotConfigurationParser
|
||||
@@ -67,14 +72,42 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
|
||||
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||
{
|
||||
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
|
||||
{ var v = new List<string>(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED"); if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED"); if (c.Configuration.AutomationMode is not null && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("AUTOMATION_CONFIGURATION_INVALID"); if (c.Configuration.ForceAfterEvidence && c.Configuration.AutomationMode is null) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (c.HashesValid) { } else v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); }
|
||||
{
|
||||
var v = new List<string>();
|
||||
if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE");
|
||||
if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED");
|
||||
if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED");
|
||||
if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED");
|
||||
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED");
|
||||
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
|
||||
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
|
||||
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
|
||||
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
|
||||
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
|
||||
if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v);
|
||||
}
|
||||
}
|
||||
public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> EnvironmentVariables);
|
||||
public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision);
|
||||
public sealed class AcadArgumentBuilder
|
||||
{
|
||||
public IReadOnlyList<string> Build(AcadLaunchMode mode, string? automationScriptPath)
|
||||
{
|
||||
var arguments = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" };
|
||||
if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED");
|
||||
arguments.Add("/b"); arguments.Add(automationScriptPath);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN");
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
public static class PilotPlanFactory
|
||||
{
|
||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision)
|
||||
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, profile.Name, entry, scriptHash); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
|
||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production)
|
||||
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
|
||||
}
|
||||
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
|
||||
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
|
||||
|
||||
Reference in New Issue
Block a user