using System; using System.IO; using System.Text; using System.Threading.Tasks; using Amazon; using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; using Microsoft.Extensions.Options; using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; namespace StardewModdingAPI.Web.Framework.Storage { /// <summary>Provides access to raw data storage.</summary> internal class StorageProvider : IStorageProvider { /********* ** Fields *********/ /// <summary>The API client settings.</summary> private readonly ApiClientsConfig ClientsConfig; /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; /// <summary>The underlying text compression helper.</summary> private readonly IGzipHelper GzipHelper; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="clientsConfig">The API client settings.</param> /// <param name="pastebin">The underlying Pastebin client.</param> /// <param name="gzipHelper">The underlying text compression helper.</param> public StorageProvider(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.ClientsConfig = clientsConfig.Value; this.Pastebin = pastebin; this.GzipHelper = gzipHelper; } /// <summary>Save a text file to Pastebin or Amazon S3, if available.</summary> /// <param name="title">The display title, if applicable.</param> /// <param name="content">The content to upload.</param> /// <param name="compress">Whether to gzip the text.</param> /// <returns>Returns metadata about the save attempt.</returns> public async Task<UploadResult> SaveAsync(string title, string content, bool compress = true) { // save to PasteBin string uploadError; { SavePasteResult result = await this.Pastebin.PostAsync(title, content); if (result.Success) return new UploadResult(true, result.ID, null); uploadError = $"Pastebin error: {result.Error ?? "unknown error"}"; } // fallback to S3 try { var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey); using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); using IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)); using TransferUtility uploader = new TransferUtility(s3); string id = Guid.NewGuid().ToString("N"); var uploadRequest = new TransferUtilityUploadRequest { BucketName = this.ClientsConfig.AmazonTempBucket, Key = $"uploads/{id}", InputStream = stream, Metadata = { // note: AWS will lowercase keys and prefix 'x-amz-meta-' ["smapi-uploaded"] = DateTime.UtcNow.ToString("O"), ["pastebin-error"] = uploadError } }; await uploader.UploadAsync(uploadRequest); return new UploadResult(true, id, uploadError); } catch (Exception ex) { return new UploadResult(false, null, $"{uploadError}\n{ex.Message}"); } } /// <summary>Fetch raw text from storage.</summary> /// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param> public async Task<StoredFileInfo> GetAsync(string id) { // get from Amazon S3 if (Guid.TryParseExact(id, "N", out Guid _)) { var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey); using IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)); try { using GetObjectResponse response = await s3.GetObjectAsync(this.ClientsConfig.AmazonTempBucket, $"uploads/{id}"); using Stream responseStream = response.ResponseStream; using StreamReader reader = new StreamReader(responseStream); DateTime expiry = response.Expiration.ExpiryDateUtc; string pastebinError = response.Metadata["x-amz-meta-pastebin-error"]; string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); return new StoredFileInfo { Success = true, Content = content, Expiry = expiry, Warning = pastebinError }; } catch (AmazonServiceException ex) { return ex.ErrorCode == "NoSuchKey" ? new StoredFileInfo { Error = "There's no file with that ID." } : new StoredFileInfo { Error = $"Could not fetch that file from AWS S3 ({ex.ErrorCode}: {ex.Message})." }; } } // get from PasteBin else { PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.GzipHelper.DecompressString(response.Content); return new StoredFileInfo { Success = response.Success, Content = response.Content, Error = response.Error }; } } } }