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.");
}
}
}