All checks were successful
Build and Deploy / deploy (push) Successful in 42s
Mattermost and Gitea store usernames as lowercase but SilverDESK passes the original case (e.g. "Merlin" instead of "merlin"), causing 404/400 errors on case-sensitive API lookups. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
551 lines
22 KiB
C#
551 lines
22 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace SilverLabs.Website.Services;
|
|
|
|
public record PendingDeployment(
|
|
string Token,
|
|
string Username,
|
|
string Email,
|
|
string FullName,
|
|
string TicketId,
|
|
string? Role,
|
|
DateTime CreatedAt,
|
|
DateTime ExpiresAt);
|
|
|
|
public class ProvisioningService
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<ProvisioningService> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
|
|
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
|
|
|
|
public ProvisioningService(
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<ProvisioningService> logger,
|
|
IConfiguration configuration)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
}
|
|
|
|
// --- Token management ---
|
|
|
|
public PendingDeployment CreatePendingDeployment(string username, string email, string fullName, string ticketId, string? role = null)
|
|
{
|
|
CleanupExpiredTokens();
|
|
|
|
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
|
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
|
|
|
var deployment = new PendingDeployment(
|
|
token, username, email, fullName, ticketId, role,
|
|
DateTime.UtcNow, DateTime.UtcNow.AddHours(48));
|
|
|
|
_pendingDeployments[token] = deployment;
|
|
_logger.LogInformation("Created pending deployment for {Username} (ticket {TicketId}), token expires {ExpiresAt}",
|
|
username, ticketId, deployment.ExpiresAt);
|
|
|
|
return deployment;
|
|
}
|
|
|
|
public PendingDeployment? GetPendingDeployment(string token)
|
|
{
|
|
CleanupExpiredTokens();
|
|
|
|
if (_pendingDeployments.TryGetValue(token, out var deployment))
|
|
{
|
|
if (deployment.ExpiresAt > DateTime.UtcNow)
|
|
return deployment;
|
|
|
|
_pendingDeployments.TryRemove(token, out _);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public void RemovePendingDeployment(string token)
|
|
{
|
|
_pendingDeployments.TryRemove(token, out _);
|
|
}
|
|
|
|
private void CleanupExpiredTokens()
|
|
{
|
|
var expired = _pendingDeployments
|
|
.Where(kvp => kvp.Value.ExpiresAt <= DateTime.UtcNow)
|
|
.Select(kvp => kvp.Key)
|
|
.ToList();
|
|
|
|
foreach (var key in expired)
|
|
_pendingDeployments.TryRemove(key, out _);
|
|
}
|
|
|
|
// --- Authentication ---
|
|
|
|
public async Task<bool> ValidateSilverDeskCredentialsAsync(string username, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new { username, password };
|
|
var response = await client.PostAsJsonAsync("/api/auth/login", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("SilverDESK credential validation succeeded for {Username}", username);
|
|
return true;
|
|
}
|
|
|
|
_logger.LogWarning("SilverDESK credential validation failed for {Username}: {Status}", username, response.StatusCode);
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error validating SilverDESK credentials for {Username}", username);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- Full provisioning with password ---
|
|
|
|
public async Task<(bool Success, string Message)> ProvisionWithPasswordAsync(
|
|
string ticketId, string username, string email, string fullName, string password, string? role = null)
|
|
{
|
|
var results = new List<string>();
|
|
var allSuccess = true;
|
|
|
|
// 1. Create Mattermost user
|
|
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
|
|
results.Add($"Mattermost: {mmMsg}");
|
|
if (!mmOk) allSuccess = false;
|
|
|
|
// 1b. Add to SilverLABS team (only if user was created)
|
|
if (mmOk)
|
|
{
|
|
var (teamOk, teamMsg) = await AddMattermostUserToTeamAsync(username);
|
|
results.Add($"Mattermost Team: {teamMsg}");
|
|
if (!teamOk) allSuccess = false;
|
|
}
|
|
|
|
// 2. Create Mailcow mailbox
|
|
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
|
|
results.Add($"Mailcow: {mailMsg}");
|
|
if (!mailOk) allSuccess = false;
|
|
|
|
// 3. Create Gitea user (Developers only)
|
|
var giteaOk = false;
|
|
if (string.Equals(role, "Developer", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var (gOk, giteaMsg) = await CreateGiteaUserAsync(username, email, fullName, password);
|
|
giteaOk = gOk;
|
|
results.Add($"Gitea: {giteaMsg}");
|
|
if (!giteaOk) allSuccess = false;
|
|
}
|
|
else
|
|
{
|
|
results.Add("Gitea: Skipped (not required for Tester role)");
|
|
}
|
|
|
|
// 4. Update the DeveloperApplication record in SilverDESK
|
|
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk, giteaOk);
|
|
results.Add($"Application record: {updateMsg}");
|
|
if (!updateOk) allSuccess = false;
|
|
|
|
var summary = string.Join("; ", results);
|
|
_logger.LogInformation("Provisioning for {Username} (ticket {TicketId}): {Summary}", username, ticketId, summary);
|
|
|
|
return (allSuccess, summary);
|
|
}
|
|
|
|
// --- Ticket replies ---
|
|
|
|
public async Task<(bool Success, string Message)> SendTicketReplyAsync(string ticketId, string content, string action = "waitingcustomer")
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new { content, action };
|
|
var response = await client.PostAsJsonAsync($"/api/tickets/{ticketId}/reply", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Sent ticket reply to {TicketId} with action {Action}", ticketId, action);
|
|
return (true, "Reply sent");
|
|
}
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to send ticket reply to {TicketId}: {Status} {Body}", ticketId, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error sending ticket reply to {TicketId}", ticketId);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Password sync ---
|
|
|
|
public async Task<(bool Success, string Message)> SyncPasswordAsync(string username, string newPassword)
|
|
{
|
|
// Normalize username to lowercase - Mattermost and Gitea store usernames as lowercase
|
|
// and their API lookups are case-sensitive
|
|
var normalizedUsername = username.ToLowerInvariant();
|
|
var results = new List<string>();
|
|
var allSuccess = true;
|
|
|
|
// 1. Mattermost - need to look up user ID first
|
|
var (mmOk, mmMsg) = await UpdateMattermostPasswordAsync(normalizedUsername, newPassword);
|
|
results.Add($"Mattermost: {mmMsg}");
|
|
if (!mmOk) allSuccess = false;
|
|
|
|
// 2. Mailcow
|
|
var (mailOk, mailMsg) = await UpdateMailcowPasswordAsync(normalizedUsername, newPassword);
|
|
results.Add($"Mailcow: {mailMsg}");
|
|
if (!mailOk) allSuccess = false;
|
|
|
|
// 3. Gitea
|
|
var (giteaOk, giteaMsg) = await UpdateGiteaPasswordAsync(normalizedUsername, newPassword);
|
|
results.Add($"Gitea: {giteaMsg}");
|
|
if (!giteaOk) allSuccess = false;
|
|
|
|
var summary = string.Join("; ", results);
|
|
_logger.LogInformation("Password sync for {Username} (normalized: {NormalizedUsername}): {Summary}", username, normalizedUsername, summary);
|
|
|
|
return (allSuccess, summary);
|
|
}
|
|
|
|
// --- Application status update ---
|
|
|
|
private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
|
|
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
|
|
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
|
|
if (!lookupResponse.IsSuccessStatusCode)
|
|
{
|
|
var body = await lookupResponse.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to look up application by ticket {TicketId}: {Status} {Body}",
|
|
ticketId, lookupResponse.StatusCode, body);
|
|
return (false, $"Lookup failed ({lookupResponse.StatusCode})");
|
|
}
|
|
|
|
var apps = await lookupResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
if (apps.GetArrayLength() == 0)
|
|
{
|
|
_logger.LogWarning("No application found for ticket {TicketId}", ticketId);
|
|
return (false, "No application found for ticket");
|
|
}
|
|
|
|
var appId = apps[0].GetProperty("id").GetString();
|
|
|
|
var updatePayload = new
|
|
{
|
|
status = 1, // ApplicationStatus.Approved
|
|
mattermostProvisioned,
|
|
mailcowProvisioned,
|
|
giteaProvisioned
|
|
};
|
|
|
|
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload);
|
|
if (updateResponse.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Application {AppId} updated to Approved for ticket {TicketId}", appId, ticketId);
|
|
return (true, "Updated to Approved");
|
|
}
|
|
|
|
var updateBody = await updateResponse.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Failed to update application {AppId}: {Status} {Body}",
|
|
appId, updateResponse.StatusCode, updateBody);
|
|
return (false, $"Update failed ({updateResponse.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating application status for ticket {TicketId}", ticketId);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Service account creation ---
|
|
|
|
public async Task<(bool Success, string Message)> CreateSilverDeskUserAsync(string username, string email, string fullName)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("SilverDesk");
|
|
var payload = new
|
|
{
|
|
username,
|
|
email,
|
|
fullName,
|
|
password = Guid.NewGuid().ToString("N")[..16] + "!A1"
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("SilverDESK user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "SilverDESK user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateMattermostUserAsync(
|
|
string username, string email, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mattermost");
|
|
var nameParts = fullName.Split(' ', 2);
|
|
var payload = new
|
|
{
|
|
email,
|
|
username,
|
|
first_name = nameParts[0],
|
|
last_name = nameParts.Length > 1 ? nameParts[1] : "",
|
|
password
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v4/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Mattermost user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mattermost user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> AddMattermostUserToTeamAsync(string username)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mattermost");
|
|
|
|
// Look up user ID by username
|
|
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
|
|
if (!userResponse.IsSuccessStatusCode)
|
|
return (false, $"User lookup failed ({userResponse.StatusCode})");
|
|
|
|
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
var userId = userData.GetProperty("id").GetString();
|
|
|
|
// Add to SilverLABS team
|
|
var teamId = _configuration["Mattermost:TeamId"] ?? "ear83bc7nprzpe878ey7hxza7h";
|
|
var payload = new { team_id = teamId, user_id = userId };
|
|
var response = await client.PostAsJsonAsync($"/api/v4/teams/{teamId}/members", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Added to team");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Mattermost team join failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Team join failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mattermost team join error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(
|
|
string username, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mailcow");
|
|
var payload = new
|
|
{
|
|
local_part = username,
|
|
domain = "silverlabs.uk",
|
|
name = fullName,
|
|
password,
|
|
password2 = password,
|
|
quota = 1024, // 1GB
|
|
active = 1,
|
|
force_pw_update = 0
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/add/mailbox", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Mailbox created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Mailcow mailbox creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mailcow mailbox creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
|
|
string username, string email, string fullName, string password)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Gitea");
|
|
var payload = new
|
|
{
|
|
email,
|
|
full_name = fullName,
|
|
login_name = username,
|
|
must_change_password = false,
|
|
password,
|
|
send_notify = false,
|
|
username,
|
|
visibility = "public"
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/admin/users", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "User created");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Gitea user creation failed: {Status} {Body}", response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Gitea user creation error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// --- Password update methods ---
|
|
|
|
private async Task<(bool Success, string Message)> UpdateMattermostPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mattermost");
|
|
|
|
// Look up user ID by username
|
|
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
|
|
if (!userResponse.IsSuccessStatusCode)
|
|
{
|
|
var lookupBody = await userResponse.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Mattermost user lookup failed for {Username}: {Status} {Body}",
|
|
username, userResponse.StatusCode, lookupBody);
|
|
return (false, $"User not found ({userResponse.StatusCode})");
|
|
}
|
|
|
|
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
var userId = userData.GetProperty("id").GetString();
|
|
|
|
// Update password (admin reset — no old password needed with bot token)
|
|
var payload = new { new_password = newPassword };
|
|
var response = await client.PutAsJsonAsync($"/api/v4/users/{userId}/password", payload);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Mattermost password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mattermost password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> UpdateMailcowPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Mailcow");
|
|
var payload = new
|
|
{
|
|
items = new[] { $"{username}@silverlabs.uk" },
|
|
attr = new
|
|
{
|
|
password = newPassword,
|
|
password2 = newPassword
|
|
}
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await client.PostAsync("/api/v1/edit/mailbox", content);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Mailcow password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Mailcow password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<(bool Success, string Message)> UpdateGiteaPasswordAsync(string username, string newPassword)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient("Gitea");
|
|
var payload = new
|
|
{
|
|
login_name = username,
|
|
password = newPassword,
|
|
must_change_password = false,
|
|
source_id = 0
|
|
};
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/admin/users/{username}")
|
|
{
|
|
Content = content
|
|
};
|
|
|
|
var response = await client.SendAsync(request);
|
|
if (response.IsSuccessStatusCode)
|
|
return (true, "Password updated");
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("Gitea password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
|
|
return (false, $"Failed ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Gitea password update error for {Username}", username);
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
}
|