Skip to content

Commit 3fdbf9b

Browse files
committed
Add webhook support and background sync service for release updates
- Introduced webhook endpoints to ClientPortal.Api for handling release notifications from Admin.Api. - Added `ReleaseSyncService` for periodic syncing of releases from Admin.Api. - Updated `appsettings` files to include sync and webhook configuration. - Created `Client` model in Admin.Shared for webhook client metadata. - Implemented webhook notification service in Admin.Api to notify clients of new releases. - Enhanced Program.cs in Admin.Api and ClientPortal.Api to configure webhook and sync functionality. - Updated Admin.Api to expose endpoints for syncing releases and update metadata through Cloudflare tunnels.
1 parent 78fd8ca commit 3fdbf9b

13 files changed

Lines changed: 787 additions & 12 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using Admin.Api.Domain.Interfaces;
2+
using Admin.Shared.Models;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
namespace Admin.Api.Endpoints.Sync;
6+
7+
/// <summary>
8+
/// Sync endpoints for client to pull updates from server through Cloudflare tunnel
9+
/// </summary>
10+
public static class SyncEndpoints
11+
{
12+
public static void MapSyncEndpoints(this IEndpointRouteBuilder app)
13+
{
14+
var group = app.MapGroup("/api/sync")
15+
.WithTags("Sync")
16+
.WithOpenApi();
17+
18+
// Endpoint for clients to pull active releases with their updates
19+
group.MapGet("/releases/active", GetActiveReleasesForSync)
20+
.WithName("SyncActiveReleases")
21+
.WithSummary("Get all active releases with update metadata for client synchronization")
22+
.Produces<List<ReleaseSyncDto>>(200);
23+
24+
// Endpoint for clients to pull update file metadata
25+
group.MapGet("/updates/{id:guid}/metadata", GetUpdateMetadata)
26+
.WithName("SyncUpdateMetadata")
27+
.WithSummary("Get update file metadata including hash and signature for verification")
28+
.Produces<UpdateMetadataDto>(200)
29+
.Produces(404);
30+
}
31+
32+
private static async Task<IResult> GetActiveReleasesForSync(
33+
[FromServices] IReleaseRepository releaseRepository,
34+
[FromServices] IUpdateRepository updateRepository)
35+
{
36+
var releases = await releaseRepository.GetActiveReleasesAsync();
37+
38+
var syncDtos = new List<ReleaseSyncDto>();
39+
40+
foreach (var release in releases)
41+
{
42+
var update = await updateRepository.GetByIdAsync(release.UpdateId);
43+
if (update == null) continue;
44+
45+
var fileInfo = new FileInfo(update.FilePath);
46+
47+
syncDtos.Add(new ReleaseSyncDto
48+
{
49+
ReleaseId = release.Id,
50+
UpdateId = update.Id,
51+
Version = update.Version,
52+
ReleaseDate = release.ReleaseDate,
53+
IsMandatory = release.IsMandatory,
54+
MaxPostponeDays = release.MaxPostponeDays,
55+
Severity = update.Severity.ToString(),
56+
Changelog = update.ChangeLog ?? string.Empty,
57+
CVEList = string.Join(", ", update.SecurityFixes),
58+
FileHash = update.FileHash,
59+
Signature = update.DigitalSignature,
60+
FileName = Path.GetFileName(update.FilePath),
61+
FileSizeBytes = fileInfo.Exists ? fileInfo.Length : 0
62+
});
63+
}
64+
65+
return Results.Ok(syncDtos);
66+
}
67+
68+
private static async Task<IResult> GetUpdateMetadata(
69+
Guid id,
70+
[FromServices] IUpdateRepository updateRepository)
71+
{
72+
var update = await updateRepository.GetByIdAsync(id);
73+
if (update == null)
74+
{
75+
return Results.NotFound($"Update with ID {id} not found");
76+
}
77+
78+
var fileInfo = new FileInfo(update.FilePath);
79+
80+
var metadata = new UpdateMetadataDto
81+
{
82+
Id = update.Id,
83+
Version = update.Version,
84+
FileName = Path.GetFileName(update.FilePath),
85+
FileHash = update.FileHash,
86+
Signature = update.DigitalSignature,
87+
FileSizeBytes = fileInfo.Exists ? fileInfo.Length : 0,
88+
Changelog = update.ChangeLog ?? string.Empty,
89+
CVEList = string.Join(", ", update.SecurityFixes)
90+
};
91+
92+
return Results.Ok(metadata);
93+
}
94+
}
95+
96+
/// <summary>
97+
/// DTO for syncing releases to client database
98+
/// </summary>
99+
public record ReleaseSyncDto
100+
{
101+
public Guid ReleaseId { get; init; }
102+
public Guid UpdateId { get; init; }
103+
public string Version { get; init; } = string.Empty;
104+
public DateTime ReleaseDate { get; init; }
105+
public bool IsMandatory { get; init; }
106+
public int MaxPostponeDays { get; init; }
107+
public string Severity { get; init; } = string.Empty;
108+
public string Changelog { get; init; } = string.Empty;
109+
public string CVEList { get; init; } = string.Empty;
110+
public string FileHash { get; init; } = string.Empty;
111+
public string Signature { get; init; } = string.Empty;
112+
public string FileName { get; init; } = string.Empty;
113+
public long FileSizeBytes { get; init; }
114+
}
115+
116+
/// <summary>
117+
/// DTO for update file metadata
118+
/// </summary>
119+
public record UpdateMetadataDto
120+
{
121+
public Guid Id { get; init; }
122+
public string Version { get; init; } = string.Empty;
123+
public string FileName { get; init; } = string.Empty;
124+
public string FileHash { get; init; } = string.Empty;
125+
public string Signature { get; init; } = string.Empty;
126+
public long FileSizeBytes { get; init; }
127+
public string Changelog { get; init; } = string.Empty;
128+
public string CVEList { get; init; } = string.Empty;
129+
}

src/Admin.Api/Program.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
using Admin.Api.Endpoints.Deployments;
44
using Admin.Api.Endpoints.Devices;
55
using Admin.Api.Endpoints.Releases;
6+
using Admin.Api.Endpoints.Sync;
67
using Admin.Api.Endpoints.Updates;
78
using Admin.Api.Infrastructure.Initialization;
89
using Admin.Api.Infrastructure.Persistence;
910
using Admin.Api.Infrastructure.Repositories;
1011
using Admin.Api.Infrastructure.Storage;
12+
using Admin.Api.Services;
1113

1214
var builder = WebApplication.CreateBuilder(args);
1315

@@ -29,6 +31,25 @@
2931
// Add storage service
3032
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
3133

34+
// Add webhook notification service
35+
builder.Services.AddScoped<IWebhookNotificationService, WebhookNotificationService>();
36+
37+
// Add HttpClient for webhooks
38+
builder.Services.AddHttpClient("WebhookClient", client =>
39+
{
40+
client.Timeout = TimeSpan.FromSeconds(10);
41+
})
42+
.ConfigurePrimaryHttpMessageHandler(() =>
43+
{
44+
var handler = new HttpClientHandler();
45+
if (builder.Environment.IsDevelopment())
46+
{
47+
handler.ServerCertificateCustomValidationCallback =
48+
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
49+
}
50+
return handler;
51+
});
52+
3253
// Add CORS
3354
builder.Services.AddCors(options =>
3455
{
@@ -80,6 +101,7 @@
80101
api.MapGroup("/releases").MapReleaseEndpoints();
81102
api.MapGroup("/devices").MapDeviceEndpoints();
82103
api.MapGroup("/deployments").MapDeploymentEndpoints();
104+
api.MapSyncEndpoints(); // Sync endpoints for client to pull through Cloudflare tunnel
83105

84106
// Health check
85107
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Service = "Admin.Api" }))
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
using Admin.Shared.Models;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using System.Text.Json;
5+
6+
namespace Admin.Api.Services;
7+
8+
/// <summary>
9+
/// Service for sending webhook notifications to clients when new releases are created
10+
/// </summary>
11+
public interface IWebhookNotificationService
12+
{
13+
Task NotifyClientsOfNewReleaseAsync(Release release, CancellationToken cancellationToken = default);
14+
Task<bool> TestWebhookAsync(Client client, CancellationToken cancellationToken = default);
15+
}
16+
17+
public class WebhookNotificationService : IWebhookNotificationService
18+
{
19+
private readonly IHttpClientFactory _httpClientFactory;
20+
private readonly ILogger<WebhookNotificationService> _logger;
21+
private readonly IConfiguration _configuration;
22+
23+
public WebhookNotificationService(
24+
IHttpClientFactory httpClientFactory,
25+
ILogger<WebhookNotificationService> logger,
26+
IConfiguration configuration)
27+
{
28+
_httpClientFactory = httpClientFactory;
29+
_logger = logger;
30+
_configuration = configuration;
31+
}
32+
33+
public async Task NotifyClientsOfNewReleaseAsync(Release release, CancellationToken cancellationToken = default)
34+
{
35+
// In a real implementation, fetch clients from database
36+
// For now, get from configuration or use sample data
37+
var clients = GetRegisteredClients();
38+
39+
_logger.LogInformation("Notifying {Count} clients of new release {ReleaseId}", clients.Count, release.Id);
40+
41+
var tasks = clients
42+
.Where(c => c.IsActive)
43+
.Select(client => SendWebhookToClientAsync(client, release, cancellationToken));
44+
45+
await Task.WhenAll(tasks);
46+
}
47+
48+
public async Task<bool> TestWebhookAsync(Client client, CancellationToken cancellationToken = default)
49+
{
50+
try
51+
{
52+
var payload = new
53+
{
54+
EventType = "webhook.test",
55+
Timestamp = DateTime.UtcNow,
56+
ClientId = client.Id,
57+
Message = "Webhook test from Admin.Api"
58+
};
59+
60+
var result = await SendWebhookAsync(client, payload, cancellationToken);
61+
return result;
62+
}
63+
catch (Exception ex)
64+
{
65+
_logger.LogError(ex, "Webhook test failed for client {ClientId}", client.Id);
66+
return false;
67+
}
68+
}
69+
70+
private async Task SendWebhookToClientAsync(Client client, Release release, CancellationToken cancellationToken)
71+
{
72+
try
73+
{
74+
var payload = new
75+
{
76+
EventType = "release.created",
77+
Timestamp = DateTime.UtcNow,
78+
ReleaseId = release.Id,
79+
UpdateId = release.UpdateId,
80+
ReleaseDate = release.ReleaseDate,
81+
IsMandatory = release.IsMandatory,
82+
MaxPostponeDays = release.MaxPostponeDays,
83+
IsActive = release.IsActive,
84+
Message = "New release available - please sync from /api/sync/releases/active"
85+
};
86+
87+
var success = await SendWebhookAsync(client, payload, cancellationToken);
88+
89+
if (success)
90+
{
91+
_logger.LogInformation("Webhook delivered successfully to client {ClientId} ({ClientName})",
92+
client.Id, client.Name);
93+
}
94+
else
95+
{
96+
_logger.LogWarning("Webhook delivery failed to client {ClientId} ({ClientName})",
97+
client.Id, client.Name);
98+
}
99+
}
100+
catch (Exception ex)
101+
{
102+
_logger.LogError(ex, "Error sending webhook to client {ClientId} ({ClientName})",
103+
client.Id, client.Name);
104+
}
105+
}
106+
107+
private async Task<bool> SendWebhookAsync(Client client, object payload, CancellationToken cancellationToken)
108+
{
109+
try
110+
{
111+
var httpClient = _httpClientFactory.CreateClient("WebhookClient");
112+
113+
// Serialize payload
114+
var jsonPayload = JsonSerializer.Serialize(payload);
115+
116+
// Generate HMAC signature for security
117+
var signature = GenerateHmacSignature(jsonPayload, client.WebhookSecret);
118+
119+
// Create HTTP request
120+
var request = new HttpRequestMessage(HttpMethod.Post, client.WebhookUrl)
121+
{
122+
Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json")
123+
};
124+
125+
// Add signature header
126+
request.Headers.Add("X-Webhook-Signature", signature);
127+
request.Headers.Add("X-Webhook-Client-Id", client.Id.ToString());
128+
129+
// Send request
130+
var response = await httpClient.SendAsync(request, cancellationToken);
131+
132+
if (response.IsSuccessStatusCode)
133+
{
134+
_logger.LogDebug("Webhook to {Url} returned {StatusCode}", client.WebhookUrl, response.StatusCode);
135+
return true;
136+
}
137+
else
138+
{
139+
_logger.LogWarning("Webhook to {Url} failed with status {StatusCode}",
140+
client.WebhookUrl, response.StatusCode);
141+
return false;
142+
}
143+
}
144+
catch (HttpRequestException ex)
145+
{
146+
_logger.LogError(ex, "Network error sending webhook to {Url}", client.WebhookUrl);
147+
return false;
148+
}
149+
catch (Exception ex)
150+
{
151+
_logger.LogError(ex, "Unexpected error sending webhook to {Url}", client.WebhookUrl);
152+
return false;
153+
}
154+
}
155+
156+
private string GenerateHmacSignature(string payload, string secret)
157+
{
158+
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
159+
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
160+
return Convert.ToBase64String(hash);
161+
}
162+
163+
/// <summary>
164+
/// Get registered clients from configuration
165+
/// In production, this would query from database via IClientRepository
166+
/// </summary>
167+
private List<Client> GetRegisteredClients()
168+
{
169+
var webhookConfig = _configuration.GetSection("Webhooks:Clients").Get<List<WebhookClient>>();
170+
if (webhookConfig == null || !webhookConfig.Any())
171+
{
172+
_logger.LogWarning("No webhook clients configured in appsettings");
173+
return new List<Client>();
174+
}
175+
176+
return webhookConfig.Select(wc => new Client
177+
{
178+
Id = Guid.NewGuid(),
179+
Name = wc.Name,
180+
WebhookUrl = wc.WebhookUrl,
181+
WebhookSecret = wc.WebhookSecret,
182+
IsActive = wc.IsActive
183+
}).ToList();
184+
}
185+
}
186+
187+
/// <summary>
188+
/// Configuration for webhook clients
189+
/// </summary>
190+
public class WebhookConfiguration
191+
{
192+
public List<WebhookClient> Clients { get; set; } = new();
193+
}
194+
195+
public class WebhookClient
196+
{
197+
public string Name { get; set; } = string.Empty;
198+
public string WebhookUrl { get; set; } = string.Empty;
199+
public string WebhookSecret { get; set; } = string.Empty;
200+
public bool IsActive { get; set; } = true;
201+
}

0 commit comments

Comments
 (0)