128 lines
3.9 KiB
C#
128 lines
3.9 KiB
C#
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Platform.Analytics.Options;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace StellaOps.Platform.Analytics.Services;
|
|
|
|
public interface ICasContentReader
|
|
{
|
|
Task<CasContent?> OpenReadAsync(string casUri, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed record CasContent(Stream Stream, long? Length);
|
|
|
|
public sealed class FileCasContentReader : ICasContentReader
|
|
{
|
|
private readonly AnalyticsCasOptions _options;
|
|
private readonly ILogger<FileCasContentReader> _logger;
|
|
|
|
public FileCasContentReader(
|
|
IOptions<AnalyticsIngestionOptions> options,
|
|
ILogger<FileCasContentReader> logger)
|
|
{
|
|
_options = options?.Value.Cas ?? new AnalyticsCasOptions();
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public Task<CasContent?> OpenReadAsync(string casUri, CancellationToken cancellationToken)
|
|
{
|
|
if (!TryParseCasUri(casUri, out var reference))
|
|
{
|
|
_logger.LogWarning("Unsupported CAS URI '{CasUri}'.", casUri);
|
|
return Task.FromResult<CasContent?>(null);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(_options.RootPath))
|
|
{
|
|
_logger.LogWarning("CAS root path not configured; skipping {CasUri}.", casUri);
|
|
return Task.FromResult<CasContent?>(null);
|
|
}
|
|
|
|
var root = Path.GetFullPath(_options.RootPath);
|
|
foreach (var candidate in ExpandKeyCandidates(reference.Key))
|
|
{
|
|
var keyPath = candidate.Replace('/', Path.DirectorySeparatorChar);
|
|
var resolved = Path.GetFullPath(Path.Combine(root, reference.Bucket, keyPath));
|
|
|
|
if (!resolved.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogWarning("CAS URI '{CasUri}' resolved outside root '{Root}'.", casUri, root);
|
|
return Task.FromResult<CasContent?>(null);
|
|
}
|
|
|
|
if (!File.Exists(resolved))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var stream = new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
var length = new FileInfo(resolved).Length;
|
|
return Task.FromResult<CasContent?>(new CasContent(stream, length));
|
|
}
|
|
|
|
_logger.LogWarning("CAS object not found at '{Key}' for '{CasUri}'.", reference.Key, casUri);
|
|
return Task.FromResult<CasContent?>(null);
|
|
}
|
|
|
|
private bool TryParseCasUri(string casUri, out CasReference reference)
|
|
{
|
|
reference = default!;
|
|
|
|
if (string.IsNullOrWhiteSpace(casUri))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!Uri.TryCreate(casUri, UriKind.Absolute, out var uri) ||
|
|
!string.Equals(uri.Scheme, "cas", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var bucket = uri.Host;
|
|
var key = uri.AbsolutePath.TrimStart('/');
|
|
|
|
if (string.IsNullOrWhiteSpace(bucket))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_options.DefaultBucket))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bucket = _options.DefaultBucket!;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
reference = new CasReference(casUri, bucket, key);
|
|
return true;
|
|
}
|
|
|
|
private static IEnumerable<string> ExpandKeyCandidates(string key)
|
|
{
|
|
yield return key;
|
|
|
|
var colonIndex = key.IndexOf(':');
|
|
if (colonIndex <= 0 || colonIndex >= key.Length - 1)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
var prefix = key[..colonIndex];
|
|
var suffix = key[(colonIndex + 1)..];
|
|
yield return $"{prefix}/{suffix}";
|
|
yield return suffix;
|
|
}
|
|
}
|
|
|
|
public sealed record CasReference(string Uri, string Bucket, string Key);
|