diff --git a/src/Podsync/Controllers/DownloadController.cs b/src/Podsync/Controllers/DownloadController.cs index 028e9f7..b3d0540 100644 --- a/src/Podsync/Controllers/DownloadController.cs +++ b/src/Podsync/Controllers/DownloadController.cs @@ -23,6 +23,7 @@ namespace Podsync.Controllers } // Main video download endpoint, don't forget to reflect any changes in LinkService.Download + [HttpGet] [Route("{feedId}/{videoId}/")] public async Task Download(string feedId, string videoId) { @@ -31,7 +32,7 @@ namespace Podsync.Controllers var url = _linkService.Make(new LinkInfo { Provider = metadata.Provider, - LinkType = metadata.LinkType, + LinkType = LinkType.Video, Id = videoId }); diff --git a/src/Podsync/Controllers/FeedController.cs b/src/Podsync/Controllers/FeedController.cs index 51a7691..c093c1a 100644 --- a/src/Podsync/Controllers/FeedController.cs +++ b/src/Podsync/Controllers/FeedController.cs @@ -50,6 +50,12 @@ namespace Podsync.Controllers PageSize = request.PageSize ?? DefaultPageSize }; + if (!User.EnablePatreonFeatures()) + { + feed.Quality = ResolveType.VideoHigh; + feed.PageSize = DefaultPageSize; + } + return _storageService.Save(feed); } diff --git a/src/Podsync/Helpers/UserExtensions.cs b/src/Podsync/Helpers/UserExtensions.cs new file mode 100644 index 0000000..ce16cab --- /dev/null +++ b/src/Podsync/Helpers/UserExtensions.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Security.Claims; +using Podsync.Services.Patreon; + +namespace Podsync.Helpers +{ + public static class UserExtensions + { + private const string OwnerId = "2822191"; + + public static bool EnablePatreonFeatures(this ClaimsPrincipal user) + { + if (!user.Identity.IsAuthenticated) + { + return false; + } + + if (user.GetClaim(ClaimTypes.NameIdentifier) == OwnerId) + { + return true; + } + + const int MinAmountCents = 100; + + int amount; + if (int.TryParse(user.GetClaim(PatreonConstants.AmountDonated), out amount)) + { + return amount >= MinAmountCents; + } + + return false; + } + + public static string GetName(this ClaimsPrincipal claimsPrincipal) + { + return claimsPrincipal.GetClaim(ClaimTypes.Name) + ?? claimsPrincipal.GetClaim(ClaimTypes.Email) + ?? "noname :("; + } + + private static string GetClaim(this ClaimsPrincipal claimsPrincipal, string type) + { + return claimsPrincipal.Claims.FirstOrDefault(x => x.Type == type)?.Value; + } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Builder/CompositeRssBuilder.cs b/src/Podsync/Services/Builder/CompositeRssBuilder.cs index 0d32a6f..ec7a305 100644 --- a/src/Podsync/Services/Builder/CompositeRssBuilder.cs +++ b/src/Podsync/Services/Builder/CompositeRssBuilder.cs @@ -21,11 +21,11 @@ namespace Podsync.Services.Builder get { throw new NotSupportedException(); } } - public override Task Query(FeedMetadata feed) + public override Task Query(string feedId, FeedMetadata feed) { if (feed.Provider == Provider.YouTube) { - return _youTubeBuilder.Query(feed); + return _youTubeBuilder.Query(feedId, feed); } throw new NotSupportedException("Not supported provider"); diff --git a/src/Podsync/Services/Builder/IRssBuilder.cs b/src/Podsync/Services/Builder/IRssBuilder.cs index 2ee55fd..c12bf92 100644 --- a/src/Podsync/Services/Builder/IRssBuilder.cs +++ b/src/Podsync/Services/Builder/IRssBuilder.cs @@ -8,6 +8,6 @@ namespace Podsync.Services.Builder { Task Query(string feedId); - Task Query(FeedMetadata feed); + Task Query(string feedId, FeedMetadata feed); } } \ No newline at end of file diff --git a/src/Podsync/Services/Builder/RssBuilderBase.cs b/src/Podsync/Services/Builder/RssBuilderBase.cs index ec82e0d..11dc02f 100644 --- a/src/Podsync/Services/Builder/RssBuilderBase.cs +++ b/src/Podsync/Services/Builder/RssBuilderBase.cs @@ -20,9 +20,9 @@ namespace Podsync.Services.Builder { var metadata = await _storageService.Load(feedId); - return await Query(metadata); + return await Query(feedId, metadata); } - public abstract Task Query(FeedMetadata metadata); + public abstract Task Query(string feedId, FeedMetadata metadata); } } \ No newline at end of file diff --git a/src/Podsync/Services/Builder/YouTubeRssBuilder.cs b/src/Podsync/Services/Builder/YouTubeRssBuilder.cs index 91dbaf5..11c5ee8 100644 --- a/src/Podsync/Services/Builder/YouTubeRssBuilder.cs +++ b/src/Podsync/Services/Builder/YouTubeRssBuilder.cs @@ -24,7 +24,7 @@ namespace Podsync.Services.Builder public override Provider Provider { get; } = Provider.YouTube; - public override async Task Query(FeedMetadata metadata) + public override async Task Query(string feedId, FeedMetadata metadata) { if (metadata.Provider != Provider.YouTube) { @@ -57,7 +57,7 @@ namespace Podsync.Services.Builder // Get video descriptions var videos = await _youTube.GetVideos(new VideoQuery { Id = string.Join(",", ids) }); - channel.Items = videos.Select(x => MakeItem(x, metadata)); + channel.Items = videos.Select(x => MakeItem(x, feedId, metadata)); var rss = new Rss { @@ -103,7 +103,7 @@ namespace Podsync.Services.Builder }; } - private Item MakeItem(Video video, FeedMetadata feed) + private Item MakeItem(Video video, string feedId, FeedMetadata feed) { return new Item { @@ -116,7 +116,7 @@ namespace Podsync.Services.Builder { Length = video.Size, MediaType = SelectMediaType(feed.Quality), - Url = _linkService.Download(feed.Id, video.VideoId) + Url = _linkService.Download(feedId, video.VideoId) } }; } diff --git a/src/Podsync/Services/Patreon/Contracts/Pledge.cs b/src/Podsync/Services/Patreon/Contracts/Pledge.cs new file mode 100644 index 0000000..79433d8 --- /dev/null +++ b/src/Podsync/Services/Patreon/Contracts/Pledge.cs @@ -0,0 +1,17 @@ +using System; + +namespace Podsync.Services.Patreon.Contracts +{ + public class Pledge + { + public string Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime DeclinedSince { get; set; } + + public int AmountCents { get; set; } + + public int PledgeCapCents { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/Contracts/User.cs b/src/Podsync/Services/Patreon/Contracts/User.cs new file mode 100644 index 0000000..0ffdc9c --- /dev/null +++ b/src/Podsync/Services/Patreon/Contracts/User.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Podsync.Services.Patreon.Contracts +{ + public class User + { + public User() + { + Pledges = Enumerable.Empty(); + } + + public string Id { get; set; } + + public string Email { get; set; } + + public string Name { get; set; } + + public string Url { get; set; } + + public IEnumerable Pledges { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/IPatreonApi.cs b/src/Podsync/Services/Patreon/IPatreonApi.cs new file mode 100644 index 0000000..bbdd221 --- /dev/null +++ b/src/Podsync/Services/Patreon/IPatreonApi.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Podsync.Services.Patreon +{ + public interface IPatreonApi : IDisposable + { + Task FetchProfile(Tokens tokens); + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/PatreonApi.cs b/src/Podsync/Services/Patreon/PatreonApi.cs new file mode 100644 index 0000000..a297d42 --- /dev/null +++ b/src/Podsync/Services/Patreon/PatreonApi.cs @@ -0,0 +1,46 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Podsync.Services.Patreon +{ + public sealed class PatreonApi : IPatreonApi + { + private readonly HttpClient _client; + + public PatreonApi() + { + _client = new HttpClient + { + BaseAddress = new Uri("https://api.patreon.com/oauth2/api/") + }; + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer"); + } + + public Task FetchProfile(Tokens tokens) + { + return Query("current_user", tokens); + } + + public void Dispose() + { + _client.Dispose(); + } + + private async Task Query(string path, Tokens tokens) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + + var response = await _client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + return JObject.Parse(json); + } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/PatreonConstants.cs b/src/Podsync/Services/Patreon/PatreonConstants.cs new file mode 100644 index 0000000..ae968ca --- /dev/null +++ b/src/Podsync/Services/Patreon/PatreonConstants.cs @@ -0,0 +1,13 @@ +namespace Podsync.Services.Patreon +{ + public static class PatreonConstants + { + public const string AuthenticationScheme = "Patreon"; + + public const string AuthorizationEndpoint = "https://www.patreon.com/oauth2/authorize"; + + public const string TokenEndpoint = "https://api.patreon.com/oauth2/token"; + + public const string AmountDonated = "Patreon/" + nameof(AmountDonated); + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/PatreonExtensions.cs b/src/Podsync/Services/Patreon/PatreonExtensions.cs new file mode 100644 index 0000000..12011e3 --- /dev/null +++ b/src/Podsync/Services/Patreon/PatreonExtensions.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Podsync.Services.Patreon.Contracts; + +namespace Podsync.Services.Patreon +{ + public static class PatreonExtensions + { + public static async Task FetchUserAndPledges(this IPatreonApi api, Tokens tokens) + { + var resp = await api.FetchProfile(tokens); + + dynamic userAttrs = resp.data.attributes; + + var user = new User + { + Id = resp.data.id, + Email = userAttrs.email, + Name = userAttrs.first_name ?? userAttrs.full_name, + Url = userAttrs.url, + Pledges = ParsePledges(resp) + }; + + return user; + } + + private static IEnumerable ParsePledges(dynamic resp) + { + dynamic pledges = resp.data.relationships.pledges.data; + + foreach (var pledge in pledges) + { + var id = pledge.id; + var type = pledge.type; + + foreach (var include in resp.included) + { + if (include.id == id && include.type == type) + { + dynamic attrs = include.attributes; + + yield return new Pledge + { + Id = include.id, + CreatedAt = ParseDate(attrs.created_at), + DeclinedSince = ParseDate(attrs.declined_since), + AmountCents = attrs.amount_cents, + PledgeCapCents = attrs.pledge_cap_cents + }; + + break; + } + } + } + } + + private static DateTime ParseDate(object obj) + { + var date = obj?.ToString(); + + if (string.IsNullOrWhiteSpace(date)) + { + return DateTime.MinValue; + } + + var dateTime = DateTime.Parse(date); + + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Patreon/Tokens.cs b/src/Podsync/Services/Patreon/Tokens.cs new file mode 100644 index 0000000..20c60c5 --- /dev/null +++ b/src/Podsync/Services/Patreon/Tokens.cs @@ -0,0 +1,9 @@ +namespace Podsync.Services.Patreon +{ + public struct Tokens + { + public string AccessToken { get; set; } + + public string RefreshToken { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/PodsyncConfiguration.cs b/src/Podsync/Services/PodsyncConfiguration.cs index a00d516..3d1ecb1 100644 --- a/src/Podsync/Services/PodsyncConfiguration.cs +++ b/src/Podsync/Services/PodsyncConfiguration.cs @@ -1,4 +1,6 @@ -namespace Podsync.Services +using Podsync.Services.Patreon; + +namespace Podsync.Services { public class PodsyncConfiguration { @@ -7,5 +9,11 @@ public string RedisConnectionString { get; set; } public string BaseUrl { get; set; } + + public string PatreonClientId { get; set; } + + public string PatreonSecret { get; set; } + + public Tokens CreatorTokens { get; set; } } } \ No newline at end of file diff --git a/src/Podsync/Startup.cs b/src/Podsync/Startup.cs index 64c91e9..d32dc38 100644 --- a/src/Podsync/Startup.cs +++ b/src/Podsync/Startup.cs @@ -1,11 +1,18 @@ -using Microsoft.AspNetCore.Builder; +using System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Authentication; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Podsync.Services; using Podsync.Services.Builder; using Podsync.Services.Links; +using Podsync.Services.Patreon; using Podsync.Services.Resolver; using Podsync.Services.Storage; using Podsync.Services.Videos.YouTube; @@ -43,8 +50,12 @@ namespace Podsync services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); - // Add framework services. + // Add authentication services + services.AddAuthentication(config => config.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme); + + // Add framework services services.AddMvc(); } @@ -62,6 +73,77 @@ namespace Podsync app.UseStaticFiles(); + app.UseCookieAuthentication(new CookieAuthenticationOptions + { + AutomaticAuthenticate = true, + AutomaticChallenge = true, + LoginPath = new PathString("/login"), + LogoutPath = new PathString("/logout") + }); + + // Patreon authentication + app.UseOAuthAuthentication(new OAuthOptions + { + AuthenticationScheme = PatreonConstants.AuthenticationScheme, + + ClientId = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonClientId)}"], + ClientSecret = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonSecret)}"], + + CallbackPath = new PathString("/oauth-patreon"), + + AuthorizationEndpoint = PatreonConstants.AuthorizationEndpoint, + TokenEndpoint = PatreonConstants.TokenEndpoint, + + SaveTokens = true, + + Scope = { "users", "pledges-to-me", "my-campaign" }, + + Events = new OAuthEvents + { + OnCreatingTicket = async context => + { + var patreonApi = app.ApplicationServices.GetService(); + + var tokens = new Tokens + { + AccessToken = context.AccessToken, + RefreshToken = context.RefreshToken + }; + + var user = await patreonApi.FetchUserAndPledges(tokens); + + context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id)); + context.Identity.AddClaim(new Claim(ClaimTypes.Name, user.Name)); + context.Identity.AddClaim(new Claim(ClaimTypes.Email, user.Email)); + context.Identity.AddClaim(new Claim(ClaimTypes.Uri, user.Url)); + + var amountCents = user.Pledges.Sum(x => x.AmountCents); + context.Identity.AddClaim(new Claim(PatreonConstants.AmountDonated, amountCents.ToString())); + } + } + }); + + app.Map("/login", builder => + { + builder.Run(async context => + { + // Return a challenge to invoke the Patreon authentication scheme + await context.Authentication.ChallengeAsync(PatreonConstants.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" }); + }); + }); + + app.Map("/logout", builder => + { + builder.Run(async context => + { + // Sign the user out of the authentication middleware (i.e. it will clear the Auth cookie) + await context.Authentication.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + // Redirect the user to the home page after signing out + context.Response.Redirect("/"); + }); + }); + app.UseMvc(routes => { routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}"); diff --git a/src/Podsync/Views/Home/Index.cshtml b/src/Podsync/Views/Home/Index.cshtml index 3a59e45..e829048 100644 --- a/src/Podsync/Views/Home/Index.cshtml +++ b/src/Podsync/Views/Home/Index.cshtml @@ -1,4 +1,8 @@ - +@using Podsync.Helpers +@{ + var enableFeatures = User.EnablePatreonFeatures(); +} +

Podsync

@@ -8,16 +12,22 @@
@@ -34,11 +44,22 @@

- - + @if (enableFeatures) + { + + } + else + { + + } + + Selected format video audio, quality best worst, + episode count 50 100 150 diff --git a/src/Podsync/Views/Shared/_Layout.cshtml b/src/Podsync/Views/Shared/_Layout.cshtml index cb385cc..23432db 100644 --- a/src/Podsync/Views/Shared/_Layout.cshtml +++ b/src/Podsync/Views/Shared/_Layout.cshtml @@ -48,8 +48,7 @@ - - +