From 18846bb3295c54cef65f667e67544a7c62c82d91 Mon Sep 17 00:00:00 2001 From: "LucHeart (Zoe Agent)" Date: Tue, 17 Mar 2026 01:59:46 +0000 Subject: [PATCH 1/3] feat: min/max settings, multiple shockers per share code, and ps.pishock.com API endpoints - Add ShareCodeMapping class with MinIntensity, MaxIntensity, MinDuration, MaxDuration fields - Support multiple shocker IDs per share code mapping (List) - Update DoWebApiController to use per-mapping limits and send controls to all mapped shockers - Add PsWebApiController handling GetUserDevices, GetShareCodesByOwner, GetShockersByShareIds - Add ps.pishock.com as SAN in server certificate - Add ps.pishock.com to hosts file redirect - Update Blazor UI with multi-select shockers and min/max configuration fields --- .../Certificates/CertificateManager.cs | 1 + Interception/HostsFile/HostsFileManager.cs | 21 ++- Interception/InterceptionConfig.cs | 6 +- Interception/InterceptionService.cs | 4 +- Interception/Server/DoWebApiController.cs | 44 ++--- Interception/Server/PsWebApiController.cs | 154 ++++++++++++++++++ Interception/Server/ShareCodeMapping.cs | 10 ++ Interception/Ui/InterceptionPage.razor | 146 ++++++++++++++--- 8 files changed, 329 insertions(+), 57 deletions(-) create mode 100644 Interception/Server/PsWebApiController.cs create mode 100644 Interception/Server/ShareCodeMapping.cs diff --git a/Interception/Certificates/CertificateManager.cs b/Interception/Certificates/CertificateManager.cs index b42c4aa..ebd656c 100644 --- a/Interception/Certificates/CertificateManager.cs +++ b/Interception/Certificates/CertificateManager.cs @@ -107,6 +107,7 @@ private static async Task LoadOrCreateServerCert(string path, var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddDnsName("do.pishock.com"); + sanBuilder.AddDnsName("ps.pishock.com"); sanBuilder.AddIpAddress(IPAddress.Loopback); req.CertificateExtensions.Add(sanBuilder.Build()); diff --git a/Interception/HostsFile/HostsFileManager.cs b/Interception/HostsFile/HostsFileManager.cs index f2c2beb..54c6fe8 100644 --- a/Interception/HostsFile/HostsFileManager.cs +++ b/Interception/HostsFile/HostsFileManager.cs @@ -6,16 +6,20 @@ namespace OpenShock.Desktop.Modules.Interception.HostsFile; public sealed class HostsFileManager { private const string HostsPath = @"C:\Windows\System32\drivers\etc\hosts"; - private const string HostEntry = "127.0.0.1 do.pishock.com"; private const string Marker = "# OpenShock Interception"; + private static readonly string[] HostEntries = + [ + $"127.0.0.1 do.pishock.com {Marker}", + $"127.0.0.1 ps.pishock.com {Marker}" + ]; + public bool IsEnabled { get; private set; } public async Task EnableAsync() { if (IsEnabled) return; - var line = $"{HostEntry} {Marker}"; - await RunElevatedHostsCommand($"add \"{line}\""); + await RunElevatedHostsCommand("add"); IsEnabled = true; await FlushDns(); } @@ -44,15 +48,16 @@ public async Task DetectCurrentState() private static async Task RunElevatedHostsCommand(string action) { string script; - if (action.StartsWith("add")) + if (action == "add") { - var line = action.Substring(4).Trim(); + var linesArray = string.Join(",", HostEntries.Select(e => $"'{e}'")); script = string.Join("\n", - $"$line = {line};", + $"$lines = @({linesArray});", $"$hostsPath = '{HostsPath}';", "$content = Get-Content $hostsPath -Raw -ErrorAction SilentlyContinue;", "if ($content -notmatch 'OpenShock Interception') {", - " Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false", + " $toAdd = \"`n\" + ($lines -join \"`n\");", + " Add-Content -Path $hostsPath -Value $toAdd -NoNewline:$false", "}"); } else @@ -104,4 +109,4 @@ private static async Task FlushDns() // Best-effort DNS flush } } -} \ No newline at end of file +} diff --git a/Interception/InterceptionConfig.cs b/Interception/InterceptionConfig.cs index 349ca2e..3a1a4a0 100644 --- a/Interception/InterceptionConfig.cs +++ b/Interception/InterceptionConfig.cs @@ -1,8 +1,10 @@ +using OpenShock.Desktop.Modules.Interception.Server; + namespace OpenShock.Desktop.Modules.Interception; public sealed class InterceptionConfig { public ushort Port { get; set; } = 443; public bool AutoStart { get; set; } = true; - public Dictionary ShareCodeMappings { get; set; } = new(); -} \ No newline at end of file + public Dictionary ShareCodeMappings { get; set; } = new(); +} diff --git a/Interception/InterceptionService.cs b/Interception/InterceptionService.cs index 5ccbf82..99444f8 100644 --- a/Interception/InterceptionService.cs +++ b/Interception/InterceptionService.cs @@ -35,12 +35,14 @@ public async Task StartAsync() var cert = certManager.ServerCertificate; var operateController = ActivatorUtilities.CreateInstance(serviceProvider); + var psController = ActivatorUtilities.CreateInstance(serviceProvider); _server = new WebServer(o => o .WithUrlPrefix($"https://*:{port}/") .WithMode(HttpListenerMode.EmbedIO) .WithCertificate(cert)) .WithWebApi("/api", m => m.WithController(() => operateController)) + .WithWebApi("/PiShock", m => m.WithController(() => psController)) ; _ = _server.RunAsync(); @@ -60,4 +62,4 @@ public async Task UpdateConfig(Action update) update(Config); await moduleConfig.Save(); } -} \ No newline at end of file +} diff --git a/Interception/Server/DoWebApiController.cs b/Interception/Server/DoWebApiController.cs index 6f1de04..7c28429 100644 --- a/Interception/Server/DoWebApiController.cs +++ b/Interception/Server/DoWebApiController.cs @@ -56,7 +56,7 @@ public async Task Operate() return; } - if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId)) + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) { _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); HttpContext.Response.StatusCode = 404; @@ -65,6 +65,15 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ return; } + if (mapping.ShockerIds.Count == 0) + { + _logger.LogError("Share code has no shockers configured: {Code}", request.Code); + HttpContext.Response.StatusCode = 404; + await HttpContext.SendStringAsync("Share code has no shockers configured", "text/plain", + Encoding.UTF8); + return; + } + var controlType = request.Op switch { 0 => ControlType.Shock, @@ -73,21 +82,18 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ _ => ControlType.Vibrate }; - var durationMs = (ushort)Math.Clamp(request.Duration * 1000, 300, 30000); - var intensity = (byte)Math.Clamp(request.Intensity, 1, 100); + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.MinDuration * 1000, mapping.MaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.MinIntensity, mapping.MaxIntensity); if (request.Intensity <= 0) controlType = ControlType.Stop; - var controls = new[] + var controls = mapping.ShockerIds.Select(id => new ShockerControl { - new ShockerControl - { - Id = shockerId, - Type = controlType, - Intensity = intensity, - Duration = durationMs - } - }; + Id = id, + Type = controlType, + Intensity = intensity, + Duration = durationMs + }).ToArray(); var customName = request.Name ?? request.Username ?? "PiShock Interception"; @@ -95,8 +101,8 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ { await _openShockControl.Control(controls, customName); _logger.LogInformation( - "PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on shocker {ShockerId} by {Name}", - controlType, intensity, durationMs / 1000.0, shockerId, customName); + "PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on {ShockerCount} shocker(s) by {Name}", + controlType, intensity, durationMs / 1000.0, controls.Length, customName); await HttpContext.SendStringAsync( JsonSerializer.Serialize(new { success = true, message = "Operation Succeeded." }), "application/json", Encoding.UTF8); @@ -137,7 +143,7 @@ public async Task GetShockerInfo() return; } - if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId)) + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) { _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); HttpContext.Response.StatusCode = 404; @@ -147,10 +153,10 @@ public async Task GetShockerInfo() var info = new { - clientId = shockerId, + clientId = mapping.ShockerIds.FirstOrDefault(), name = $"Shocker ({request.Code})", - maxIntensity = 100, - maxDuration = 15, + maxIntensity = (int)mapping.MaxIntensity, + maxDuration = (int)mapping.MaxDuration, online = true }; @@ -158,4 +164,4 @@ await HttpContext.SendStringAsync( JsonSerializer.Serialize(info), "application/json", Encoding.UTF8); } -} \ No newline at end of file +} diff --git a/Interception/Server/PsWebApiController.cs b/Interception/Server/PsWebApiController.cs new file mode 100644 index 0000000..2056a65 --- /dev/null +++ b/Interception/Server/PsWebApiController.cs @@ -0,0 +1,154 @@ +using System.Text; +using System.Text.Json; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Microsoft.Extensions.Logging; +using OpenShock.Desktop.ModuleBase.Api; + +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class PsWebApiController : WebApiController +{ + private readonly ILogger _logger; + private readonly IOpenShockData _openShockData; + private readonly InterceptionService _service; + + public PsWebApiController(InterceptionService service, IOpenShockData openShockData, + ILogger logger) + { + _service = service; + _openShockData = openShockData; + _logger = logger; + } + + [Route(HttpVerbs.Get, "/GetUserDevices")] + public async Task GetUserDevices() + { + _logger.LogInformation("PiShock Ps API: GetUserDevices request"); + + var shareCodeIndex = 0; + var devices = new List(); + + // Build a synthetic device containing all mapped shockers + var shockers = new List(); + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + foreach (var shockerId in mapping.ShockerIds) + { + var shockerInfo = FindShockerInfo(shockerId); + shockers.Add(new + { + shockerId = shareCodeIndex++, + name = shockerInfo?.Name ?? $"Shocker ({shareCode})", + shareCode, + isPaused = false, + maxIntensity = (int)mapping.MaxIntensity, + maxDuration = (int)mapping.MaxDuration + }); + } + } + + if (shockers.Count > 0) + { + devices.Add(new + { + deviceId = 0, + name = "OpenShock Interception", + shockers + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(devices), + "application/json", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/GetShareCodesByOwner")] + public async Task GetShareCodesByOwner() + { + _logger.LogInformation("PiShock Ps API: GetShareCodesByOwner request"); + + var result = new List(); + var shareId = 0; + + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + var shockerInfo = mapping.ShockerIds.Count > 0 ? FindShockerInfo(mapping.ShockerIds[0]) : null; + result.Add(new + { + shareCodeId = shareId++, + code = shareCode, + shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", + isPaused = false, + maxIntensity = (int)mapping.MaxIntensity, + maxDuration = (int)mapping.MaxDuration, + permissions = new + { + shock = true, + vibrate = true, + sound = true + } + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(result), + "application/json", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/GetShockersByShareIds")] + public async Task GetShockersByShareIds() + { + _logger.LogInformation("PiShock Ps API: GetShockersByShareIds request"); + + var shareIdParams = HttpContext.GetRequestQueryData().GetValues("shareIds") ?? []; + + var result = new List(); + var shareId = 0; + + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + var currentId = shareId++; + if (shareIdParams.Length > 0 && !shareIdParams.Contains(currentId.ToString())) continue; + + var shockerInfo = mapping.ShockerIds.Count > 0 ? FindShockerInfo(mapping.ShockerIds[0]) : null; + result.Add(new + { + shareCodeId = currentId, + code = shareCode, + shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", + isPaused = false, + maxIntensity = (int)mapping.MaxIntensity, + maxDuration = (int)mapping.MaxDuration, + online = true, + permissions = new + { + shock = true, + vibrate = true, + sound = true + } + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(result), + "application/json", Encoding.UTF8); + } + + private ShockerInfo? FindShockerInfo(Guid shockerId) + { + foreach (var hub in _openShockData.Hubs.Value) + { + foreach (var shocker in hub.Shockers) + { + if (shocker.Id == shockerId) + return new ShockerInfo(shocker.Name, hub.Name); + } + } + + return null; + } + + private sealed record ShockerInfo(string Name, string HubName); +} diff --git a/Interception/Server/ShareCodeMapping.cs b/Interception/Server/ShareCodeMapping.cs new file mode 100644 index 0000000..5b02cd1 --- /dev/null +++ b/Interception/Server/ShareCodeMapping.cs @@ -0,0 +1,10 @@ +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class ShareCodeMapping +{ + public List ShockerIds { get; set; } = []; + public byte MinIntensity { get; set; } = 1; + public byte MaxIntensity { get; set; } = 100; + public ushort MinDuration { get; set; } = 1; + public ushort MaxDuration { get; set; } = 15; +} diff --git a/Interception/Ui/InterceptionPage.razor b/Interception/Ui/InterceptionPage.razor index 64e8cad..6a476cd 100644 --- a/Interception/Ui/InterceptionPage.razor +++ b/Interception/Ui/InterceptionPage.razor @@ -2,6 +2,7 @@ @using OpenShock.Desktop.ModuleBase.Api @using OpenShock.Desktop.Modules.Interception.Certificates @using OpenShock.Desktop.Modules.Interception.HostsFile +@using OpenShock.Desktop.Modules.Interception.Server PiShock API Interception @@ -28,7 +29,7 @@ Hosts File Redirect - Redirects do.pishock.com to 127.0.0.1 via the system hosts file. + Redirects do.pishock.com and ps.pishock.com to 127.0.0.1 via the system hosts file. Each action requires administrator elevation. @@ -81,43 +82,84 @@ Share Code Mappings - Map PiShock share codes to OpenShock shockers. + Map PiShock share codes to OpenShock shockers. Each mapping can target multiple shockers + and has configurable intensity/duration limits. Share Code - OpenShock Shocker - + OpenShock Shockers + Intensity + Duration + @context.Key - @GetShockerName(context.Value) + @GetShockerNames(context.Value) + @context.Value.MinIntensity–@context.Value.MaxIntensity% + @context.Value.MinDuration–@context.Value.MaxDuration s + - - - - @foreach (var hub in OpenShockData.Hubs.Value) - { - foreach (var shocker in hub.Shockers) + @* Add / Edit Mapping Form *@ + + @(_editingShareCode != null ? "Edit Mapping" : "Add Mapping") + + + + + Guid.TryParse(id, out var g) ? GetShockerName(g) : id)))" + Style="max-width: 500px;"> + @foreach (var hub in OpenShockData.Hubs.Value) { - @hub.Name / @shocker.Name + foreach (var shocker in hub.Shockers) + { + @hub.Name / @shocker.Name + } } - } - - - Add - - + + + + + + + + + + + + @(_editingShareCode != null ? "Save" : "Add") + + @if (_editingShareCode != null) + { + + Cancel + + } + + + @@ -139,7 +181,12 @@ [ModuleInject] public required HostsFileManager HostsManager { get; set; } private string _newShareCode = string.Empty; - private Guid _selectedShockerId; + private HashSet _selectedShockerIds = []; + private byte _minIntensity = 1; + private byte _maxIntensity = 100; + private ushort _minDuration = 1; + private ushort _maxDuration = 15; + private string? _editingShareCode; private async Task OnServerToggle(bool value) { @@ -179,21 +226,66 @@ StateHasChanged(); } - private async Task AddMapping() + private void EditMapping(string shareCode, ShareCodeMapping mapping) { - if (string.IsNullOrWhiteSpace(_newShareCode) || _selectedShockerId == Guid.Empty) return; - await Service.UpdateConfig(c => c.ShareCodeMappings[_newShareCode] = _selectedShockerId); - _newShareCode = string.Empty; - _selectedShockerId = Guid.Empty; + _editingShareCode = shareCode; + _newShareCode = shareCode; + _selectedShockerIds = [.. mapping.ShockerIds]; + _minIntensity = mapping.MinIntensity; + _maxIntensity = mapping.MaxIntensity; + _minDuration = mapping.MinDuration; + _maxDuration = mapping.MaxDuration; + StateHasChanged(); + } + + private void CancelEdit() + { + ResetForm(); + StateHasChanged(); + } + + private async Task SaveMapping() + { + if (string.IsNullOrWhiteSpace(_newShareCode) || _selectedShockerIds.Count == 0) return; + + var mapping = new ShareCodeMapping + { + ShockerIds = _selectedShockerIds.ToList(), + MinIntensity = _minIntensity, + MaxIntensity = _maxIntensity, + MinDuration = _minDuration, + MaxDuration = _maxDuration + }; + + await Service.UpdateConfig(c => c.ShareCodeMappings[_newShareCode] = mapping); + ResetForm(); StateHasChanged(); } private async Task RemoveMapping(string code) { await Service.UpdateConfig(c => c.ShareCodeMappings.Remove(code)); + if (_editingShareCode == code) ResetForm(); StateHasChanged(); } + private void ResetForm() + { + _editingShareCode = null; + _newShareCode = string.Empty; + _selectedShockerIds = []; + _minIntensity = 1; + _maxIntensity = 100; + _minDuration = 1; + _maxDuration = 15; + } + + private string GetShockerNames(ShareCodeMapping mapping) + { + if (mapping.ShockerIds.Count == 0) return "(none)"; + return string.Join(", ", mapping.ShockerIds.Select(GetShockerName)); + } + private string GetShockerName(Guid shockerId) { foreach (var hub in OpenShockData.Hubs.Value) From 93cce1578f035c7de08aaf508df1ad94e7324920 Mon Sep 17 00:00:00 2001 From: "LucHeart (Zoe Agent)" Date: Tue, 17 Mar 2026 11:33:03 +0000 Subject: [PATCH 2/3] feat: add POST /PiShock/Operate endpoint and health checks - Add Operate endpoint to PsWebApiController matching the ps.pishock.com non-legacy operate API (same share code based auth, same control flow) - Add HealthWebApiController with /Health/Check (200) and /Health/Server (204) endpoints to satisfy apps that health-check the PiShock API - Register health controller in InterceptionService --- Interception/InterceptionService.cs | 2 + Interception/Server/HealthWebApiController.cs | 23 +++++ Interception/Server/PsWebApiController.cs | 91 ++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Interception/Server/HealthWebApiController.cs diff --git a/Interception/InterceptionService.cs b/Interception/InterceptionService.cs index 99444f8..fce0903 100644 --- a/Interception/InterceptionService.cs +++ b/Interception/InterceptionService.cs @@ -36,6 +36,7 @@ public async Task StartAsync() var operateController = ActivatorUtilities.CreateInstance(serviceProvider); var psController = ActivatorUtilities.CreateInstance(serviceProvider); + var healthController = new HealthWebApiController(); _server = new WebServer(o => o .WithUrlPrefix($"https://*:{port}/") @@ -43,6 +44,7 @@ public async Task StartAsync() .WithCertificate(cert)) .WithWebApi("/api", m => m.WithController(() => operateController)) .WithWebApi("/PiShock", m => m.WithController(() => psController)) + .WithWebApi("/Health", m => m.WithController(() => healthController)) ; _ = _server.RunAsync(); diff --git a/Interception/Server/HealthWebApiController.cs b/Interception/Server/HealthWebApiController.cs new file mode 100644 index 0000000..ffbc417 --- /dev/null +++ b/Interception/Server/HealthWebApiController.cs @@ -0,0 +1,23 @@ +using System.Text; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; + +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class HealthWebApiController : WebApiController +{ + [Route(HttpVerbs.Get, "/Check")] + public async Task Check() + { + HttpContext.Response.StatusCode = 200; + await HttpContext.SendStringAsync("OK", "text/plain", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/Server")] + public Task Server() + { + HttpContext.Response.StatusCode = 204; + return Task.CompletedTask; + } +} diff --git a/Interception/Server/PsWebApiController.cs b/Interception/Server/PsWebApiController.cs index 2056a65..93813b2 100644 --- a/Interception/Server/PsWebApiController.cs +++ b/Interception/Server/PsWebApiController.cs @@ -5,23 +5,110 @@ using EmbedIO.WebApi; using Microsoft.Extensions.Logging; using OpenShock.Desktop.ModuleBase.Api; +using OpenShock.Desktop.ModuleBase.Models; namespace OpenShock.Desktop.Modules.Interception.Server; public sealed class PsWebApiController : WebApiController { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private readonly ILogger _logger; + private readonly IOpenShockControl _openShockControl; private readonly IOpenShockData _openShockData; private readonly InterceptionService _service; - public PsWebApiController(InterceptionService service, IOpenShockData openShockData, - ILogger logger) + public PsWebApiController(InterceptionService service, IOpenShockControl openShockControl, + IOpenShockData openShockData, ILogger logger) { _service = service; + _openShockControl = openShockControl; _openShockData = openShockData; _logger = logger; } + [Route(HttpVerbs.Post, "/Operate")] + public async Task Operate() + { + using var reader = new StreamReader(HttpContext.Request.InputStream); + var body = await reader.ReadToEndAsync(); + + PiShockRequest? request; + try + { + request = JsonSerializer.Deserialize(body, JsonOptions); + } + catch + { + _logger.LogError("Error parsing JSON body: {Body}", body); + HttpContext.Response.StatusCode = 400; + await HttpContext.SendStringAsync("Invalid JSON", "text/plain", Encoding.UTF8); + return; + } + + if (request?.Code == null) + { + _logger.LogError("Missing share code in request: {Body}", body); + HttpContext.Response.StatusCode = 400; + await HttpContext.SendStringAsync("Missing share code", "text/plain", Encoding.UTF8); + return; + } + + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) + { + _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); + await HttpContext.SendStringAsync("This code doesn't exist.", "text/plain", Encoding.UTF8); + return; + } + + if (mapping.ShockerIds.Count == 0) + { + _logger.LogError("Share code has no shockers configured: {Code}", request.Code); + await HttpContext.SendStringAsync("This code doesn't exist.", "text/plain", Encoding.UTF8); + return; + } + + var controlType = request.Op switch + { + 0 => ControlType.Shock, + 1 => ControlType.Vibrate, + 2 => ControlType.Sound, + _ => ControlType.Vibrate + }; + + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.MinDuration * 1000, mapping.MaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.MinIntensity, mapping.MaxIntensity); + + if (request.Intensity <= 0) controlType = ControlType.Stop; + + var controls = mapping.ShockerIds.Select(id => new ShockerControl + { + Id = id, + Type = controlType, + Intensity = intensity, + Duration = durationMs + }).ToArray(); + + var customName = request.Name ?? request.Username ?? "PiShock Interception"; + + try + { + await _openShockControl.Control(controls, customName); + _logger.LogInformation( + "PiShock Ps API Operate: {ControlType} {Intensity}% for {Duration}s on {ShockerCount} shocker(s) by {Name}", + controlType, intensity, durationMs / 1000.0, controls.Length, customName); + await HttpContext.SendStringAsync("Operation Succeeded.", "text/plain", Encoding.UTF8); + } + catch (Exception ex) + { + HttpContext.Response.StatusCode = 500; + await HttpContext.SendStringAsync(ex.Message, "text/plain", Encoding.UTF8); + } + } + [Route(HttpVerbs.Get, "/GetUserDevices")] public async Task GetUserDevices() { From 9e16ef70c02eb266c35a5459dd7bbee97954efcc Mon Sep 17 00:00:00 2001 From: "LucHeart (Zoe Agent)" Date: Thu, 19 Mar 2026 07:56:53 +0000 Subject: [PATCH 3/3] fix: address review feedback - cert SAN, hosts upgrade, clamp safety - Regenerate server cert when existing cert is missing ps.pishock.com SAN so upgrades from old single-domain cert work without manual deletion - Check hosts file entries per-hostname instead of just the marker, so upgrades from old single do.pishock.com entry pick up ps.pishock.com - Add NeedsUpdate flag to HostsFileManager for detecting partial state - Add Effective{Min,Max}{Intensity,Duration} properties to ShareCodeMapping that swap values when min > max, preventing Math.Clamp ArgumentException - Use Effective* properties in both DoWebApiController and PsWebApiController --- .../Certificates/CertificateManager.cs | 13 +++++++++- Interception/HostsFile/HostsFileManager.cs | 24 +++++++++++++---- Interception/Server/DoWebApiController.cs | 8 +++--- Interception/Server/PsWebApiController.cs | 16 ++++++------ Interception/Server/ShareCodeMapping.cs | 26 +++++++++++++++++++ 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/Interception/Certificates/CertificateManager.cs b/Interception/Certificates/CertificateManager.cs index ebd656c..d486441 100644 --- a/Interception/Certificates/CertificateManager.cs +++ b/Interception/Certificates/CertificateManager.cs @@ -76,6 +76,8 @@ private static async Task LoadOrCreateCaCert(string path) return cert; } + private static readonly string[] RequiredDnsNames = ["do.pishock.com", "ps.pishock.com"]; + private static async Task LoadOrCreateServerCert(string path, X509Certificate2 caCert) { if (File.Exists(path)) @@ -89,7 +91,7 @@ private static async Task LoadOrCreateServerCert(string path, var loaded = X509CertificateLoader.LoadPkcs12(buffer, CertPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); - if (loaded.NotAfter > DateTime.UtcNow.AddDays(30)) + if (loaded.NotAfter > DateTime.UtcNow.AddDays(30) && HasRequiredSans(loaded)) return loaded; loaded.Dispose(); } @@ -128,6 +130,15 @@ private static async Task LoadOrCreateServerCert(string path, return exported; } + private static bool HasRequiredSans(X509Certificate2 cert) + { + var sanExtension = cert.Extensions.OfType().FirstOrDefault(); + if (sanExtension == null) return false; + + var dnsNames = sanExtension.EnumerateDnsNames().ToHashSet(StringComparer.OrdinalIgnoreCase); + return RequiredDnsNames.All(dnsNames.Contains); + } + private bool CheckCaTrusted() { if (_caCert == null) return false; diff --git a/Interception/HostsFile/HostsFileManager.cs b/Interception/HostsFile/HostsFileManager.cs index 54c6fe8..dbe8a2b 100644 --- a/Interception/HostsFile/HostsFileManager.cs +++ b/Interception/HostsFile/HostsFileManager.cs @@ -18,9 +18,10 @@ public sealed class HostsFileManager public async Task EnableAsync() { - if (IsEnabled) return; + if (IsEnabled && !NeedsUpdate) return; await RunElevatedHostsCommand("add"); IsEnabled = true; + NeedsUpdate = false; await FlushDns(); } @@ -32,16 +33,22 @@ public async Task DisableAsync() await FlushDns(); } + public bool NeedsUpdate { get; private set; } + public async Task DetectCurrentState() { try { var content = await File.ReadAllTextAsync(HostsPath); - IsEnabled = content.Contains(Marker); + var hasAny = content.Contains(Marker); + var hasAll = content.Contains("do.pishock.com") && content.Contains("ps.pishock.com"); + IsEnabled = hasAny; + NeedsUpdate = hasAny && !hasAll; } catch { IsEnabled = false; + NeedsUpdate = false; } } @@ -50,14 +57,21 @@ private static async Task RunElevatedHostsCommand(string action) string script; if (action == "add") { + // Check each host entry individually so upgrades from the old + // single-entry format pick up the new ps.pishock.com line. var linesArray = string.Join(",", HostEntries.Select(e => $"'{e}'")); script = string.Join("\n", $"$lines = @({linesArray});", $"$hostsPath = '{HostsPath}';", "$content = Get-Content $hostsPath -Raw -ErrorAction SilentlyContinue;", - "if ($content -notmatch 'OpenShock Interception') {", - " $toAdd = \"`n\" + ($lines -join \"`n\");", - " Add-Content -Path $hostsPath -Value $toAdd -NoNewline:$false", + "if (-not $content) { $content = '' }", + "$added = $false;", + "foreach ($line in $lines) {", + " $host = ($line -split '\\s+')[1];", + " if ($content -notmatch [regex]::Escape($host)) {", + " Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false;", + " $added = $true", + " }", "}"); } else diff --git a/Interception/Server/DoWebApiController.cs b/Interception/Server/DoWebApiController.cs index 7c28429..a3a495f 100644 --- a/Interception/Server/DoWebApiController.cs +++ b/Interception/Server/DoWebApiController.cs @@ -82,8 +82,8 @@ await HttpContext.SendStringAsync("Share code has no shockers configured", "text _ => ControlType.Vibrate }; - var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.MinDuration * 1000, mapping.MaxDuration * 1000); - var intensity = (byte)Math.Clamp(request.Intensity, mapping.MinIntensity, mapping.MaxIntensity); + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.EffectiveMinDuration * 1000, mapping.EffectiveMaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.EffectiveMinIntensity, mapping.EffectiveMaxIntensity); if (request.Intensity <= 0) controlType = ControlType.Stop; @@ -155,8 +155,8 @@ public async Task GetShockerInfo() { clientId = mapping.ShockerIds.FirstOrDefault(), name = $"Shocker ({request.Code})", - maxIntensity = (int)mapping.MaxIntensity, - maxDuration = (int)mapping.MaxDuration, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, online = true }; diff --git a/Interception/Server/PsWebApiController.cs b/Interception/Server/PsWebApiController.cs index 93813b2..5d81764 100644 --- a/Interception/Server/PsWebApiController.cs +++ b/Interception/Server/PsWebApiController.cs @@ -79,8 +79,8 @@ public async Task Operate() _ => ControlType.Vibrate }; - var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.MinDuration * 1000, mapping.MaxDuration * 1000); - var intensity = (byte)Math.Clamp(request.Intensity, mapping.MinIntensity, mapping.MaxIntensity); + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.EffectiveMinDuration * 1000, mapping.EffectiveMaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.EffectiveMinIntensity, mapping.EffectiveMaxIntensity); if (request.Intensity <= 0) controlType = ControlType.Stop; @@ -130,8 +130,8 @@ public async Task GetUserDevices() name = shockerInfo?.Name ?? $"Shocker ({shareCode})", shareCode, isPaused = false, - maxIntensity = (int)mapping.MaxIntensity, - maxDuration = (int)mapping.MaxDuration + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration }); } } @@ -168,8 +168,8 @@ public async Task GetShareCodesByOwner() code = shareCode, shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", isPaused = false, - maxIntensity = (int)mapping.MaxIntensity, - maxDuration = (int)mapping.MaxDuration, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, permissions = new { shock = true, @@ -206,8 +206,8 @@ public async Task GetShockersByShareIds() code = shareCode, shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", isPaused = false, - maxIntensity = (int)mapping.MaxIntensity, - maxDuration = (int)mapping.MaxDuration, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, online = true, permissions = new { diff --git a/Interception/Server/ShareCodeMapping.cs b/Interception/Server/ShareCodeMapping.cs index 5b02cd1..2bf93f8 100644 --- a/Interception/Server/ShareCodeMapping.cs +++ b/Interception/Server/ShareCodeMapping.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace OpenShock.Desktop.Modules.Interception.Server; public sealed class ShareCodeMapping @@ -7,4 +9,28 @@ public sealed class ShareCodeMapping public byte MaxIntensity { get; set; } = 100; public ushort MinDuration { get; set; } = 1; public ushort MaxDuration { get; set; } = 15; + + /// + /// Effective min intensity, guaranteed <= EffectiveMaxIntensity. + /// + [JsonIgnore] + public byte EffectiveMinIntensity => Math.Min(MinIntensity, MaxIntensity); + + /// + /// Effective max intensity, guaranteed >= EffectiveMinIntensity. + /// + [JsonIgnore] + public byte EffectiveMaxIntensity => Math.Max(MinIntensity, MaxIntensity); + + /// + /// Effective min duration (seconds), guaranteed <= EffectiveMaxDuration. + /// + [JsonIgnore] + public ushort EffectiveMinDuration => Math.Min(MinDuration, MaxDuration); + + /// + /// Effective max duration (seconds), guaranteed >= EffectiveMinDuration. + /// + [JsonIgnore] + public ushort EffectiveMaxDuration => Math.Max(MinDuration, MaxDuration); }