summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-12-26 00:31:36 -0500
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-12-26 00:31:36 -0500
commit15d4b6310e3dd15c62f3faedbf1290b2db26fb59 (patch)
tree47d49a9c69628f0df1e688361f46bc5b46b3c0fd /src/SMAPI.Web
parent5cc5f089b9645a60385ff293b5a7202f260bfc0f (diff)
parentf19cc3aac1a781bf2f2d20bc9577c2fe929b1e96 (diff)
downloadSMAPI-15d4b6310e3dd15c62f3faedbf1290b2db26fb59.tar.gz
SMAPI-15d4b6310e3dd15c62f3faedbf1290b2db26fb59.tar.bz2
SMAPI-15d4b6310e3dd15c62f3faedbf1290b2db26fb59.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs93
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs29
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs34
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs83
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs18
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs20
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs70
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs25
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs48
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs21
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs17
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs (renamed from src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs)4
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs (renamed from src/SMAPI.Web/Framework/LogParser/PastebinClient.cs)30
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs (renamed from src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs)4
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs72
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs15
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs24
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs49
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs50
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs59
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs51
-rw-r--r--src/SMAPI.Web/Properties/launchSettings.json2
-rw-r--r--src/SMAPI.Web/StardewModdingAPI.Web.csproj1
-rw-r--r--src/SMAPI.Web/Startup.cs44
-rw-r--r--src/SMAPI.Web/ViewModels/IndexModel.cs41
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml68
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml10
-rw-r--r--src/SMAPI.Web/appsettings.Development.json12
-rw-r--r--src/SMAPI.Web/appsettings.json30
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/index.css58
33 files changed, 846 insertions, 287 deletions
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
new file mode 100644
index 00000000..5d45118f
--- /dev/null
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Memory;
+using StardewModdingAPI.Web.Framework.Clients.GitHub;
+using StardewModdingAPI.Web.ViewModels;
+
+namespace StardewModdingAPI.Web.Controllers
+{
+ /// <summary>Provides an info/download page about SMAPI.</summary>
+ [Route("")]
+ [Route("install")]
+ internal class IndexController : Controller
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The cache in which to store release data.</summary>
+ private readonly IMemoryCache Cache;
+
+ /// <summary>The GitHub API client.</summary>
+ private readonly IGitHubClient GitHub;
+
+ /// <summary>The cache time for release info.</summary>
+ private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(5);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="cache">The cache in which to store release data.</param>
+ /// <param name="github">The GitHub API client.</param>
+ public IndexController(IMemoryCache cache, IGitHubClient github)
+ {
+ this.Cache = cache;
+ this.GitHub = github;
+ }
+
+ /// <summary>Display the index page.</summary>
+ [HttpGet]
+ public async Task<ViewResult> Index()
+ {
+ // fetch latest SMAPI release
+ GitRelease release = await this.Cache.GetOrCreateAsync("latest-smapi-release", async entry =>
+ {
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
+ return await this.GitHub.GetLatestReleaseAsync("Pathoschild/SMAPI");
+ });
+ string downloadUrl = this.GetMainDownloadUrl(release);
+ string devDownloadUrl = this.GetDevDownloadUrl(release);
+
+ // render view
+ var model = new IndexModel(release.Name, release.Body, downloadUrl, devDownloadUrl);
+ return this.View(model);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the main download URL for a SMAPI release.</summary>
+ /// <param name="release">The SMAPI release.</param>
+ private string GetMainDownloadUrl(GitRelease release)
+ {
+ // get main download URL
+ foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ {
+ if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip"))
+ return asset.DownloadUrl;
+ }
+
+ // fallback just in case
+ return "https://github.com/pathoschild/SMAPI/releases";
+ }
+
+ /// <summary>Get the for-developers download URL for a SMAPI release.</summary>
+ /// <param name="release">The SMAPI release.</param>
+ private string GetDevDownloadUrl(GitRelease release)
+ {
+ // get dev download URL
+ foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ {
+ if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip"))
+ return asset.DownloadUrl;
+ }
+
+ // fallback just in case
+ return "https://github.com/pathoschild/SMAPI/releases";
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 454440bb..04a11a82 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -6,8 +6,8 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Web.Framework;
+using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.ConfigModels;
-using StardewModdingAPI.Web.Framework.LogParser;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@@ -19,10 +19,10 @@ namespace StardewModdingAPI.Web.Controllers
** Properties
*********/
/// <summary>The log parser config settings.</summary>
- private readonly LogParserConfig Config;
+ private readonly ContextConfig Config;
/// <summary>The underlying Pastebin client.</summary>
- private readonly PastebinClient PastebinClient;
+ private readonly IPastebinClient Pastebin;
/// <summary>The first bytes in a valid zip file.</summary>
/// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
@@ -36,14 +36,12 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
- /// <param name="configProvider">The log parser config settings.</param>
- public LogParserController(IOptions<LogParserConfig> configProvider)
+ /// <param name="contextProvider">The context config settings.</param>
+ /// <param name="pastebin">The Pastebin API client.</param>
+ public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin)
{
- // init Pastebin client
- this.Config = configProvider.Value;
- string version = this.GetType().Assembly.GetName().Version.ToString(3);
- string userAgent = string.Format(this.Config.PastebinUserAgent, version);
- this.PastebinClient = new PastebinClient(this.Config.PastebinBaseUrl, userAgent, this.Config.PastebinUserKey, this.Config.PastebinDevKey);
+ this.Config = contextProvider.Value;
+ this.Pastebin = pastebin;
}
/***
@@ -52,12 +50,11 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Render the log parser UI.</summary>
/// <param name="id">The paste ID.</param>
[HttpGet]
- [Route("")]
[Route("log")]
[Route("log/{id}")]
public ViewResult Index(string id = null)
{
- return this.View("Index", new LogParserModel(this.Config.SectionUrl, id));
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id));
}
/***
@@ -67,9 +64,9 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="id">The Pastebin paste ID.</param>
[HttpGet, Produces("application/json")]
[Route("log/fetch/{id}")]
- public async Task<GetPasteResponse> GetAsync(string id)
+ public async Task<PasteInfo> GetAsync(string id)
{
- GetPasteResponse response = await this.PastebinClient.GetAsync(id);
+ PasteInfo response = await this.Pastebin.GetAsync(id);
response.Content = this.DecompressString(response.Content);
return response;
}
@@ -78,10 +75,10 @@ namespace StardewModdingAPI.Web.Controllers
/// <param name="content">The log content to save.</param>
[HttpPost, Produces("application/json"), AllowLargePosts]
[Route("log/save")]
- public async Task<SavePasteResponse> PostAsync([FromBody] string content)
+ public async Task<SavePasteResult> PostAsync([FromBody] string content)
{
content = this.CompressString(content);
- return await this.PastebinClient.PostAsync(content);
+ return await this.Pastebin.PostAsync(content);
}
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index a600662c..dcb4ec52 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -7,6 +7,9 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
+using StardewModdingAPI.Web.Framework.Clients.GitHub;
+using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.ModRepositories;
@@ -39,39 +42,22 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
- public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider)
+ /// <param name="chucklefish">The Chucklefish API client.</param>
+ /// <param name="github">The GitHub API client.</param>
+ /// <param name="nexus">The Nexus API client.</param>
+ public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
{
ModUpdateCheckConfig config = configProvider.Value;
this.Cache = cache;
this.CacheMinutes = config.CacheMinutes;
this.VersionRegex = config.SemanticVersionRegex;
-
- string version = this.GetType().Assembly.GetName().Version.ToString(3);
this.Repositories =
new IModRepository[]
{
- new ChucklefishRepository(
- vendorKey: config.ChucklefishKey,
- userAgent: string.Format(config.ChucklefishUserAgent, version),
- baseUrl: config.ChucklefishBaseUrl,
- modPageUrlFormat: config.ChucklefishModPageUrlFormat
- ),
- new GitHubRepository(
- vendorKey: config.GitHubKey,
- baseUrl: config.GitHubBaseUrl,
- releaseUrlFormat: config.GitHubReleaseUrlFormat,
- userAgent: string.Format(config.GitHubUserAgent, version),
- acceptHeader: config.GitHubAcceptHeader,
- username: config.GitHubUsername,
- password: config.GitHubPassword
- ),
- new NexusRepository(
- vendorKey: config.NexusKey,
- userAgent: config.NexusUserAgent,
- baseUrl: config.NexusBaseUrl,
- modUrlFormat: config.NexusModUrlFormat
- )
+ new ChucklefishRepository(config.ChucklefishKey, chucklefish),
+ new GitHubRepository(config.GitHubKey, github),
+ new NexusRepository(config.NexusKey, nexus)
}
.ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase);
}
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
new file mode 100644
index 00000000..6772672c
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using HtmlAgilityPack;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
+{
+ /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary>
+ internal class ChucklefishClient : IChucklefishClient
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The base URL for the Chucklefish mod site.</summary>
+ private readonly string BaseUrl;
+
+ /// <summary>The URL for a mod page excluding the base URL, where {0} is the mod ID.</summary>
+ private readonly string ModPageUrlFormat;
+
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the API client.</param>
+ /// <param name="baseUrl">The base URL for the Chucklefish mod site.</param>
+ /// <param name="modPageUrlFormat">The URL for a mod page excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
+ public ChucklefishClient(string userAgent, string baseUrl, string modPageUrlFormat)
+ {
+ this.BaseUrl = baseUrl;
+ this.ModPageUrlFormat = modPageUrlFormat;
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ }
+
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The Chucklefish mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ public async Task<ChucklefishMod> GetModAsync(uint id)
+ {
+ // fetch HTML
+ string html;
+ try
+ {
+ html = await this.Client
+ .GetAsync(string.Format(this.ModPageUrlFormat, id))
+ .AsString();
+ }
+ catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ // parse HTML
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ // extract mod info
+ string url = new UriBuilder(new Uri(this.BaseUrl)) { Path = string.Format(this.ModPageUrlFormat, id) }.Uri.ToString();
+ string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
+ if (name.StartsWith("[SMAPI] "))
+ name = name.Substring("[SMAPI] ".Length);
+ string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText;
+
+ // create model
+ return new ChucklefishMod
+ {
+ Name = name,
+ Version = version,
+ Url = url
+ };
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs
new file mode 100644
index 00000000..fd0101d4
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs
@@ -0,0 +1,18 @@
+namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
+{
+ /// <summary>Mod metadata from the Chucklefish mod site.</summary>
+ internal class ChucklefishMod
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod's semantic version number.</summary>
+ public string Version { get; set; }
+
+ /// <summary>The mod's web URL.</summary>
+ public string Url { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs
new file mode 100644
index 00000000..1d8b256e
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Threading.Tasks;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
+{
+ /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary>
+ internal interface IChucklefishClient : IDisposable
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The Chucklefish mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ Task<ChucklefishMod> GetModAsync(uint id);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs
new file mode 100644
index 00000000..73ce4025
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs
@@ -0,0 +1,20 @@
+using Newtonsoft.Json;
+
+namespace StardewModdingAPI.Web.Framework.Clients.GitHub
+{
+ /// <summary>A GitHub download attached to a release.</summary>
+ internal class GitAsset
+ {
+ /// <summary>The file name.</summary>
+ [JsonProperty("name")]
+ public string FileName { get; set; }
+
+ /// <summary>The file content type.</summary>
+ [JsonProperty("content_type")]
+ public string ContentType { get; set; }
+
+ /// <summary>The download URL.</summary>
+ [JsonProperty("browser_download_url")]
+ public string DownloadUrl { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
new file mode 100644
index 00000000..0b205660
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Web.Framework.Clients.GitHub
+{
+ /// <summary>An HTTP client for fetching metadata from GitHub.</summary>
+ internal class GitHubClient : IGitHubClient
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The URL for a GitHub releases API query excluding the base URL, where {0} is the repository owner and name.</summary>
+ private readonly string ReleaseUrlFormat;
+
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="baseUrl">The base URL for the GitHub API.</param>
+ /// <param name="releaseUrlFormat">The URL for a GitHub releases API query excluding the <paramref name="baseUrl"/>, where {0} is the repository owner and name.</param>
+ /// <param name="userAgent">The user agent for the API client.</param>
+ /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param>
+ /// <param name="username">The username with which to authenticate to the GitHub API.</param>
+ /// <param name="password">The password with which to authenticate to the GitHub API.</param>
+ public GitHubClient(string baseUrl, string releaseUrlFormat, string userAgent, string acceptHeader, string username, string password)
+ {
+ this.ReleaseUrlFormat = releaseUrlFormat;
+
+ this.Client = new FluentClient(baseUrl)
+ .SetUserAgent(userAgent)
+ .AddDefault(req => req.WithHeader("Accept", acceptHeader));
+ if (!string.IsNullOrWhiteSpace(username))
+ this.Client = this.Client.SetBasicAuthentication(username, password);
+ }
+
+ /// <summary>Get the latest release for a GitHub repository.</summary>
+ /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
+ /// <returns>Returns the latest release if found, else <c>null</c>.</returns>
+ public async Task<GitRelease> GetLatestReleaseAsync(string repo)
+ {
+ // validate key format
+ if (!repo.Contains("/") || repo.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != repo.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase))
+ throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo));
+
+ // fetch info
+ try
+ {
+ return await this.Client
+ .GetAsync(string.Format(this.ReleaseUrlFormat, repo))
+ .As<GitRelease>();
+ }
+ catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs
new file mode 100644
index 00000000..b944088d
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs
@@ -0,0 +1,25 @@
+using Newtonsoft.Json;
+
+namespace StardewModdingAPI.Web.Framework.Clients.GitHub
+{
+ /// <summary>A GitHub project release.</summary>
+ internal class GitRelease
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The display name.</summary>
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ /// <summary>The semantic version string.</summary>
+ [JsonProperty("tag_name")]
+ public string Tag { get; set; }
+
+ /// <summary>The Markdown description for the release.</summary>
+ public string Body { get; set; }
+
+ /// <summary>The attached files.</summary>
+ public GitAsset[] Assets { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs
new file mode 100644
index 00000000..6e8eadff
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Threading.Tasks;
+
+namespace StardewModdingAPI.Web.Framework.Clients.GitHub
+{
+ /// <summary>An HTTP client for fetching metadata from GitHub.</summary>
+ internal interface IGitHubClient : IDisposable
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get the latest release for a GitHub repository.</summary>
+ /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
+ /// <returns>Returns the latest release if found, else <c>null</c>.</returns>
+ Task<GitRelease> GetLatestReleaseAsync(string repo);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs
new file mode 100644
index 00000000..e56e7af4
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Threading.Tasks;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Nexus
+{
+ /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary>
+ internal interface INexusClient : IDisposable
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The Nexus mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ Task<NexusMod> GetModAsync(uint id);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
new file mode 100644
index 00000000..1a7011e2
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Nexus
+{
+ /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary>
+ internal class NexusClient : INexusClient
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary>
+ private readonly string ModUrlFormat;
+
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
+ /// <param name="baseUrl">The base URL for the Nexus Mods API.</param>
+ /// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
+ public NexusClient(string userAgent, string baseUrl, string modUrlFormat)
+ {
+ this.ModUrlFormat = modUrlFormat;
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ }
+
+ /// <summary>Get metadata about a mod.</summary>
+ /// <param name="id">The Nexus mod ID.</param>
+ /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
+ public async Task<NexusMod> GetModAsync(uint id)
+ {
+ return await this.Client
+ .GetAsync(string.Format(this.ModUrlFormat, id))
+ .As<NexusMod>();
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
new file mode 100644
index 00000000..2b04104f
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
@@ -0,0 +1,21 @@
+using Newtonsoft.Json;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Nexus
+{
+ /// <summary>Mod metadata from Nexus Mods.</summary>
+ internal class NexusMod
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod's semantic version number.</summary>
+ public string Version { get; set; }
+
+ /// <summary>The mod's web URL.</summary>
+ [JsonProperty("mod_page_uri")]
+ public string Url { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
new file mode 100644
index 00000000..630dfb76
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Threading.Tasks;
+
+namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
+{
+ /// <summary>An API client for Pastebin.</summary>
+ internal interface IPastebinClient : IDisposable
+ {
+ /// <summary>Fetch a saved paste.</summary>
+ /// <param name="id">The paste ID.</param>
+ Task<PasteInfo> GetAsync(string id);
+
+ /// <summary>Save a paste to Pastebin.</summary>
+ /// <param name="content">The paste content.</param>
+ Task<SavePasteResult> PostAsync(string content);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
index 4f8794db..955156eb 100644
--- a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Web.Framework.LogParser
+namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{
/// <summary>The response for a get-paste request.</summary>
- internal class GetPasteResponse
+ internal class PasteInfo
{
/// <summary>Whether the log was successfully fetched.</summary>
public bool Success { get; set; }
diff --git a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
index 1cfaed17..ef83a91e 100644
--- a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
@@ -8,10 +8,10 @@ using System.Threading.Tasks;
using System.Web;
using Pathoschild.Http.Client;
-namespace StardewModdingAPI.Web.Framework.LogParser
+namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{
/// <summary>An API client for Pastebin.</summary>
- internal class PastebinClient : IDisposable
+ internal class PastebinClient : IPastebinClient
{
/*********
** Properties
@@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.LogParser
/// <summary>Fetch a saved paste.</summary>
/// <param name="id">The paste ID.</param>
- public async Task<GetPasteResponse> GetAsync(string id)
+ public async Task<PasteInfo> GetAsync(string id)
{
try
{
@@ -54,30 +54,30 @@ namespace StardewModdingAPI.Web.Framework.LogParser
// handle Pastebin errors
if (string.IsNullOrWhiteSpace(content))
- return new GetPasteResponse { Error = "Received an empty response from Pastebin." };
+ return new PasteInfo { Error = "Received an empty response from Pastebin." };
if (content.StartsWith("<!DOCTYPE"))
- return new GetPasteResponse { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." };
- return new GetPasteResponse { Success = true, Content = content };
+ return new PasteInfo { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." };
+ return new PasteInfo { Success = true, Content = content };
}
catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
{
- return new GetPasteResponse { Error = "There's no log with that ID." };
+ return new PasteInfo { Error = "There's no log with that ID." };
}
catch (Exception ex)
{
- return new GetPasteResponse { Error = ex.ToString() };
+ return new PasteInfo { Error = ex.ToString() };
}
}
/// <summary>Save a paste to Pastebin.</summary>
/// <param name="content">The paste content.</param>
- public async Task<SavePasteResponse> PostAsync(string content)
+ public async Task<SavePasteResult> PostAsync(string content)
{
try
{
// validate
if (string.IsNullOrWhiteSpace(content))
- return new SavePasteResponse { Error = "The log content can't be empty." };
+ return new SavePasteResult { Error = "The log content can't be empty." };
// post to API
string response = await this.Client
@@ -96,19 +96,19 @@ namespace StardewModdingAPI.Web.Framework.LogParser
// handle Pastebin errors
if (string.IsNullOrWhiteSpace(response))
- return new SavePasteResponse { Error = "Received an empty response from Pastebin." };
+ return new SavePasteResult { Error = "Received an empty response from Pastebin." };
if (response.StartsWith("Bad API request"))
- return new SavePasteResponse { Error = response };
+ return new SavePasteResult { Error = response };
if (!response.Contains("/"))
- return new SavePasteResponse { Error = $"Received an unknown response: {response}" };
+ return new SavePasteResult { Error = $"Received an unknown response: {response}" };
// return paste ID
string pastebinID = response.Split("/").Last();
- return new SavePasteResponse { Success = true, ID = pastebinID };
+ return new SavePasteResult { Success = true, ID = pastebinID };
}
catch (Exception ex)
{
- return new SavePasteResponse { Success = false, Error = ex.ToString() };
+ return new SavePasteResult { Success = false, Error = ex.ToString() };
}
}
diff --git a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs
index 1c0960a4..89dab697 100644
--- a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Web.Framework.LogParser
+namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{
/// <summary>The response for a save-log request.</summary>
- internal class SavePasteResponse
+ internal class SavePasteResult
{
/// <summary>Whether the log was successfully saved.</summary>
public bool Success { get; set; }
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
new file mode 100644
index 00000000..61219414
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
@@ -0,0 +1,72 @@
+namespace StardewModdingAPI.Web.Framework.ConfigModels
+{
+ /// <summary>The config settings for the API clients.</summary>
+ internal class ApiClientsConfig
+ {
+ /*********
+ ** Accessors
+ *********/
+ /****
+ ** Generic
+ ****/
+ /// <summary>The user agent for API clients, where {0} is the SMAPI version.</summary>
+ public string UserAgent { get; set; }
+
+
+ /****
+ ** Chucklefish
+ ****/
+ /// <summary>The base URL for the Chucklefish mod site.</summary>
+ public string ChucklefishBaseUrl { get; set; }
+
+ /// <summary>The URL for a mod page on the Chucklefish mod site excluding the <see cref="GitHubBaseUrl"/>, where {0} is the mod ID.</summary>
+ public string ChucklefishModPageUrlFormat { get; set; }
+
+
+ /****
+ ** GitHub
+ ****/
+ /// <summary>The base URL for the GitHub API.</summary>
+ public string GitHubBaseUrl { get; set; }
+
+ /// <summary>The URL for a GitHub API latest-release query excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary>
+ public string GitHubReleaseUrlFormat { get; set; }
+
+ /// <summary>The Accept header value expected by the GitHub API.</summary>
+ public string GitHubAcceptHeader { get; set; }
+
+ /// <summary>The username with which to authenticate to the GitHub API (if any).</summary>
+ public string GitHubUsername { get; set; }
+
+ /// <summary>The password with which to authenticate to the GitHub API (if any).</summary>
+ public string GitHubPassword { get; set; }
+
+ /****
+ ** Nexus Mods
+ ****/
+ /// <summary>The user agent for the Nexus Mods API client.</summary>
+ public string NexusUserAgent { get; set; }
+
+ /// <summary>The base URL for the Nexus Mods API.</summary>
+ public string NexusBaseUrl { get; set; }
+
+ /// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
+ public string NexusModUrlFormat { get; set; }
+
+ /****
+ ** Pastebin
+ ****/
+ /// <summary>The base URL for the Pastebin API.</summary>
+ public string PastebinBaseUrl { get; set; }
+
+ /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary>
+ public string PastebinUserAgent { get; set; }
+
+ /// <summary>The user key used to authenticate with the Pastebin API.</summary>
+ public string PastebinUserKey { get; set; }
+
+ /// <summary>The developer key used to authenticate with the Pastebin API.</summary>
+ public string PastebinDevKey { get; set; }
+
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs
new file mode 100644
index 00000000..117462f4
--- /dev/null
+++ b/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Web.Framework.ConfigModels
+{
+ /// <summary>The config settings for the app context.</summary>
+ public class ContextConfig // must be public to pass into views
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The root URL for the app.</summary>
+ public string RootUrl { get; set; }
+
+ /// <summary>The root URL for the log parser.</summary>
+ public string LogParserUrl { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
deleted file mode 100644
index df5d605d..00000000
--- a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace StardewModdingAPI.Web.Framework.ConfigModels
-{
- /// <summary>The config settings for the log parser.</summary>
- internal class LogParserConfig
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The root URL for the log parser controller.</summary>
- public string SectionUrl { get; set; }
-
- /// <summary>The base URL for the Pastebin API.</summary>
- public string PastebinBaseUrl { get; set; }
-
- /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary>
- public string PastebinUserAgent { get; set; }
-
- /// <summary>The user key used to authenticate with the Pastebin API.</summary>
- public string PastebinUserKey { get; set; }
-
- /// <summary>The developer key used to authenticate with the Pastebin API.</summary>
- public string PastebinDevKey { get; set; }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
index 2fb5b97e..58c3a100 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
@@ -6,9 +6,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/*********
** Accessors
*********/
- /****
- ** General
- ****/
/// <summary>The number of minutes update checks should be cached before refetching them.</summary>
public int CacheMinutes { get; set; }
@@ -16,59 +13,13 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <remarks>Derived from SMAPI's SemanticVersion implementation.</remarks>
public string SemanticVersionRegex { get; set; }
- /****
- ** Chucklefish mod site
- ****/
/// <summary>The repository key for the Chucklefish mod site.</summary>
public string ChucklefishKey { get; set; }
- /// <summary>The user agent for the Chucklefish API client, where {0} is the SMAPI version.</summary>
- public string ChucklefishUserAgent { get; set; }
-
- /// <summary>The base URL for the Chucklefish mod site.</summary>
- public string ChucklefishBaseUrl { get; set; }
-
- /// <summary>The URL for a mod page on the Chucklefish mod site excluding the <see cref="GitHubBaseUrl"/>, where {0} is the mod ID.</summary>
- public string ChucklefishModPageUrlFormat { get; set; }
-
-
- /****
- ** GitHub
- ****/
/// <summary>The repository key for Nexus Mods.</summary>
public string GitHubKey { get; set; }
- /// <summary>The user agent for the GitHub API client, where {0} is the SMAPI version.</summary>
- public string GitHubUserAgent { get; set; }
-
- /// <summary>The base URL for the GitHub API.</summary>
- public string GitHubBaseUrl { get; set; }
-
- /// <summary>The URL for a GitHub API latest-release query excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary>
- public string GitHubReleaseUrlFormat { get; set; }
-
- /// <summary>The Accept header value expected by the GitHub API.</summary>
- public string GitHubAcceptHeader { get; set; }
-
- /// <summary>The username with which to authenticate to the GitHub API (if any).</summary>
- public string GitHubUsername { get; set; }
-
- /// <summary>The password with which to authenticate to the GitHub API (if any).</summary>
- public string GitHubPassword { get; set; }
-
- /****
- ** Nexus Mods
- ****/
/// <summary>The repository key for Nexus Mods.</summary>
public string NexusKey { get; set; }
-
- /// <summary>The user agent for the Nexus Mods API client.</summary>
- public string NexusUserAgent { get; set; }
-
- /// <summary>The base URL for the Nexus Mods API.</summary>
- public string NexusBaseUrl { get; set; }
-
- /// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
- public string NexusModUrlFormat { get; set; }
}
}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
index 06ec58ed..266055a6 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
@@ -1,9 +1,7 @@
using System;
-using System.Net;
using System.Threading.Tasks;
-using HtmlAgilityPack;
-using Pathoschild.Http.Client;
using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
@@ -13,14 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/*********
** Properties
*********/
- /// <summary>The base URL for the Chucklefish mod site.</summary>
- private readonly string BaseUrl;
-
- /// <summary>The URL for a mod page excluding the base URL, where {0} is the mod ID.</summary>
- private readonly string ModPageUrlFormat;
-
/// <summary>The underlying HTTP client.</summary>
- private readonly IClient Client;
+ private readonly IChucklefishClient Client;
/*********
@@ -28,15 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
*********/
/// <summary>Construct an instance.</summary>
/// <param name="vendorKey">The unique key for this vendor.</param>
- /// <param name="userAgent">The user agent for the API client.</param>
- /// <param name="baseUrl">The base URL for the Chucklefish mod site.</param>
- /// <param name="modPageUrlFormat">The URL for a mod page excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
- public ChucklefishRepository(string vendorKey, string userAgent, string baseUrl, string modPageUrlFormat)
+ /// <param name="client">The underlying HTTP client.</param>
+ public ChucklefishRepository(string vendorKey, IChucklefishClient client)
: base(vendorKey)
{
- this.BaseUrl = baseUrl;
- this.ModPageUrlFormat = modPageUrlFormat;
- this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ this.Client = client;
}
/// <summary>Get metadata about a mod in the repository.</summary>
@@ -44,38 +32,18 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
public override async Task<ModInfoModel> GetModInfoAsync(string id)
{
// validate ID format
- if (!uint.TryParse(id, out uint _))
+ if (!uint.TryParse(id, out uint realID))
return new ModInfoModel($"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID.");
// fetch info
try
{
- // fetch HTML
- string html;
- try
- {
- html = await this.Client
- .GetAsync(string.Format(this.ModPageUrlFormat, id))
- .AsString();
- }
- catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
- {
+ var mod = await this.Client.GetModAsync(realID);
+ if (mod == null)
return new ModInfoModel("Found no mod with this ID.");
- }
-
- // parse HTML
- var doc = new HtmlDocument();
- doc.LoadHtml(html);
-
- // extract mod info
- string url = new UriBuilder(new Uri(this.BaseUrl)) { Path = string.Format(this.ModPageUrlFormat, id) }.Uri.ToString();
- string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
- if (name.StartsWith("[SMAPI] "))
- name = name.Substring("[SMAPI] ".Length);
- string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText;
// create model
- return new ModInfoModel(name, this.NormaliseVersion(version), url);
+ return new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url);
}
catch (Exception ex)
{
diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
index 9d43adf0..7bad6127 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
@@ -1,9 +1,7 @@
using System;
-using System.Net;
using System.Threading.Tasks;
-using Newtonsoft.Json;
-using Pathoschild.Http.Client;
using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Web.Framework.Clients.GitHub;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
@@ -13,11 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/*********
** Properties
*********/
- /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary>
- private readonly string ReleaseUrlFormat;
-
- /// <summary>The underlying HTTP client.</summary>
- private readonly IClient Client;
+ /// <summary>The underlying GitHub API client.</summary>
+ private readonly IGitHubClient Client;
/*********
@@ -25,22 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
*********/
/// <summary>Construct an instance.</summary>
/// <param name="vendorKey">The unique key for this vendor.</param>
- /// <param name="baseUrl">The base URL for the Nexus Mods API.</param>
- /// <param name="releaseUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
- /// <param name="userAgent">The user agent for the API client.</param>
- /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param>
- /// <param name="username">The username with which to authenticate to the GitHub API.</param>
- /// <param name="password">The password with which to authenticate to the GitHub API.</param>
- public GitHubRepository(string vendorKey, string baseUrl, string releaseUrlFormat, string userAgent, string acceptHeader, string username, string password)
+ /// <param name="client">The underlying GitHub API client.</param>
+ public GitHubRepository(string vendorKey, IGitHubClient client)
: base(vendorKey)
{
- this.ReleaseUrlFormat = releaseUrlFormat;
-
- this.Client = new FluentClient(baseUrl)
- .SetUserAgent(userAgent)
- .AddDefault(req => req.WithHeader("Accept", acceptHeader));
- if (!string.IsNullOrWhiteSpace(username))
- this.Client = this.Client.SetBasicAuthentication(username, password);
+ this.Client = client;
}
/// <summary>Get metadata about a mod in the repository.</summary>
@@ -54,14 +38,10 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
// fetch info
try
{
- GitRelease release = await this.Client
- .GetAsync(string.Format(this.ReleaseUrlFormat, id))
- .As<GitRelease>();
- return new ModInfoModel(id, this.NormaliseVersion(release.Tag), $"https://github.com/{id}/releases");
- }
- catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
- {
- return new ModInfoModel("Found no mod with this ID.");
+ GitRelease release = await this.Client.GetLatestReleaseAsync(id);
+ return release != null
+ ? new ModInfoModel(id, this.NormaliseVersion(release.Tag), $"https://github.com/{id}/releases")
+ : new ModInfoModel("Found no mod with this ID.");
}
catch (Exception ex)
{
@@ -74,24 +54,5 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
{
this.Client.Dispose();
}
-
-
- /*********
- ** Private models
- *********/
- /// <summary>Metadata about a GitHub release tag.</summary>
- private class GitRelease
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The display name.</summary>
- [JsonProperty("name")]
- public string Name { get; set; }
-
- /// <summary>The semantic version string.</summary>
- [JsonProperty("tag_name")]
- public string Tag { get; set; }
- }
}
}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
index 8a4bb0d8..cfa757ab 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
@@ -1,8 +1,7 @@
using System;
using System.Threading.Tasks;
-using Newtonsoft.Json;
-using Pathoschild.Http.Client;
using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Web.Framework.Clients.Nexus;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
@@ -12,11 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
/*********
** Properties
*********/
- /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary>
- private readonly string ModUrlFormat;
-
- /// <summary>The underlying HTTP client.</summary>
- private readonly IClient Client;
+ /// <summary>The underlying Nexus Mods API client.</summary>
+ private readonly INexusClient Client;
/*********
@@ -24,14 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
*********/
/// <summary>Construct an instance.</summary>
/// <param name="vendorKey">The unique key for this vendor.</param>
- /// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
- /// <param name="baseUrl">The base URL for the Nexus Mods API.</param>
- /// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
- public NexusRepository(string vendorKey, string userAgent, string baseUrl, string modUrlFormat)
+ /// <param name="client">The underlying Nexus Mods API client.</param>
+ public NexusRepository(string vendorKey, INexusClient client)
: base(vendorKey)
{
- this.ModUrlFormat = modUrlFormat;
- this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ this.Client = client;
}
/// <summary>Get metadata about a mod in the repository.</summary>
@@ -39,18 +32,15 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
public override async Task<ModInfoModel> GetModInfoAsync(string id)
{
// validate ID format
- if (!uint.TryParse(id, out uint _))
+ if (!uint.TryParse(id, out uint nexusID))
return new ModInfoModel($"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID.");
// fetch info
try
{
- NexusResponseModel response = await this.Client
- .GetAsync(string.Format(this.ModUrlFormat, id))
- .As<NexusResponseModel>();
-
- return response != null
- ? new ModInfoModel(response.Name, this.NormaliseVersion(response.Version), response.Url)
+ NexusMod mod = await this.Client.GetModAsync(nexusID);
+ return mod != null
+ ? new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url)
: new ModInfoModel("Found no mod with this ID.");
}
catch (Exception ex)
@@ -64,26 +54,5 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
{
this.Client.Dispose();
}
-
-
- /*********
- ** Private models
- *********/
- /// <summary>A mod metadata response from Nexus Mods.</summary>
- private class NexusResponseModel
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The mod name.</summary>
- public string Name { get; set; }
-
- /// <summary>The mod's semantic version number.</summary>
- public string Version { get; set; }
-
- /// <summary>The mod's web URL.</summary>
- [JsonProperty("mod_page_uri")]
- public string Url { get; set; }
- }
}
}
diff --git a/src/SMAPI.Web/Properties/launchSettings.json b/src/SMAPI.Web/Properties/launchSettings.json
index e485e4e3..88179044 100644
--- a/src/SMAPI.Web/Properties/launchSettings.json
+++ b/src/SMAPI.Web/Properties/launchSettings.json
@@ -11,7 +11,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
- "launchUrl": "log",
+ "launchUrl": "",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
index b5b0ff07..19198503 100644
--- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj
+++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.6.0" />
+ <PackageReference Include="Markdig" Version="0.14.8" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.0" />
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 16952124..e5e759e7 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -7,6 +7,10 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using StardewModdingAPI.Web.Framework;
+using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
+using StardewModdingAPI.Web.Framework.Clients.GitHub;
+using StardewModdingAPI.Web.Framework.Clients.Nexus;
+using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RewriteRules;
@@ -41,9 +45,10 @@ namespace StardewModdingAPI.Web
/// <param name="services">The service injection container.</param>
public void ConfigureServices(IServiceCollection services)
{
+ // init configuration
services
.Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck"))
- .Configure<LogParserConfig>(this.Configuration.GetSection("LogParser"))
+ .Configure<ContextConfig>(this.Configuration.GetSection("Context"))
.Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint)))
.AddMemoryCache()
.AddMvc()
@@ -53,6 +58,41 @@ namespace StardewModdingAPI.Web
options.SerializerSettings.Formatting = Formatting.Indented;
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
});
+
+ // init API clients
+ {
+ ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>();
+ string version = this.GetType().Assembly.GetName().Version.ToString(3);
+ string userAgent = string.Format(api.UserAgent, version);
+
+ services.AddSingleton<IChucklefishClient>(new ChucklefishClient(
+ userAgent: userAgent,
+ baseUrl: api.ChucklefishBaseUrl,
+ modPageUrlFormat: api.ChucklefishModPageUrlFormat
+ ));
+
+ services.AddSingleton<IGitHubClient>(new GitHubClient(
+ baseUrl: api.GitHubBaseUrl,
+ releaseUrlFormat: api.GitHubReleaseUrlFormat,
+ userAgent: userAgent,
+ acceptHeader: api.GitHubAcceptHeader,
+ username: api.GitHubUsername,
+ password: api.GitHubPassword
+ ));
+
+ services.AddSingleton<INexusClient>(new NexusClient(
+ userAgent: api.NexusUserAgent,
+ baseUrl: api.NexusBaseUrl,
+ modUrlFormat: api.NexusModUrlFormat
+ ));
+
+ services.AddSingleton<IPastebinClient>(new PastebinClient(
+ baseUrl: api.PastebinBaseUrl,
+ userAgent: userAgent,
+ userKey: api.PastebinUserKey,
+ devKey: api.PastebinDevKey
+ ));
+ }
}
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
@@ -89,11 +129,11 @@ namespace StardewModdingAPI.Web
req.Host.Host != "localhost"
&& (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log."))
&& !req.Path.StartsWithSegments("/content")
+ && !req.Path.StartsWithSegments("/favicon.ico")
))
// shortcut redirects
.Add(new RedirectToUrlRule("^/docs$", "https://stardewvalleywiki.com/Modding:Index"))
- .Add(new RedirectToUrlRule("^/install$", "https://stardewvalleywiki.com/Modding:Installing_SMAPI"))
)
.UseStaticFiles() // wwwroot folder
.UseMvc();
diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs
new file mode 100644
index 00000000..6d3da91e
--- /dev/null
+++ b/src/SMAPI.Web/ViewModels/IndexModel.cs
@@ -0,0 +1,41 @@
+namespace StardewModdingAPI.Web.ViewModels
+{
+ /// <summary>The view model for the index page.</summary>
+ public class IndexModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The latest SMAPI version.</summary>
+ public string LatestVersion { get; set; }
+
+ /// <summary>The Markdown description for the release.</summary>
+ public string Description { get; set; }
+
+ /// <summary>The main download URL.</summary>
+ public string DownloadUrl { get; set; }
+
+ /// <summary>The for-developers download URL.</summary>
+ public string DevDownloadUrl { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public IndexModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="latestVersion">The latest SMAPI version.</param>
+ /// <param name="description">The Markdown description for the release.</param>
+ /// <param name="downloadUrl">The main download URL.</param>
+ /// <param name="devDownloadUrl">The for-developers download URL.</param>
+ internal IndexModel(string latestVersion, string description, string downloadUrl, string devDownloadUrl)
+ {
+ this.LatestVersion = latestVersion;
+ this.Description = description;
+ this.DownloadUrl = downloadUrl;
+ this.DevDownloadUrl = devDownloadUrl;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
new file mode 100644
index 00000000..2d76cc15
--- /dev/null
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -0,0 +1,68 @@
+@{
+ ViewData["Title"] = "SMAPI";
+}
+@model StardewModdingAPI.Web.ViewModels.IndexModel
+@section Head {
+ <link rel="stylesheet" href="~/Content/css/index.css" />
+}
+
+<p id="blurb">
+ The mod loader for Stardew Valley. It works fine with GOG and Steam achievements, it's
+ compatible with Linux/Mac/Windows, you can uninstall it anytime, and there's a friendly
+ community if you need help. It's a cool pufferchick.
+</p>
+
+<div id="call-to-action">
+ <a href="@Model.DownloadUrl" class="main-cta">Download SMAPI @Model.LatestVersion</a><br />
+ <a href="https://stardewvalleywiki.com/Modding:Installing_SMAPI" class="secondary-cta">Install guide</a><br />
+ <a href="https://stardewvalleywiki.com/Modding:Player_FAQs" class="secondary-cta">FAQs</a><br />
+ <img src="favicon.ico" />
+</div>
+
+<h2>Get help</h2>
+<ul>
+ <li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li>
+ <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li>
+</ul>
+
+<h2>What's new in SMAPI @Model.LatestVersion?</h2>
+<div class="github-description">
+ @Html.Raw(Markdig.Markdown.ToHtml(Model.Description))
+</div>
+
+<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
+
+<h2>Donate to support SMAPI ♥</h2>
+<p>
+ SMAPI is an open-source project by Pathoschild. It will always be free, but donations
+ are much appreciated to help pay for development, server hosting, domain fees, coffee, etc.
+</p>
+<ul id="support-links">
+ <li><a href="https://www.paypal.me/pathoschild">Donate once</a></li>
+ <li>
+ <a href="https://www.patreon.com/pathoschild">Donate $1 per month (or more)</a><br />
+ <small>
+ You can cancel anytime. You'll have access to all private posts with behind-the-scenes
+ info, upcoming features, and early previews of SMAPI updates. You can optionally
+ provide early feedback on SMAPI features to influence development. Donate $5/month and
+ you'll be publicly credited (with optional link) below!
+ </small>
+ </li>
+</ul>
+
+<p>
+ Special thanks to
+ acerbicon,
+ <a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>,
+ jwdred,
+ OfficialPiAddict,
+ Robby LaFarge,
+ and a few anonymous users for their ongoing support; you're awesome! 🏅
+</p>
+
+<h2>For mod creators</h2>
+<ul>
+ <li><a href="@Model.DevDownloadUrl">SMAPI 2.2 for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li>
+ <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li>
+ <li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li>
+</ul>
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
index 547a8178..ac98c71b 100644
--- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml
+++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
@@ -1,3 +1,7 @@
+@using Microsoft.Extensions.Options
+@using StardewModdingAPI.Web.Framework.ConfigModels
+@inject IOptions<ContextConfig> ContextConfig
+
<!DOCTYPE html>
<html>
<head>
@@ -10,9 +14,9 @@
<div id="sidebar">
<h4>SMAPI</h4>
<ul>
- <li><a href="https://stardewvalleywiki.com/Modding:Index">FAQs & guides</a></li>
- <li><a href="https://github.com/pathoschild/SMAPI/releases">Download SMAPI</a></li>
- <li><a href="https://discord.gg/stardewvalley">Get help on Discord</a></li>
+ <li><a href="@ContextConfig.Value.RootUrl">About SMAPI</a></li>
+ <li><a href="@ContextConfig.Value.LogParserUrl">Log parser</a></li>
+ <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
</ul>
</div>
<div id="content-column">
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index 87c35ca9..495af120 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -16,12 +16,14 @@
"Microsoft": "Information"
}
},
- "ModUpdateCheck": {
- "GitHubUsername": null,
- "GitHubPassword": null
+ "Context": {
+ "RootUrl": "http://localhost:59482/",
+ "LogParserUrl": "http://localhost:59482/log/"
},
- "LogParser": {
- "SectionUrl": "http://localhost:59482/log/",
+ "ApiClients": {
+ "GitHubUsername": null,
+ "GitHubPassword": null,
+
"PastebinUserKey": null,
"PastebinDevKey": null
}
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index eb6ecc9b..9758f4a7 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -13,33 +13,37 @@
"Default": "Warning"
}
},
- "ModUpdateCheck": {
- "CacheMinutes": 60,
- "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$",
+ "Context": {
+ "RootUrl": null, // see top note
+ "LogParserUrl": null // see top note
+ },
+ "ApiClients": {
+ "UserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)",
- "ChucklefishKey": "Chucklefish",
- "ChucklefishUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)",
"ChucklefishBaseUrl": "https://community.playstarbound.com",
"ChucklefishModPageUrlFormat": "resources/{0}",
- "GitHubKey": "GitHub",
- "GitHubUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)",
"GitHubBaseUrl": "https://api.github.com",
"GitHubReleaseUrlFormat": "repos/{0}/releases/latest",
"GitHubAcceptHeader": "application/vnd.github.v3+json",
"GitHubUsername": null, // see top note
"GitHubPassword": null, // see top note
- "NexusKey": "Nexus",
"NexusUserAgent": "Nexus Client v0.63.15",
"NexusBaseUrl": "http://www.nexusmods.com/stardewvalley",
- "NexusModUrlFormat": "mods/{0}"
- },
- "LogParser": {
- "SectionUrl": null, // see top note
+ "NexusModUrlFormat": "mods/{0}",
+
"PastebinBaseUrl": "https://pastebin.com/",
- "PastebinUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)",
"PastebinUserKey": null, // see top note
"PastebinDevKey": null // see top note
+ },
+
+ "ModUpdateCheck": {
+ "CacheMinutes": 60,
+ "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$",
+
+ "ChucklefishKey": "Chucklefish",
+ "GitHubKey": "GitHub",
+ "NexusKey": "Nexus"
}
}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css
new file mode 100644
index 00000000..06cd6fb4
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/index.css
@@ -0,0 +1,58 @@
+/*********
+** Intro
+*********/
+h1 {
+ text-align: center;
+ font-size: 6em;
+ color: #000;
+}
+
+#blurb {
+ margin: auto;
+ width: 30em;
+ text-align: center;
+}
+
+#call-to-action {
+ margin: 3em 0;
+ text-align: center;
+}
+
+#call-to-action a {
+ box-shadow: #caefab 0 1px 0 0 inset;
+ background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a;
+ border-radius: 6px;
+ border: 1px solid #268a16;
+ display: inline-block;
+ cursor: pointer;
+ color: #306108;
+ font-weight: bold;
+ margin-bottom: 1em;
+ padding: 6px 24px;
+ text-decoration: none;
+ text-shadow: #aade7c 0 1px 0;
+}
+
+#call-to-action a.secondary-cta {
+ background: #768d87;
+ border: 1px solid #566963;
+ color: #ffffff;
+ text-shadow: #2b665e 0 1px 0;
+}
+
+/*********
+** Subsections
+*********/
+.github-description {
+ border-left: 0.25em solid #dfe2e5;
+ padding-left: 1em;
+}
+
+.github-description .noinclude {
+ display: none;
+}
+
+#support-links li small {
+ display: block;
+ width: 50em;
+}