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;
}
}
}
}