2f389ab8e9
- Fix ReconciliationController and BankSync LinkAccount/UpdateLinkedAccount IDOR (verify account ownership) - Add ASP.NET Core rate limiting: strict on auth endpoints, global on all API routes - Harden SSRF validation on Ollama URL (IPv6-mapped bypass, link-local, 0.0.0.0 blocking) - Upgrade encryption from AES-CBC to AES-GCM with HKDF key derivation (backward-compatible decrypt) - Add startup validation that rejects default JWT/encryption keys in Production - Add security headers (X-Frame-Options, CSP, HSTS, Cache-Control, nosniff) to API and nginx - Mask account numbers in API responses (show last 4 digits only) - Add password strength validation (min 8 chars) and BCrypt work factor 12 - Hash refresh tokens (SHA-256) before database storage - Set JWT ClockSkew to zero for immediate expiry enforcement - Add file upload size limit (10 MB) and filename sanitization - Cap transaction search PageSize to 200 - Fix FileWatcherService cross-user account matching in multi-user deployments - Sanitize console.error calls to prevent token leakage in browser logs - Parameterize raw SQL in SimpleFinConnectionMigration - Make AuthService.GenerateAuthResponse fully async Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
6.9 KiB
C#
187 lines
6.9 KiB
C#
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Purrse.Api.Hubs;
|
|
using Purrse.Core.Interfaces.Services;
|
|
using Purrse.Data;
|
|
|
|
namespace Purrse.Api.Services;
|
|
|
|
public class FileWatcherService : BackgroundService
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<FileWatcherService> _logger;
|
|
private readonly IHubContext<NotificationHub> _hubContext;
|
|
private readonly string _importsPath;
|
|
private FileSystemWatcher? _watcher;
|
|
|
|
private static readonly string[] SupportedExtensions = { ".ofx", ".qfx", ".csv", ".qif" };
|
|
|
|
public FileWatcherService(
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<FileWatcherService> logger,
|
|
IConfiguration config,
|
|
IHubContext<NotificationHub> hubContext)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
_hubContext = hubContext;
|
|
_importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports");
|
|
}
|
|
|
|
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
if (!Directory.Exists(_importsPath))
|
|
Directory.CreateDirectory(_importsPath);
|
|
|
|
var archivePath = Path.Combine(_importsPath, "archive");
|
|
if (!Directory.Exists(archivePath))
|
|
Directory.CreateDirectory(archivePath);
|
|
|
|
_watcher = new FileSystemWatcher(_importsPath)
|
|
{
|
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
|
|
EnableRaisingEvents = true,
|
|
IncludeSubdirectories = false
|
|
};
|
|
|
|
_watcher.Created += OnFileCreated;
|
|
|
|
_logger.LogInformation("File watcher started, monitoring: {Path}", _importsPath);
|
|
|
|
stoppingToken.Register(() =>
|
|
{
|
|
_watcher?.Dispose();
|
|
_logger.LogInformation("File watcher stopped");
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async void OnFileCreated(object sender, FileSystemEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var extension = Path.GetExtension(e.FullPath).ToLowerInvariant();
|
|
if (!SupportedExtensions.Contains(extension))
|
|
{
|
|
_logger.LogDebug("Ignoring non-supported file: {FileName}", e.Name);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("New import file detected: {FileName}", e.Name);
|
|
|
|
// Wait for file to finish writing
|
|
await WaitForFileReady(e.FullPath);
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
|
|
var importService = scope.ServiceProvider.GetRequiredService<IImportService>();
|
|
|
|
// Find the target account by matching filename to account name
|
|
// In multi-user setups, the filename format is "username--accountname.ext"
|
|
// If no separator, match against all accounts (single-user mode)
|
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name);
|
|
string? targetUsername = null;
|
|
string accountMatch = fileNameWithoutExt!;
|
|
|
|
if (fileNameWithoutExt!.Contains("--"))
|
|
{
|
|
var parts = fileNameWithoutExt.Split("--", 2);
|
|
targetUsername = parts[0];
|
|
accountMatch = parts[1];
|
|
}
|
|
|
|
var accountQuery = db.Accounts
|
|
.Include(a => a.User)
|
|
.Where(a => a.IsActive && !a.IsClosed);
|
|
|
|
if (targetUsername != null)
|
|
accountQuery = accountQuery.Where(a => a.User.Username.ToLower() == targetUsername.ToLower());
|
|
|
|
var account = await accountQuery
|
|
.FirstOrDefaultAsync(a => a.Name.ToLower() == accountMatch.ToLower());
|
|
|
|
// Fall back to first active account only if no username filter (single-user mode)
|
|
if (account == null && targetUsername == null)
|
|
{
|
|
account = await db.Accounts
|
|
.Where(a => a.IsActive && !a.IsClosed)
|
|
.OrderBy(a => a.SortOrder)
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
|
|
if (account == null)
|
|
{
|
|
_logger.LogWarning("No active account found for file import: {FileName}", e.Name);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Importing {FileName} into account {AccountName} ({AccountId})",
|
|
e.Name, account.Name, account.Id);
|
|
|
|
await using var fileStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
var result = await importService.UploadAsync(account.UserId, account.Id, e.Name!, fileStream);
|
|
|
|
_logger.LogInformation(
|
|
"File import complete: {FileName} - {Total} transactions ({New} new, {Duplicates} duplicates)",
|
|
e.Name, result.TotalTransactions, result.NewTransactions, result.PossibleDuplicates);
|
|
|
|
// Send SignalR notification to the account owner
|
|
await _hubContext.Clients.Group(account.UserId.ToString()).SendAsync("FileImportReady", new
|
|
{
|
|
batchId = result.BatchId,
|
|
fileName = e.Name,
|
|
accountId = account.Id,
|
|
accountName = account.Name,
|
|
totalTransactions = result.TotalTransactions,
|
|
newTransactions = result.NewTransactions,
|
|
possibleDuplicates = result.PossibleDuplicates
|
|
});
|
|
|
|
// Archive the processed file
|
|
ArchiveFile(e.FullPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing imported file: {FileName}", e.Name);
|
|
}
|
|
}
|
|
|
|
private static async Task WaitForFileReady(string filePath, int maxRetries = 30, int delayMs = 500)
|
|
{
|
|
for (int i = 0; i < maxRetries; i++)
|
|
{
|
|
try
|
|
{
|
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None);
|
|
return; // File is ready
|
|
}
|
|
catch (IOException)
|
|
{
|
|
await Task.Delay(delayMs);
|
|
}
|
|
}
|
|
|
|
throw new IOException($"File {filePath} was not ready after {maxRetries * delayMs}ms");
|
|
}
|
|
|
|
private void ArchiveFile(string filePath)
|
|
{
|
|
try
|
|
{
|
|
var archivePath = Path.Combine(_importsPath, "archive");
|
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
|
var fileName = Path.GetFileName(filePath);
|
|
var archiveFileName = $"{timestamp}_{fileName}";
|
|
var destination = Path.Combine(archivePath, archiveFileName);
|
|
|
|
File.Move(filePath, destination);
|
|
_logger.LogInformation("Archived import file to: {ArchivePath}", destination);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to archive file: {FilePath}", filePath);
|
|
}
|
|
}
|
|
}
|