< 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: 240
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 UploadBlobAsync(string blobPath, byte[] content, string contentType)
 148    {
 149        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 150        var blobClient = containerClient.GetBlobClient(blobPath);
 151
 152        using var stream = new MemoryStream(content);
 153        await blobClient.UploadAsync(stream, new BlobHttpHeaders { ContentType = contentType }, conditions: null);
 154
 155        _logger.LogTraceSanitized("Uploaded blob: {BlobPath} ({Size} bytes)", blobPath, content.Length);
 156    }
 157
 158    /// <inheritdoc/>
 159    public async Task DeleteBlobAsync(string blobPath)
 160    {
 161        try
 162        {
 163            var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 164            var blobClient = containerClient.GetBlobClient(blobPath);
 165
 166            await blobClient.DeleteIfExistsAsync();
 167
 168            _logger.LogTraceSanitized("Deleted blob: {BlobPath}", blobPath);
 169        }
 170        catch (RequestFailedException ex)
 171        {
 172            _logger.LogErrorSanitized(ex, "Error deleting blob: {BlobPath}", blobPath);
 173            throw;
 174        }
 175    }
 176
 177    /// <inheritdoc/>
 178    public Task<string> GenerateDownloadSasUrlAsync(string blobPath)
 179    {
 1180        var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
 1181        var blobClient = containerClient.GetBlobClient(blobPath);
 182
 183        // Generate SAS token with read permissions, 15-minute expiry
 1184        var sasBuilder = new BlobSasBuilder
 1185        {
 1186            BlobContainerName = _containerName,
 1187            BlobName = blobPath,
 1188            Resource = "b", // blob
 1189            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // Allow for clock skew
 1190            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 1191        };
 192
 1193        sasBuilder.SetPermissions(BlobSasPermissions.Read);
 194
 1195        var sasUrl = BuildSasUrl(blobClient, sasBuilder);
 196
 1197        _logger.LogTraceSanitized("Generated download SAS URL for blob: {BlobPath}", blobPath);
 198
 1199        return Task.FromResult(sasUrl);
 200    }
 201
 202    private static string SanitizeFileName(string fileName)
 203    {
 204        // Remove path separators and keep only safe characters
 4205        var invalidChars = Path.GetInvalidFileNameChars();
 4206        var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
 207
 208        // Limit length
 4209        if (sanitized.Length > 200)
 210        {
 1211            var extension = Path.GetExtension(sanitized);
 1212            var nameWithoutExt = Path.GetFileNameWithoutExtension(sanitized);
 1213            sanitized = nameWithoutExt[..(200 - extension.Length)] + extension;
 214        }
 215
 4216        return sanitized;
 217    }
 218
 219    /// <summary>
 220    /// Build a SAS URL using either the custom domain or the default blob endpoint.
 221    /// </summary>
 222    private string BuildSasUrl(BlobClient blobClient, BlobSasBuilder sasBuilder)
 223    {
 4224        if (!string.IsNullOrEmpty(_customDomain))
 225        {
 226            // Generate SAS token only (query string)
 1227            var sasToken = blobClient.GenerateSasUri(sasBuilder).Query;
 228
 229            // Build custom URL: {customDomain}/{container}/{blobPath}?{sasToken}
 1230            var customUrl = $"{_customDomain.TrimEnd('/')}/{_containerName}/{blobClient.Name}{sasToken}";
 231
 1232            return customUrl;
 233        }
 234        else
 235        {
 236            // Use default blob endpoint with SAS
 3237            return blobClient.GenerateSasUri(sasBuilder).ToString();
 238        }
 239    }
 240}