using System.Collections.Concurrent; using Amazon; using Amazon.S3; using GB5Shared.Connection; using GB5Shared.DTO.ECM; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; namespace GB5Shared.Storage { /// /// Resolves the to use for a given login, based on the /// tenant DB's own MDBLEVELSETTING.ATTACHMENTOPTION — not a single static provider per /// deployment. Each tenant DB (MDBLEVELSETTING is a single row, no ClientId filter) picks /// its own backend: 0=Alfresco, 1=FileNet, 2=AmazonS3, 3=FileBased. /// public interface IAttachmentStorageResolver { /// Resolves storage for a new write, using the tenant's current ATTACHMENTOPTION. Task ResolveAsync(LoginDTO login, CancellationToken ct = default); /// /// Resolves storage for an existing attachment using the option it was actually stored /// under (TATTACHMENT.ATTACHMENTOPTION) rather than the tenant's current setting — required /// for delete/download to keep working after a tenant switches provider, since files /// uploaded under the old provider don't move themselves. /// Task ResolveAsync(LoginDTO login, byte attachmentOption, CancellationToken ct = default); } public sealed class AttachmentStorageResolver : IAttachmentStorageResolver { private readonly IQueryExecutor _qe; private readonly IApplicationConnection _appConnection; private readonly NetworkStorageProvider _networkProvider; private readonly HybridCache _cache; private readonly ILogger _log; // AmazonS3Client is HttpClient-backed and meant to be reused, not built per call — // cached per tenant so repeat uploads don't re-decrypt/reconnect. Keyed on the raw // (still-encrypted) access key alongside the tenant DB name, so a credential change // in MDBLEVELSETTING naturally produces a new key instead of serving a stale client // (same trick as ApplicationConnection._sysConnStrCache). private static readonly ConcurrentDictionary _s3ClientCache = new(); private const string LoadSettingsSql = @" SELECT ATTACHMENTOPTION AS AttachmentOption, ISNULL(AMAZONS3ACCESSKEY, '') AS AmazonS3AccessKey, ISNULL(AMAZONS3SECRETKEY, '') AS AmazonS3SecretKey, ISNULL(AMAZONS3BUCKETNAME, '') AS AmazonS3BucketName, ISNULL(AMAZONS3REGION, '') AS AmazonS3Region FROM MDBLEVELSETTING"; public AttachmentStorageResolver( IQueryExecutor qe, IApplicationConnection appConnection, NetworkStorageProvider networkProvider, HybridCache cache, ILogger log) { _qe = qe; _appConnection = appConnection; _networkProvider = networkProvider; _cache = cache; _log = log; } public Task ResolveAsync(LoginDTO login, CancellationToken ct = default) => ResolveCoreAsync(login, explicitOption: null, ct); public Task ResolveAsync(LoginDTO login, byte attachmentOption, CancellationToken ct = default) => ResolveCoreAsync(login, explicitOption: attachmentOption, ct); private async Task ResolveCoreAsync(LoginDTO login, byte? explicitOption, CancellationToken ct) { // S3 credentials always come from this tenant's own MDBLEVELSETTING row — // even when explicitOption pins the decision to a specific (possibly older) // backend, the bucket/keys used to reach it are still today's configured ones. var settings = await _cache.GetOrCreateAsync( $"attachment_storage_settings:{login.DatabaseName}", async cacheCt => await _qe.QuerySingleAsync( login, LoadSettingsSql, cancellationToken: cacheCt).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false); var option = (AttachmentOptionType)(explicitOption ?? settings.AttachmentOption); return option switch { AttachmentOptionType.AmazonS3 => BuildS3Provider(login, settings), AttachmentOptionType.FileBased => _networkProvider, var other => throw new NotSupportedException( $"Attachment storage option '{other}' is not yet implemented for database '{login.DatabaseName}'.") }; } private S3StorageProvider BuildS3Provider(LoginDTO login, AmazonS3Settings settings) { if (string.IsNullOrWhiteSpace(settings.AmazonS3AccessKey) || string.IsNullOrWhiteSpace(settings.AmazonS3SecretKey) || string.IsNullOrWhiteSpace(settings.AmazonS3BucketName)) { throw new InvalidOperationException( $"AttachmentOption is AmazonS3 for database '{login.DatabaseName}' but " + "MDBLEVELSETTING has no S3 access key / secret key / bucket name configured."); } var clientCacheKey = $"{login.DatabaseName}:{settings.AmazonS3AccessKey}"; var client = _s3ClientCache.GetOrAdd(clientCacheKey, _ => { _log.LogInformation( "Creating AmazonS3Client for database {DatabaseName} (bucket {Bucket}, region {Region})", login.DatabaseName, settings.AmazonS3BucketName, settings.AmazonS3Region); var accessKey = _appConnection.DecryptPasswordHash(settings.AmazonS3AccessKey, true); var secretKey = _appConnection.DecryptPasswordHash(settings.AmazonS3SecretKey, true); return BuildS3Client(accessKey, secretKey, settings.AmazonS3Region); }); return new S3StorageProvider(client, settings.AmazonS3BucketName); } // Matches the legacy GB4 S3FrameBLL.SendMyFileToS3Latest region mapping. // MDBLEVELSETTING.AMAZONS3REGION stores GB4's short codes (e.g. "apsouth1", no // hyphens) — RegionEndpoint.GetBySystemName expects AWS's canonical hyphenated // system names ("ap-south-1") and won't recognize these, so it must be resolved // via this explicit table instead to keep every existing tenant row working. private static AmazonS3Client BuildS3Client(string accessKey, string secretKey, string region) { var endpoint = (region ?? string.Empty).Trim().ToLowerInvariant() switch { "apeast1" => RegionEndpoint.APEast1, "apnortheast1" => RegionEndpoint.APNortheast1, "apnortheast2" => RegionEndpoint.APNortheast2, "apnortheast3" => RegionEndpoint.APNortheast3, "apsouth1" => RegionEndpoint.APSouth1, "apsoutheast1" => RegionEndpoint.APSoutheast1, "apsoutheast2" => RegionEndpoint.APSoutheast2, "cacentral1" => RegionEndpoint.CACentral1, "cnnorth1" => RegionEndpoint.CNNorth1, "cnnorthwest1" => RegionEndpoint.CNNorthWest1, "eucentral1" => RegionEndpoint.EUCentral1, "eunorth1" => RegionEndpoint.EUNorth1, "euwest1" => RegionEndpoint.EUWest1, "euwest2" => RegionEndpoint.EUWest2, "euwest3" => RegionEndpoint.EUWest3, "saeast1" => RegionEndpoint.SAEast1, "useast1" => RegionEndpoint.USEast1, "useast2" => RegionEndpoint.USEast2, "usgovcloudeast1" => RegionEndpoint.USGovCloudEast1, "usgovcloudwest1" => RegionEndpoint.USGovCloudWest1, "uswest1" => RegionEndpoint.USWest1, "uswest2" => RegionEndpoint.USWest2, _ => null }; if (endpoint is not null) return new AmazonS3Client(accessKey, secretKey, endpoint); if (region is not null && region.StartsWith("https:", StringComparison.OrdinalIgnoreCase)) return new AmazonS3Client(accessKey, secretKey, new AmazonS3Config { ServiceURL = region }); throw new NotSupportedException($"Region '{region}' not found in Amazon S3 List."); } } }