using System; using System.IO; using System.Linq; 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.AspNetCore.Mvc; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing.Models; using StardewModdingAPI.Web.ViewModels; namespace StardewModdingAPI.Web.Controllers { /// Provides a web UI and API for parsing SMAPI log files. internal class LogParserController : Controller { /********* ** Fields *********/ /// The site config settings. private readonly SiteConfig SiteConfig; /// The API client settings. private readonly ApiClientsConfig ClientsConfig; /// The underlying Pastebin client. private readonly IPastebinClient Pastebin; /// The underlying text compression helper. private readonly IGzipHelper GzipHelper; /********* ** Public methods *********/ /*** ** Constructor ***/ /// Construct an instance. /// The context config settings. /// The API client settings. /// The Pastebin API client. /// The underlying text compression helper. public LogParserController(IOptions siteConfig, IOptions clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.SiteConfig = siteConfig.Value; this.ClientsConfig = clientsConfig.Value; this.Pastebin = pastebin; this.GzipHelper = gzipHelper; } /*** ** Web UI ***/ /// Render the log parser UI. /// The paste ID. /// Whether to display the raw unparsed log. [HttpGet] [Route("log")] [Route("log/{id}")] public async Task Index(string id = null, bool raw = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) return this.View("Index", this.GetModel(id)); // log page PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) : new ParsedLog { IsValid = false, Error = paste.Error }; return this.View("Index", this.GetModel(id, uploadWarning: paste.Warning, expiry: paste.Expiry).SetResult(log, raw)); } /*** ** JSON ***/ /// Save raw log data. [HttpPost, AllowLargePosts] [Route("log")] public async Task PostAsync() { // get raw log text string input = this.Request.Form["input"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(input)) return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); // upload log input = this.GzipHelper.CompressString(input); var uploadResult = await this.TrySaveLog(input); if (!uploadResult.Succeeded) return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); // redirect to view UriBuilder uri = new UriBuilder(new Uri(this.SiteConfig.LogParserUrl)); uri.Path = $"{uri.Path.TrimEnd('/')}/{uploadResult.ID}"; return this.Redirect(uri.Uri.ToString()); } /********* ** Private methods *********/ /// Fetch raw text from Pastebin. /// The Pastebin paste ID. private async Task 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.AmazonLogBucket, $"logs/{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 PasteInfo { Success = true, Content = content, Expiry = expiry, Warning = pastebinError }; } } catch (AmazonServiceException ex) { return ex.ErrorCode == "NoSuchKey" ? new PasteInfo { Error = "There's no log with that ID." } : new PasteInfo { Error = $"Could not fetch that log 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 response; } } /// Save a log to Pastebin or Amazon S3, if available. /// The content to upload. /// Returns metadata about the save attempt. private async Task TrySaveLog(string content) { // save to PasteBin string uploadError; { SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", 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.AmazonLogBucket, Key = $"logs/{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}"); } } /// Build a log parser model. /// The paste ID. /// When the uploaded file will no longer be available. /// A non-blocking warning while uploading the log. /// An error which occurred while uploading the log. private LogParserModel GetModel(string pasteID, DateTime? expiry = null, string uploadWarning = null, string uploadError = null) { string sectionUrl = this.SiteConfig.LogParserUrl; Platform? platform = this.DetectClientPlatform(); return new LogParserModel(sectionUrl, pasteID, platform) { UploadWarning = uploadWarning, UploadError = uploadError, Expiry = expiry }; } /// Detect the viewer's OS. /// Returns the viewer OS if known, else null. private Platform? DetectClientPlatform() { string userAgent = this.Request.Headers["User-Agent"]; switch (userAgent) { case string ua when ua.Contains("Windows"): return Platform.Windows; case string ua when ua.Contains("Android"): // check for Android before Linux because Android user agents also contain Linux return Platform.Android; case string ua when ua.Contains("Linux"): return Platform.Linux; case string ua when ua.Contains("Mac"): return Platform.Mac; default: return null; } } /// The result of an attempt to upload a file. private class UploadResult { /********* ** Accessors *********/ /// Whether the file upload succeeded. public bool Succeeded { get; } /// The file ID, if applicable. public string ID { get; } /// The upload error, if any. public string UploadError { get; } /********* ** Public methods *********/ /// Construct an instance. /// Whether the file upload succeeded. /// The file ID, if applicable. /// The upload error, if any. public UploadResult(bool succeeded, string id, string uploadError) { this.Succeeded = succeeded; this.ID = id; this.UploadError = uploadError; } } } }