Compare commits
5 Commits
master
...
ft/plugin_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62103c4275 | ||
|
|
22672a7047 | ||
|
|
76b6132932 | ||
|
|
040050ac38 | ||
|
|
cf8b8462d0 |
@ -1,3 +1,5 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace PluginBuilder.APIModels;
|
namespace PluginBuilder.APIModels;
|
||||||
|
|
||||||
public sealed record InstalledPluginRequest(string Identifier, string Version);
|
public sealed record InstalledPluginRequest(string Identifier, string Version);
|
||||||
|
|||||||
@ -28,7 +28,8 @@ public class ApiController(
|
|||||||
UserManager<IdentityUser> userManager,
|
UserManager<IdentityUser> userManager,
|
||||||
UserVerifiedLogic userVerifiedLogic,
|
UserVerifiedLogic userVerifiedLogic,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ServerEnvironment serverEnvironment)
|
ServerEnvironment serverEnvironment,
|
||||||
|
TelemetryService telemetryService)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
private sealed class BuildRow
|
private sealed class BuildRow
|
||||||
@ -200,6 +201,38 @@ public class ApiController(
|
|||||||
return Ok(versions);
|
return Ok(versions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("telemetry/plugins")]
|
||||||
|
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
||||||
|
public async Task<IActionResult> ReportInstalledPlugins([FromBody] List<InstalledPluginRequest> plugins)
|
||||||
|
{
|
||||||
|
if (plugins is null || plugins.Count == 0)
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
var userAgent = Request.Headers.UserAgent.ToString();
|
||||||
|
var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var xForwardedFor = Request.Headers["X-Forwarded-For"].ToString();
|
||||||
|
var xOriginalFor = Request.Headers["X-Original-For"].ToString();
|
||||||
|
|
||||||
|
var pluginReports = plugins.Where(p => !string.IsNullOrWhiteSpace(p.Identifier) && !string.IsNullOrWhiteSpace(p.Version))
|
||||||
|
.Select(p => new PluginReport(p.Identifier, p.Version)).ToList();
|
||||||
|
|
||||||
|
if (pluginReports.Count == 0)
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
_ = telemetryService.RecordServerSnapshot(remoteIp, userAgent, pluginReports, xOriginalFor, xForwardedFor);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("plugins/{pluginSlug}/stats")]
|
||||||
|
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
||||||
|
public async Task<IActionResult> GetPluginStats(string pluginSlug)
|
||||||
|
{
|
||||||
|
var stats = await telemetryService.GetStats(pluginSlug);
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("plugins/{pluginSlug}/versions/{version}")]
|
[HttpGet("plugins/{pluginSlug}/versions/{version}")]
|
||||||
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
||||||
@ -249,6 +282,10 @@ public class ApiController(
|
|||||||
|
|
||||||
await using var conn = await connectionFactory.Open();
|
await using var conn = await connectionFactory.Open();
|
||||||
await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() });
|
await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() });
|
||||||
|
|
||||||
|
_ = telemetryService.RecordPluginDownload(pluginSlug.ToString(), version.ToString(), Request.Headers.UserAgent.ToString(),
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(), Request.Headers["X-Original-For"].ToString(), Request.Headers["X-Forwarded-For"].ToString());
|
||||||
|
|
||||||
if (serverEnvironment.EnableLocalArtifactDownloadProxy && Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) && artifactUri.IsLoopback)
|
if (serverEnvironment.EnableLocalArtifactDownloadProxy && Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) && artifactUri.IsLoopback)
|
||||||
{
|
{
|
||||||
return RedirectToAction(
|
return RedirectToAction(
|
||||||
|
|||||||
16
PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql
Normal file
16
PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS plugin_server_installs
|
||||||
|
(
|
||||||
|
hashed_ip TEXT NOT NULL,
|
||||||
|
plugin_slug TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
btcpay_version TEXT NOT NULL,
|
||||||
|
install_count BIGINT NOT NULL DEFAULT 1,
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
uninstalled_at TIMESTAMPTZ NULL,
|
||||||
|
PRIMARY KEY (hashed_ip, plugin_slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_plugin_server_installs_plugin_slug ON plugin_server_installs (plugin_slug);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_plugin_server_installs_plugin_slug_uninstalled ON plugin_server_installs (plugin_slug, uninstalled_at);
|
||||||
23
PluginBuilder/DataModels/PluginDownload.cs
Normal file
23
PluginBuilder/DataModels/PluginDownload.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
namespace PluginBuilder.DataModels;
|
||||||
|
|
||||||
|
public class PluginDownload
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string PluginSlug { get; set; } = null!;
|
||||||
|
public string Version { get; set; } = null!;
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
public string HashedIp { get; set; } = null!;
|
||||||
|
public string BTCPayVersion { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PluginServerInstall
|
||||||
|
{
|
||||||
|
public string HashedIp { get; set; } = null!;
|
||||||
|
public string PluginSlug { get; set; } = null!;
|
||||||
|
public string Version { get; set; } = null!;
|
||||||
|
public string BTCPayVersion { get; set; } = null!;
|
||||||
|
public long InstallCount { get; set; }
|
||||||
|
public DateTimeOffset InstalledAt { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public DateTimeOffset? UninstalledAt { get; set; }
|
||||||
|
}
|
||||||
@ -188,6 +188,7 @@ public class Program
|
|||||||
services.AddHostedService<UserCleanupHostedService>();
|
services.AddHostedService<UserCleanupHostedService>();
|
||||||
|
|
||||||
services.AddSingleton<DBConnectionFactory>();
|
services.AddSingleton<DBConnectionFactory>();
|
||||||
|
services.AddScoped<TelemetryService>();
|
||||||
services.AddScoped<PluginCleanupRunner>();
|
services.AddScoped<PluginCleanupRunner>();
|
||||||
services.AddScoped<UserCleanupRunner>();
|
services.AddScoped<UserCleanupRunner>();
|
||||||
services.AddSingleton<BuildService>();
|
services.AddSingleton<BuildService>();
|
||||||
|
|||||||
296
PluginBuilder/Services/TelemetryService.cs
Normal file
296
PluginBuilder/Services/TelemetryService.cs
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Dapper;
|
||||||
|
using PluginBuilder.DataModels;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Services;
|
||||||
|
|
||||||
|
public class TelemetryService(DBConnectionFactory connectionFactory, ILogger<TelemetryService> logger)
|
||||||
|
{
|
||||||
|
private static readonly Regex BTCPayUserAgentRegex = new(@"^BTCPayServer/(\d+\.\d+\.\d+)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public async Task RecordPluginDownload(string pluginSlug, string version, string? userAgent, string? remoteIp,
|
||||||
|
string? xOriginalFor = null, string? xForwardedFor = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPublicIp(remoteIp, xOriginalFor, xForwardedFor, out var ip))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var hashedIp = HashIp(ip!);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
await using var conn = await connectionFactory.Open();
|
||||||
|
var existing = await conn.QueryFirstOrDefaultAsync<PluginServerInstall>("""
|
||||||
|
SELECT * FROM plugin_server_installs WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = pluginSlug });
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT INTO plugin_server_installs
|
||||||
|
(hashed_ip, plugin_slug, version, btcpay_version, installed_at, updated_at, uninstalled_at, install_count)
|
||||||
|
VALUES
|
||||||
|
(@HashedIp, @PluginSlug, @Version, @BTCPayVersion, @Now, @Now, NULL, 1)
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
else if (existing.UninstalledAt != null)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
UPDATE plugin_server_installs
|
||||||
|
SET version = @Version,
|
||||||
|
btcpay_version = @BTCPayVersion,
|
||||||
|
installed_at = @Now,
|
||||||
|
updated_at = @Now,
|
||||||
|
uninstalled_at = NULL,
|
||||||
|
install_count = install_count + 1
|
||||||
|
WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
UPDATE plugin_server_installs
|
||||||
|
SET version = @Version, btcpay_version = @BTCPayVersion, updated_at = @Now
|
||||||
|
WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to record download telemetry for {PluginSlug} {Version}", pluginSlug, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task RecordServerSnapshot(string? remoteIp, string userAgent, IEnumerable<PluginReport> plugins,
|
||||||
|
string? xOriginalFor = null, string? xForwardedFor = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pluginList = plugins.GroupBy(p => p.Slug, StringComparer.OrdinalIgnoreCase).Select(g => g.Last()).ToList();
|
||||||
|
if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPublicIp(remoteIp, xOriginalFor, xForwardedFor, out var ip))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var hashedIp = HashIp(ip!);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
await using var conn = await connectionFactory.Open();
|
||||||
|
|
||||||
|
var resolvedPluginList = (await conn.QueryAsync<PluginReport>("""
|
||||||
|
SELECT p.slug AS Slug, i.version AS Version
|
||||||
|
FROM unnest(@Identifiers::text[], @Versions::text[]) AS i(identifier, version)
|
||||||
|
JOIN plugins p ON p.identifier = i.identifier
|
||||||
|
WHERE i.identifier IS NOT NULL AND i.version IS NOT NULL
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Identifiers = pluginList.Select(p => p.Slug).ToArray(),
|
||||||
|
Versions = pluginList.Select(p => p.Version).ToArray()
|
||||||
|
})).ToList();
|
||||||
|
|
||||||
|
if (resolvedPluginList.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
|
||||||
|
var existing = (await conn.QueryAsync<PluginServerInstall>("""
|
||||||
|
SELECT * FROM plugin_server_installs WHERE hashed_ip = @HashedIp
|
||||||
|
""", new { HashedIp = hashedIp })).ToList();
|
||||||
|
|
||||||
|
var reportedSlugs = resolvedPluginList.Select(p => p.Slug).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingBySlug = existing.ToDictionary(x => x.PluginSlug, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var plugin in resolvedPluginList)
|
||||||
|
{
|
||||||
|
if (existingBySlug.TryGetValue(plugin.Slug, out var existingInstall))
|
||||||
|
{
|
||||||
|
if (existingInstall.UninstalledAt != null)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
UPDATE plugin_server_installs
|
||||||
|
SET version = @Version, btcpay_version = @BTCPayVersion, installed_at = @Now, updated_at = @Now, uninstalled_at = NULL, install_count = install_count + 1
|
||||||
|
WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
UPDATE plugin_server_installs
|
||||||
|
SET version = @Version, btcpay_version = @BTCPayVersion, updated_at = @Now
|
||||||
|
WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT INTO plugin_server_installs
|
||||||
|
(hashed_ip, plugin_slug, version, btcpay_version, installed_at, updated_at, uninstalled_at, install_count)
|
||||||
|
VALUES
|
||||||
|
(@HashedIp, @PluginSlug, @Version, @BTCPayVersion, @Now, @Now, NULL, 1)
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var install in existing.Where(x => x.UninstalledAt == null))
|
||||||
|
{
|
||||||
|
if (!reportedSlugs.Contains(install.PluginSlug))
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
UPDATE plugin_server_installs SET uninstalled_at = @Now, install_count = GREATEST(0, install_count - 1)
|
||||||
|
WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug
|
||||||
|
""",
|
||||||
|
new { HashedIp = hashedIp, PluginSlug = install.PluginSlug, Now = now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to record server snapshot telemetry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PluginStats> GetStats(string pluginSlug)
|
||||||
|
{
|
||||||
|
await using var conn = await connectionFactory.Open();
|
||||||
|
|
||||||
|
var installStats = await conn.QueryFirstOrDefaultAsync<(int TotalInstalls, int ActiveInstalls, int TotalUninstalls)>("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(install_count), 0) AS TotalInstalls,
|
||||||
|
COALESCE(COUNT(*) FILTER (WHERE uninstalled_at IS NULL), 0) AS ActiveInstalls,
|
||||||
|
COALESCE(COUNT(*) FILTER (WHERE uninstalled_at IS NOT NULL), 0) AS TotalUninstalls
|
||||||
|
FROM plugin_server_installs WHERE plugin_slug = @PluginSlug
|
||||||
|
""", new { PluginSlug = pluginSlug });
|
||||||
|
|
||||||
|
var totalInstalls = installStats == default ? 0 : installStats.TotalInstalls;
|
||||||
|
var activeInstalls = installStats == default ? 0 : installStats.ActiveInstalls;
|
||||||
|
var totalUninstalls = installStats == default ? 0 : installStats.TotalUninstalls;
|
||||||
|
|
||||||
|
var versionBreakdown = (await conn.QueryAsync<VersionStat>("""
|
||||||
|
SELECT COALESCE(version, 'unknown') AS Version, COALESCE(COUNT(*), 0) AS Count
|
||||||
|
FROM plugin_server_installs
|
||||||
|
WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL AND version IS NOT NULL
|
||||||
|
GROUP BY version
|
||||||
|
ORDER BY Count DESC
|
||||||
|
""", new { PluginSlug = pluginSlug })).ToList();
|
||||||
|
|
||||||
|
var btcpayVersionBreakdown = (await conn.QueryAsync<VersionStat>("""
|
||||||
|
SELECT COALESCE(btcpay_version, 'unknown') AS Version, COALESCE(COUNT(*), 0) AS Count
|
||||||
|
FROM plugin_server_installs
|
||||||
|
WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL AND btcpay_version IS NOT NULL
|
||||||
|
GROUP BY btcpay_version
|
||||||
|
ORDER BY Count DESC
|
||||||
|
""", new { PluginSlug = pluginSlug })).ToList();
|
||||||
|
|
||||||
|
return new PluginStats(
|
||||||
|
ActiveInstalls: installStats.ActiveInstalls,
|
||||||
|
TotalUninstalls: installStats.TotalUninstalls,
|
||||||
|
VersionBreakdown: versionBreakdown,
|
||||||
|
BTCPayVersionBreakdown: btcpayVersionBreakdown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseBTCPayUserAgent(string? userAgent, out string btcpayVersion)
|
||||||
|
{
|
||||||
|
btcpayVersion = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(userAgent))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var match = BTCPayUserAgentRegex.Match(userAgent);
|
||||||
|
if (!match.Success)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
btcpayVersion = match.Groups[1].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPublicIp(string? remoteIp, string? xOriginalFor, string? xForwardedFor, out string? result)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
var candidates = new[] { xOriginalFor, xForwardedFor, remoteIp };
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var raw = candidate.Split(',')[0].Trim();
|
||||||
|
|
||||||
|
if (raw.Contains("]:"))
|
||||||
|
raw = raw.Substring(1, raw.IndexOf(']') - 1);
|
||||||
|
else if (raw.Contains(':') && !raw.Contains('.') && raw.Count(c => c == ':') == 1)
|
||||||
|
raw = raw.Split(':')[0];
|
||||||
|
|
||||||
|
if (!IPAddress.TryParse(raw, out var ip))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (IsPrivateOrLoopback(ip))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result = raw;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static bool IsPrivateOrLoopback(IPAddress ip)
|
||||||
|
{
|
||||||
|
if (IPAddress.IsLoopback(ip))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (ip.IsIPv4MappedToIPv6)
|
||||||
|
ip = ip.MapToIPv4();
|
||||||
|
|
||||||
|
var bytes = ip.GetAddressBytes();
|
||||||
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
if (bytes[0] == 10)
|
||||||
|
return true;
|
||||||
|
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
|
||||||
|
return true;
|
||||||
|
if (bytes[0] == 192 && bytes[1] == 168)
|
||||||
|
return true;
|
||||||
|
if (bytes[0] == 169 && bytes[1] == 254)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
if ((bytes[0] & 0xFE) == 0xFC)
|
||||||
|
return true;
|
||||||
|
if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashIp(string ip)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(ip);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PluginReport(string Slug, string Version);
|
||||||
|
public record PluginStats(
|
||||||
|
int ActiveInstalls,
|
||||||
|
int TotalUninstalls,
|
||||||
|
List<VersionStat> VersionBreakdown,
|
||||||
|
List<VersionStat> BTCPayVersionBreakdown
|
||||||
|
);
|
||||||
|
|
||||||
|
public record VersionStat(string Version, long Count);
|
||||||
12
PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs
Normal file
12
PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace PluginBuilder.ViewModels.Plugin;
|
||||||
|
|
||||||
|
public class PluginTelemetryRequest
|
||||||
|
{
|
||||||
|
public List<PluginTelemetryItem> Plugins { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PluginTelemetryItem
|
||||||
|
{
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
public string? Version { get; set; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user