< 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
0%
Covered lines: 0
Uncovered lines: 99
Coverable lines: 99
Total lines: 229
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 20
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)0%156120%
BuildBlobPath(...)100%210%
GenerateUploadSasUrlAsync(...)100%210%
GetBlobMetadataAsync()0%620%
OpenReadAsync()0%620%
DeleteBlobAsync()100%210%
GenerateDownloadSasUrlAsync(...)100%210%
SanitizeFileName(...)0%620%
BuildSasUrl(...)0%620%

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;
 5using Chronicis.Shared.Extensions;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Azure Blob Storage service for managing world document files.
 11/// </summary>
 12public class BlobStorageService : IBlobStorageService
 13{
 14    private readonly BlobServiceClient _blobServiceClient;
 15    private readonly IConfiguration _configuration;
 16    private readonly ILogger<BlobStorageService> _logger;
 17    private readonly string _containerName;
 18    private readonly string? _customDomain;
 19
 020    public BlobStorageService(
 021        IConfiguration configuration,
 022        ILogger<BlobStorageService> logger)
 23    {
 024        _configuration = configuration;
 025        _logger = logger;
 26
 027        var connectionString = configuration["BlobStorage:ConnectionString"]
 028            ?? configuration["BlobStorage__ConnectionString"];  // Try double underscore format
 29
 030        if (string.IsNullOrEmpty(connectionString))
 31        {
 032            _logger.LogError("BlobStorage:ConnectionString not configured. Check Azure app settings.");
 033            throw new InvalidOperationException("BlobStorage:ConnectionString not configured. Please add BlobStorage__Co
 34        }
 35
 036        _containerName = configuration["BlobStorage:ContainerName"]
 037            ?? configuration["BlobStorage__ContainerName"]
 038            ?? "chronicis-documents";
 39
 40        // Optional custom domain (e.g., "http://docs.chronicis.app" or "https://docs.chronicis.app")
 041        _customDomain = configuration["BlobStorage:CustomDomain"]
 042            ?? configuration["BlobStorage__CustomDomain"];
 43
 044        if (!string.IsNullOrEmpty(_customDomain))
 45        {
 046            _logger.LogDebug("Using custom domain for blob URLs: {CustomDomain}", _customDomain);
 47        }
 48
 49        try
 50        {
 051            _blobServiceClient = new BlobServiceClient(connectionString);
 52
 53            // Ensure container exists (idempotent)
 054            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 055            containerClient.CreateIfNotExists(PublicAccessType.None);
 56
 057            _logger.LogDebug("BlobStorageService initialized with container: {ContainerName}", _containerName);
 058        }
 059        catch (Exception ex)
 60        {
 061            _logger.LogError(ex, "Failed to initialize BlobStorageService. Connection string may be invalid.");
 062            throw;
 63        }
 064    }
 65
 66    /// <inheritdoc/>
 67    public string BuildBlobPath(Guid worldId, Guid documentId, string fileName)
 68    {
 69        // Sanitize filename: remove path separators, keep only safe chars
 070        var sanitized = SanitizeFileName(fileName);
 071        return $"worlds/{worldId}/documents/{documentId}/{sanitized}";
 72    }
 73
 74    /// <inheritdoc/>
 75    public Task<string> GenerateUploadSasUrlAsync(
 76        Guid worldId,
 77        Guid documentId,
 78        string fileName,
 79        string contentType)
 80    {
 081        var blobPath = BuildBlobPath(worldId, documentId, fileName);
 082        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 083        var blobClient = containerClient.GetBlobClient(blobPath);
 84
 85        // Generate SAS token with write permissions, 15-minute expiry
 086        var sasBuilder = new BlobSasBuilder
 087        {
 088            BlobContainerName = _containerName,
 089            BlobName = blobPath,
 090            Resource = "b", // blob
 091            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // Allow for clock skew
 092            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 093        };
 94
 095        sasBuilder.SetPermissions(BlobSasPermissions.Create | BlobSasPermissions.Write);
 96
 097        var sasUrl = BuildSasUrl(blobClient, sasBuilder);
 98
 099        _logger.LogDebugSanitized("Generated upload SAS URL for blob: {BlobPath}", blobPath);
 100
 0101        return Task.FromResult(sasUrl);
 102    }
 103
 104    /// <inheritdoc/>
 105    public async Task<BlobMetadata?> GetBlobMetadataAsync(string blobPath)
 106    {
 107        try
 108        {
 0109            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 0110            var blobClient = containerClient.GetBlobClient(blobPath);
 111
 0112            if (!await blobClient.ExistsAsync())
 113            {
 0114                _logger.LogWarningSanitized("Blob not found: {BlobPath}", blobPath);
 0115                return null;
 116            }
 117
 0118            var properties = await blobClient.GetPropertiesAsync();
 119
 0120            return new BlobMetadata
 0121            {
 0122                SizeBytes = properties.Value.ContentLength,
 0123                ContentType = properties.Value.ContentType
 0124            };
 125        }
 0126        catch (RequestFailedException ex)
 127        {
 0128            _logger.LogErrorSanitized(ex, "Error getting blob metadata for: {BlobPath}", blobPath);
 0129            return null;
 130        }
 0131    }
 132
 133    /// <inheritdoc/>
 134    public async Task<Stream> OpenReadAsync(string blobPath)
 135    {
 0136        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 0137        var blobClient = containerClient.GetBlobClient(blobPath);
 138
 0139        if (!await blobClient.ExistsAsync())
 140        {
 0141            throw new FileNotFoundException($"Blob not found: {blobPath}");
 142        }
 143
 0144        return await blobClient.OpenReadAsync();
 0145    }
 146
 147    /// <inheritdoc/>
 148    public async Task DeleteBlobAsync(string blobPath)
 149    {
 150        try
 151        {
 0152            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 0153            var blobClient = containerClient.GetBlobClient(blobPath);
 154
 0155            await blobClient.DeleteIfExistsAsync();
 156
 0157            _logger.LogDebugSanitized("Deleted blob: {BlobPath}", blobPath);
 0158        }
 0159        catch (RequestFailedException ex)
 160        {
 0161            _logger.LogErrorSanitized(ex, "Error deleting blob: {BlobPath}", blobPath);
 0162            throw;
 163        }
 0164    }
 165
 166    /// <inheritdoc/>
 167    public Task<string> GenerateDownloadSasUrlAsync(string blobPath)
 168    {
 0169        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 0170        var blobClient = containerClient.GetBlobClient(blobPath);
 171
 172        // Generate SAS token with read permissions, 15-minute expiry
 0173        var sasBuilder = new BlobSasBuilder
 0174        {
 0175            BlobContainerName = _containerName,
 0176            BlobName = blobPath,
 0177            Resource = "b", // blob
 0178            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // Allow for clock skew
 0179            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 0180        };
 181
 0182        sasBuilder.SetPermissions(BlobSasPermissions.Read);
 183
 0184        var sasUrl = BuildSasUrl(blobClient, sasBuilder);
 185
 0186        _logger.LogDebugSanitized("Generated download SAS URL for blob: {BlobPath}", blobPath);
 187
 0188        return Task.FromResult(sasUrl);
 189    }
 190
 191    private static string SanitizeFileName(string fileName)
 192    {
 193        // Remove path separators and keep only safe characters
 0194        var invalidChars = Path.GetInvalidFileNameChars();
 0195        var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
 196
 197        // Limit length
 0198        if (sanitized.Length > 200)
 199        {
 0200            var extension = Path.GetExtension(sanitized);
 0201            var nameWithoutExt = Path.GetFileNameWithoutExtension(sanitized);
 0202            sanitized = nameWithoutExt[..(200 - extension.Length)] + extension;
 203        }
 204
 0205        return sanitized;
 206    }
 207
 208    /// <summary>
 209    /// Build a SAS URL using either the custom domain or the default blob endpoint.
 210    /// </summary>
 211    private string BuildSasUrl(BlobClient blobClient, BlobSasBuilder sasBuilder)
 212    {
 0213        if (!string.IsNullOrEmpty(_customDomain))
 214        {
 215            // Generate SAS token only (query string)
 0216            var sasToken = blobClient.GenerateSasUri(sasBuilder).Query;
 217
 218            // Build custom URL: {customDomain}/{container}/{blobPath}?{sasToken}
 0219            var customUrl = $"{_customDomain.TrimEnd('/')}/{_containerName}/{blobClient.Name}{sasToken}";
 220
 0221            return customUrl;
 222        }
 223        else
 224        {
 225            // Use default blob endpoint with SAS
 0226            return blobClient.GenerateSasUri(sasBuilder).ToString();
 227        }
 228    }
 229}