< Summary

Information
Class: Chronicis.Api.Services.BlobStorageService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/BlobStorageService.cs
Line coverage
100%
Covered lines: 66
Uncovered lines: 0
Coverable lines: 66
Total lines: 228
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%1212100%
BuildBlobPath(...)100%11100%
GenerateUploadSasUrlAsync(...)100%11100%
GenerateDownloadSasUrlAsync(...)100%11100%
SanitizeFileName(...)100%22100%
BuildSasUrl(...)100%22100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/BlobStorageService.cs

#LineLine coverage
 1using Azure;
 2using Azure.Storage.Blobs;
 3using Azure.Storage.Blobs.Models;
 4using Azure.Storage.Sas;
 5
 6namespace Chronicis.Api.Services;
 7
 8/// <summary>
 9/// Azure Blob Storage service for managing world document files.
 10/// </summary>
 11public sealed class BlobStorageService : IBlobStorageService
 12{
 13    private readonly BlobServiceClient _blobServiceClient;
 14    private readonly IConfiguration _configuration;
 15    private readonly ILogger<BlobStorageService> _logger;
 16    private readonly string _containerName;
 17    private readonly string? _customDomain;
 18
 19    public BlobStorageService(
 20        IConfiguration configuration,
 21        ILogger<BlobStorageService> logger)
 22    {
 423        _configuration = configuration;
 424        _logger = logger;
 25
 426        var connectionString = configuration["BlobStorage:ConnectionString"]
 427            ?? configuration["BlobStorage__ConnectionString"];  // Try double underscore format
 28
 429        if (string.IsNullOrEmpty(connectionString))
 30        {
 131            _logger.LogErrorSanitized("BlobStorage:ConnectionString not configured. Check Azure app settings.");
 132            throw new InvalidOperationException("BlobStorage:ConnectionString not configured. Please add BlobStorage__Co
 33        }
 34
 335        _containerName = configuration["BlobStorage:ContainerName"]
 336            ?? configuration["BlobStorage__ContainerName"]
 337            ?? "chronicis-documents";
 38
 39        // Optional custom domain (e.g., "http://docs.chronicis.app" or "https://docs.chronicis.app")
 340        _customDomain = configuration["BlobStorage:CustomDomain"]
 341            ?? configuration["BlobStorage__CustomDomain"];
 42
 343        if (!string.IsNullOrEmpty(_customDomain))
 44        {
 145            _logger.LogTraceSanitized("Using custom domain for blob URLs: {CustomDomain}", _customDomain);
 46        }
 47
 48        try
 49        {
 350            _blobServiceClient = new BlobServiceClient(connectionString);
 51
 52            // Ensure container exists (idempotent)
 153            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 154            containerClient.CreateIfNotExists(PublicAccessType.None);
 55
 156            _logger.LogTraceSanitized("BlobStorageService initialized with container: {ContainerName}", _containerName);
 157        }
 258        catch (Exception ex)
 59        {
 260            _logger.LogErrorSanitized(ex, "Failed to initialize BlobStorageService. Connection string may be invalid.");
 261            throw;
 62        }
 163    }
 64
 65    /// <inheritdoc/>
 66    public string BuildBlobPath(Guid worldId, Guid documentId, string fileName)
 67    {
 68        // Sanitize filename: remove path separators, keep only safe chars
 269        var sanitized = SanitizeFileName(fileName);
 270        return $"worlds/{worldId}/documents/{documentId}/{sanitized}";
 71    }
 72
 73    /// <inheritdoc/>
 74    public Task<string> GenerateUploadSasUrlAsync(
 75        Guid worldId,
 76        Guid documentId,
 77        string fileName,
 78        string contentType)
 79    {
 180        var blobPath = BuildBlobPath(worldId, documentId, fileName);
 181        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 182        var blobClient = containerClient.GetBlobClient(blobPath);
 83
 84        // Generate SAS token with write permissions, 15-minute expiry
 185        var sasBuilder = new BlobSasBuilder
 186        {
 187            BlobContainerName = _containerName,
 188            BlobName = blobPath,
 189            Resource = "b", // blob
 190            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // Allow for clock skew
 191            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 192        };
 93
 194        sasBuilder.SetPermissions(BlobSasPermissions.Create | BlobSasPermissions.Write);
 95
 196        var sasUrl = BuildSasUrl(blobClient, sasBuilder);
 97
 198        _logger.LogTraceSanitized("Generated upload SAS URL for blob: {BlobPath}", blobPath);
 99
 1100        return Task.FromResult(sasUrl);
 101    }
 102
 103    /// <inheritdoc/>
 104    public async Task<BlobMetadata?> GetBlobMetadataAsync(string blobPath)
 105    {
 106        try
 107        {
 108            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 109            var blobClient = containerClient.GetBlobClient(blobPath);
 110
 111            if (!await blobClient.ExistsAsync())
 112            {
 113                _logger.LogWarningSanitized("Blob not found: {BlobPath}", blobPath);
 114                return null;
 115            }
 116
 117            var properties = await blobClient.GetPropertiesAsync();
 118
 119            return new BlobMetadata
 120            {
 121                SizeBytes = properties.Value.ContentLength,
 122                ContentType = properties.Value.ContentType
 123            };
 124        }
 125        catch (RequestFailedException ex)
 126        {
 127            _logger.LogErrorSanitized(ex, "Error getting blob metadata for: {BlobPath}", blobPath);
 128            return null;
 129        }
 130    }
 131
 132    /// <inheritdoc/>
 133    public async Task<Stream> OpenReadAsync(string blobPath)
 134    {
 135        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 136        var blobClient = containerClient.GetBlobClient(blobPath);
 137
 138        if (!await blobClient.ExistsAsync())
 139        {
 140            throw new FileNotFoundException($"Blob not found: {blobPath}");
 141        }
 142
 143        return await blobClient.OpenReadAsync();
 144    }
 145
 146    /// <inheritdoc/>
 147    public async Task DeleteBlobAsync(string blobPath)
 148    {
 149        try
 150        {
 151            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 152            var blobClient = containerClient.GetBlobClient(blobPath);
 153
 154            await blobClient.DeleteIfExistsAsync();
 155
 156            _logger.LogTraceSanitized("Deleted blob: {BlobPath}", blobPath);
 157        }
 158        catch (RequestFailedException ex)
 159        {
 160            _logger.LogErrorSanitized(ex, "Error deleting blob: {BlobPath}", blobPath);
 161            throw;
 162        }
 163    }
 164
 165    /// <inheritdoc/>
 166    public Task<string> GenerateDownloadSasUrlAsync(string blobPath)
 167    {
 1168        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 1169        var blobClient = containerClient.GetBlobClient(blobPath);
 170
 171        // Generate SAS token with read permissions, 15-minute expiry
 1172        var sasBuilder = new BlobSasBuilder
 1173        {
 1174            BlobContainerName = _containerName,
 1175            BlobName = blobPath,
 1176            Resource = "b", // blob
 1177            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // Allow for clock skew
 1178            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 1179        };
 180
 1181        sasBuilder.SetPermissions(BlobSasPermissions.Read);
 182
 1183        var sasUrl = BuildSasUrl(blobClient, sasBuilder);
 184
 1185        _logger.LogTraceSanitized("Generated download SAS URL for blob: {BlobPath}", blobPath);
 186
 1187        return Task.FromResult(sasUrl);
 188    }
 189
 190    private static string SanitizeFileName(string fileName)
 191    {
 192        // Remove path separators and keep only safe characters
 4193        var invalidChars = Path.GetInvalidFileNameChars();
 4194        var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
 195
 196        // Limit length
 4197        if (sanitized.Length > 200)
 198        {
 1199            var extension = Path.GetExtension(sanitized);
 1200            var nameWithoutExt = Path.GetFileNameWithoutExtension(sanitized);
 1201            sanitized = nameWithoutExt[..(200 - extension.Length)] + extension;
 202        }
 203
 4204        return sanitized;
 205    }
 206
 207    /// <summary>
 208    /// Build a SAS URL using either the custom domain or the default blob endpoint.
 209    /// </summary>
 210    private string BuildSasUrl(BlobClient blobClient, BlobSasBuilder sasBuilder)
 211    {
 4212        if (!string.IsNullOrEmpty(_customDomain))
 213        {
 214            // Generate SAS token only (query string)
 1215            var sasToken = blobClient.GenerateSasUri(sasBuilder).Query;
 216
 217            // Build custom URL: {customDomain}/{container}/{blobPath}?{sasToken}
 1218            var customUrl = $"{_customDomain.TrimEnd('/')}/{_containerName}/{blobClient.Name}{sasToken}";
 219
 1220            return customUrl;
 221        }
 222        else
 223        {
 224            // Use default blob endpoint with SAS
 3225            return blobClient.GenerateSasUri(sasBuilder).ToString();
 226        }
 227    }
 228}