Added new modules and updated existing logic

This commit is contained in:
Dieter Neumann
2026-02-24 13:32:01 +01:00
parent 2a4b4ed5fe
commit ad734273ce
694 changed files with 27935 additions and 610 deletions

View File

@@ -1,6 +1,8 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using HotlinePlanner.Data;
using HotlinePlanner.Models;
namespace HotlinePlanner.Services
{
@@ -9,33 +11,36 @@ namespace HotlinePlanner.Services
private readonly ILogger<CalendarWorker> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly IRefreshTokenStore _tokenStore;
private readonly CalendarWorkerStatus _status;
private readonly IServiceScopeFactory _scopeFactory;
public CalendarWorker(
ILogger<CalendarWorker> logger,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IRefreshTokenStore tokenStore,
CalendarWorkerStatus status)
CalendarWorkerStatus status,
IServiceScopeFactory scopeFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_tokenStore = tokenStore;
_status = status;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await LogEvent("BackgroundWorker", "Background worker starting", "Info");
var enabled = _configuration.GetValue("CalendarWorker:Enabled", true);
if (!enabled)
{
_logger.LogInformation("CalendarWorker disabled.");
await LogEvent("BackgroundWorker", "Background worker disabled by configuration", "Warning");
return;
}
var intervalMinutes = _configuration.GetValue("CalendarWorker:IntervalMinutes", 120);
var intervalMinutes = _configuration.GetValue("CalendarWorker:IntervalMinutes", 60);
var delay = TimeSpan.FromMinutes(intervalMinutes);
_status.UpdateNext(DateTime.UtcNow.Add(delay));
@@ -43,13 +48,16 @@ namespace HotlinePlanner.Services
{
try
{
await LogEvent("BackgroundWorker", "Starting hourly refresh cycle", "Info");
await RunOnce(stoppingToken);
_status.UpdateRun(DateTime.UtcNow, "Success");
await LogEvent("BackgroundWorker", "Hourly refresh cycle completed successfully", "Info");
}
catch (Exception ex)
{
_logger.LogError(ex, "CalendarWorker failed.");
_status.UpdateRun(DateTime.UtcNow, $"Error: {ex.GetType().Name}");
await LogEvent("BackgroundWorker", $"Refresh cycle failed: {ex.Message}", "Error");
}
try
@@ -62,18 +70,22 @@ namespace HotlinePlanner.Services
break;
}
}
await LogEvent("BackgroundWorker", "Background worker stopping", "Info");
}
private async Task RunOnce(CancellationToken stoppingToken)
{
var records = _tokenStore.GetAll()
.Where(r => string.Equals(r.Provider, "Microsoft", StringComparison.OrdinalIgnoreCase))
using var scope = _scopeFactory.CreateScope();
var tokenStore = scope.ServiceProvider.GetRequiredService<IRefreshTokenStore>();
var records = tokenStore.GetAll()
.Where(r => r.Enabled)
.ToList();
if (records.Count == 0)
{
_logger.LogInformation("CalendarWorker: no Microsoft refresh tokens found.");
_logger.LogInformation("CalendarWorker: no enabled refresh tokens found.");
return;
}
@@ -84,27 +96,62 @@ namespace HotlinePlanner.Services
break;
}
var (accessToken, newRefreshToken) = await RefreshAccessToken(record.RefreshToken, stoppingToken);
if (string.IsNullOrWhiteSpace(accessToken))
try
{
_logger.LogWarning("CalendarWorker: could not refresh access token for {UserId}.", record.UserId);
continue;
}
if (!string.IsNullOrWhiteSpace(newRefreshToken) &&
!string.Equals(newRefreshToken, record.RefreshToken, StringComparison.Ordinal))
{
_tokenStore.Upsert(record with { RefreshToken = newRefreshToken, UpdatedUtc = DateTime.UtcNow });
}
var (accessToken, newRefreshToken) = await RefreshAccessToken(record, stoppingToken);
if (string.IsNullOrWhiteSpace(accessToken))
{
var msg = $"Could not refresh access token for {record.UserId}";
_logger.LogWarning("CalendarWorker: {Message}", msg);
await LogEvent("BackgroundWorker", msg, "Warning", record.Tenant, record.UserId);
continue;
}
var ok = await CreateCalendarEvent(accessToken, stoppingToken);
if (!ok)
// Always update even if token is the same, to update LastUpdated timestamp
tokenStore.Upsert(record with
{
RefreshToken = !string.IsNullOrWhiteSpace(newRefreshToken) ? newRefreshToken : record.RefreshToken,
UpdatedUtc = DateTime.UtcNow
});
if (!string.IsNullOrWhiteSpace(newRefreshToken))
{
await LogEvent("BackgroundWorker", $"Refresh token rotated and stored for user {record.UserId}", "Info", record.Tenant, record.UserId);
}
// For now we still create a calendar event to verify it works
var ok = await CreateCalendarEvent(accessToken, stoppingToken);
if (!ok)
{
var msg = $"Failed to create calendar event for {record.UserId}";
_logger.LogWarning("CalendarWorker: {Message}", msg);
await LogEvent("BackgroundWorker", msg, "Warning", record.Tenant, record.UserId);
}
else
{
await LogEvent("BackgroundWorker", $"Successfully refreshed and created event for {record.UserId}", "Info", record.Tenant, record.UserId);
}
}
catch (Exception ex)
{
_logger.LogWarning("CalendarWorker: failed to create event for {UserId}.", record.UserId);
var msg = $"Error processing {record.UserId}: {ex.Message}";
_logger.LogError(ex, "CalendarWorker error");
await LogEvent("BackgroundWorker", msg, "Error", record.Tenant, record.UserId);
}
}
}
private async Task<(string? AccessToken, string? RefreshToken)> RefreshAccessToken(string refreshToken, CancellationToken stoppingToken)
private async Task<(string? AccessToken, string? RefreshToken)> RefreshAccessToken(RefreshTokenRecord record, CancellationToken stoppingToken)
{
if (string.Equals(record.Provider, "Microsoft", StringComparison.OrdinalIgnoreCase))
{
return await RefreshMicrosoftToken(record.RefreshToken, stoppingToken);
}
// Add Google support here if needed
return (null, null);
}
private async Task<(string? AccessToken, string? RefreshToken)> RefreshMicrosoftToken(string refreshToken, CancellationToken stoppingToken)
{
var clientId = _configuration["Authentication:Microsoft:ClientId"];
var clientSecret = _configuration["Authentication:Microsoft:ClientSecret"];
@@ -162,8 +209,8 @@ namespace HotlinePlanner.Services
var payload = new
{
subject = "Hotline Planner event",
body = new { contentType = "Text", content = "This event was created by Hotline Planner." },
subject = "Hotline Planner Sync (Background)",
body = new { contentType = "Text", content = "This event was created by the Hotline Planner background worker." },
start = new { dateTime = nowUtc.ToString("o"), timeZone = "UTC" },
end = new { dateTime = endUtc.ToString("o"), timeZone = "UTC" }
};
@@ -172,5 +219,31 @@ namespace HotlinePlanner.Services
var response = await client.PostAsync("https://graph.microsoft.com/v1.0/me/events", content, stoppingToken);
return response.IsSuccessStatusCode;
}
private async Task LogEvent(string category, string message, string level = "Info", string? tenant = null, string? userId = null, string? payload = null)
{
try
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
context.EventLogs.Add(new EventLog
{
Timestamp = DateTime.UtcNow,
Level = level,
Category = category,
Message = message,
Tenant = tenant,
UserId = userId,
Payload = payload
});
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to log event to database: {Message}", message);
}
}
}
}

View File

@@ -0,0 +1,81 @@
using HotlinePlanner.Data;
using HotlinePlanner.Models;
using Microsoft.EntityFrameworkCore;
namespace HotlinePlanner.Services
{
public class DatabaseRefreshTokenStore : IRefreshTokenStore
{
private readonly IServiceProvider _serviceProvider;
private readonly ITokenEncryptionService _encryptionService;
private readonly ILogger<DatabaseRefreshTokenStore> _logger;
public DatabaseRefreshTokenStore(
IServiceProvider serviceProvider,
ITokenEncryptionService encryptionService,
ILogger<DatabaseRefreshTokenStore> logger)
{
_serviceProvider = serviceProvider;
_encryptionService = encryptionService;
_logger = logger;
}
public IReadOnlyList<RefreshTokenRecord> GetAll()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
return context.Tokens
.Select(t => new RefreshTokenRecord
{
Provider = t.Provider,
Tenant = t.Tenant,
UserId = t.UserId,
UserEmail = t.UserEmail,
RefreshToken = _encryptionService.Decrypt(t.EncryptedToken),
UpdatedUtc = t.LastUpdated,
Enabled = t.Enabled
})
.ToList();
}
public void Upsert(RefreshTokenRecord record)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<HotlinePlannerDbContext>();
var existing = context.Tokens.FirstOrDefault(t =>
t.Provider == record.Provider &&
t.UserId == record.UserId);
if (existing != null)
{
existing.Tenant = record.Tenant;
existing.UserEmail = record.UserEmail;
existing.EncryptedToken = _encryptionService.Encrypt(record.RefreshToken);
existing.LastUpdated = record.UpdatedUtc;
existing.Enabled = record.Enabled;
context.Tokens.Update(existing);
}
else
{
context.Tokens.Add(new Token
{
Provider = record.Provider,
Tenant = record.Tenant,
UserId = record.UserId,
UserEmail = record.UserEmail,
EncryptedToken = _encryptionService.Encrypt(record.RefreshToken),
LastUpdated = record.UpdatedUtc,
Enabled = record.Enabled,
Timestamp = DateTime.UtcNow
});
}
context.SaveChanges();
_logger.LogInformation("Upserted token for user {UserId} (Provider: {Provider}, Tenant: {Tenant})",
record.UserId, record.Provider, record.Tenant);
}
}
}

View File

@@ -0,0 +1,8 @@
namespace HotlinePlanner.Services
{
public interface ITokenEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string encryptedText);
}
}

View File

@@ -5,7 +5,9 @@ namespace HotlinePlanner.Services
public record RefreshTokenRecord
{
public string Provider { get; init; } = "";
public string Tenant { get; init; } = "default";
public string UserId { get; init; } = "";
public string? UserEmail { get; init; }
public string RefreshToken { get; init; } = "";
public DateTime UpdatedUtc { get; init; }
public bool Enabled { get; init; }
@@ -16,76 +18,5 @@ namespace HotlinePlanner.Services
void Upsert(RefreshTokenRecord record);
IReadOnlyList<RefreshTokenRecord> GetAll();
}
public class FileRefreshTokenStore : IRefreshTokenStore
{
private readonly string _filePath;
private readonly object _lock = new();
public FileRefreshTokenStore(IConfiguration configuration)
{
var dataPath = configuration["CalendarWorker:TokenStorePath"] ?? "App_Data/refresh-tokens.json";
_filePath = Path.IsPathRooted(dataPath)
? dataPath
: Path.Combine(AppContext.BaseDirectory, dataPath);
}
public IReadOnlyList<RefreshTokenRecord> GetAll()
{
lock (_lock)
{
return LoadAllInternal();
}
}
public void Upsert(RefreshTokenRecord record)
{
lock (_lock)
{
var all = LoadAllInternal();
var existingIndex = all.FindIndex(r =>
string.Equals(r.Provider, record.Provider, StringComparison.OrdinalIgnoreCase) &&
string.Equals(r.UserId, record.UserId, StringComparison.Ordinal));
if (existingIndex >= 0)
{
all[existingIndex] = record;
}
else
{
all.Add(record);
}
SaveAllInternal(all);
}
}
private List<RefreshTokenRecord> LoadAllInternal()
{
if (!File.Exists(_filePath))
{
return new List<RefreshTokenRecord>();
}
var json = File.ReadAllText(_filePath);
if (string.IsNullOrWhiteSpace(json))
{
return new List<RefreshTokenRecord>();
}
var records = JsonSerializer.Deserialize<List<RefreshTokenRecord>>(json);
return records ?? new List<RefreshTokenRecord>();
}
private void SaveAllInternal(List<RefreshTokenRecord> records)
{
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.DataProtection;
namespace HotlinePlanner.Services
{
public class TokenEncryptionService : ITokenEncryptionService
{
private readonly IDataProtector _protector;
private const string Purpose = "HotlinePlanner.TokenStore.v1";
public TokenEncryptionService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector(Purpose);
}
public string Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText)) return plainText;
return _protector.Protect(plainText);
}
public string Decrypt(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText)) return encryptedText;
try
{
return _protector.Unprotect(encryptedText);
}
catch (Exception)
{
// In case of decryption failure (e.g. key change without migration)
// we might want to log this or handle it gracefully.
return string.Empty;
}
}
}
}