Files
maraphon-app/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs
T
alexei.dolgolyov 08486667c3 fix(export): neutralize CSV/DDE formula injection in exported text
Exported journal notes and scraped event titles could begin with a formula
trigger (= + - @, tab, CR) that Excel/LibreOffice execute on open.
Csv.NeutralizeFormula apostrophe-prefixes such cells so they render as text;
applied to user notes, raw event ids and scraped titles. Numeric/date cells
the exporter formats itself stay numeric for downstream analysis.
2026-05-29 13:57:18 +03:00

135 lines
6.0 KiB
C#

using System.Globalization;
using System.Text;
using Marathon.Application.Abstractions;
using Marathon.Application.Export;
using Marathon.Application.Storage;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in
/// the configured export directory, returning each file's path (or null when there is
/// nothing to export). Mirrors <see cref="ExportToExcelUseCase"/>'s write-and-return-path
/// contract; CSV needs no third-party library so it stays in the Application layer.
/// </summary>
public sealed class ExportToCsvUseCase
{
// BOM so Excel opens UTF-8 (Cyrillic team names) correctly.
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
private readonly IPlacedBetRepository _bets;
private readonly IPaperBetRepository _paperBets;
private readonly IEventRepository _events;
private readonly IOptions<StorageOptions> _storage;
private readonly ILogger<ExportToCsvUseCase> _logger;
public ExportToCsvUseCase(
IPlacedBetRepository bets,
IPaperBetRepository paperBets,
IEventRepository events,
IOptions<StorageOptions> storage,
ILogger<ExportToCsvUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Writes the bet journal to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportJournalAsync(CancellationToken ct = default)
{
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes",
};
var rows = bets
.OrderByDescending(b => b.PlacedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
Csv.NeutralizeFormula(b.EventId.Value),
b.Selection.Type.ToString(),
b.Selection.Side.ToString(),
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
Csv.NeutralizeFormula(b.Notes),
});
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
/// <summary>Writes the paper-trading ledger to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportPaperLedgerAsync(CancellationToken ct = default)
{
var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt",
};
var rows = bets
.OrderByDescending(b => b.OpenedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
Csv.NeutralizeFormula(b.EventId.Value),
b.PickedSide.ToString(),
b.Rate.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
});
return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
private async Task<string> WriteAsync(string label, string content, CancellationToken ct)
{
var dir = _storage.Value.ExportDirectory;
Directory.CreateDirectory(dir);
var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv";
var path = Path.Combine(dir, fileName);
await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false);
_logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path);
return path;
}
private async Task<IReadOnlyDictionary<DomainEventId, string>> TitlesAsync(
IEnumerable<DomainEventId> ids, CancellationToken ct)
{
var distinct = ids.Distinct().ToList();
var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(events.Count);
foreach (var (id, ev) in events)
titles[id] = ev.Title;
return titles;
}
// Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
// neutralized against CSV/formula injection.
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
}