Added new modules and updated existing logic
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace HotlinePlanner.Services
|
||||
{
|
||||
public interface ITokenEncryptionService
|
||||
{
|
||||
string Encrypt(string plainText);
|
||||
string Decrypt(string encryptedText);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user