Dons PayPal
This commit is contained in:
parent
20a44bfde1
commit
e3b4414ab1
45 changed files with 7476 additions and 9 deletions
|
|
@ -664,8 +664,13 @@ namespace Yavsc.Controllers
|
|||
if (_dbContext.Performers.Any(p=>p.PerformerId == uid))
|
||||
{
|
||||
_dbContext.Update(model);
|
||||
_dbContext.Entry(model).Property(p => p.DonationPayPalEmail).IsModified = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
model.DonationPayPalEmail = null;
|
||||
_dbContext.Performers.Add(model);
|
||||
}
|
||||
else _dbContext.Performers.Add(model);
|
||||
_dbContext.SaveChanges(User.GetUserId());
|
||||
// Give this user the Performer role
|
||||
if (!User.IsInMsRole("Performer"))
|
||||
|
|
@ -716,6 +721,8 @@ namespace Yavsc.Controllers
|
|||
bool existing = _dbContext.Performers.Any(x => x.PerformerId == uid);
|
||||
if (existing)
|
||||
{
|
||||
if (await _dbContext.PerformerDonations.AnyAsync(d => d.PerformerId == uid))
|
||||
return Conflict("Ce profil a recu des dons et doit etre conserve pour leur suivi. Desactivez les nouveaux dons dans les parametres PayPal.");
|
||||
_dbContext.Performers.Remove(
|
||||
_dbContext.Performers.First(x => x.PerformerId == uid)
|
||||
);
|
||||
|
|
|
|||
232
src/Yavsc.Org/Controllers/DonationController.cs
Normal file
232
src/Yavsc.Org/Controllers/DonationController.cs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Payment;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.Podcast;
|
||||
using Yavsc.Web.Services;
|
||||
|
||||
namespace Yavsc.Controllers;
|
||||
|
||||
[Authorize(Policy = "WebUser")]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class DonationController(ApplicationDbContext db, IPayPalDonationGateway paypal,
|
||||
IOptions<SiteSettings> siteOptions, ILogger<DonationController> logger) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(CancellationToken ct)
|
||||
=> View(await db.PerformerDonations.AsNoTracking().Where(d => d.DonorId == User.GetUserId())
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.Join(db.Users, d => d.PerformerId, u => u.Id,
|
||||
(d, u) => new DonationDetailsViewModel(d, u.UserName ?? d.PerformerId)).ToArrayAsync(ct));
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Settings(CancellationToken ct)
|
||||
{
|
||||
var performer = await db.Performers.FindAsync([User.GetUserId()], ct);
|
||||
if (performer == null)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
ViewData["PaymentError"] = "Creez d'abord votre profil performer dans Mon compte > Parametres professionnels pour recevoir des dons.";
|
||||
return View("Error");
|
||||
}
|
||||
return View(new DonationSettingsViewModel { PayPalEmail = performer.DonationPayPalEmail });
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Settings(DonationSettingsViewModel model, CancellationToken ct)
|
||||
{
|
||||
var performer = await db.Performers.FindAsync([User.GetUserId()], ct);
|
||||
if (performer == null) return NotFound();
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
performer.DonationPayPalEmail = string.IsNullOrWhiteSpace(model.PayPalEmail) ? null : model.PayPalEmail.Trim();
|
||||
await db.SaveChangesAsync(ct);
|
||||
TempData["DonationSettingsSaved"] = true;
|
||||
return RedirectToAction(nameof(Settings));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create(string id, CancellationToken ct)
|
||||
{
|
||||
var performer = await RecipientAsync(id, ct);
|
||||
if (performer == null) return NotFound();
|
||||
return View(new CreateDonationViewModel { PerformerId = id, PerformerName = performer.Performer.UserName ?? id });
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateDonationViewModel model, CancellationToken ct)
|
||||
{
|
||||
var performer = await RecipientAsync(model.PerformerId, ct);
|
||||
if (performer == null) return NotFound();
|
||||
model.PerformerName = performer.Performer.UserName ?? performer.PerformerId;
|
||||
if (decimal.Round(model.Amount, 2) != model.Amount)
|
||||
ModelState.AddModelError(nameof(model.Amount), "Le montant doit avoir au maximum deux decimales.");
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
var urls = PaymentUrls();
|
||||
var donation = new PerformerDonation
|
||||
{
|
||||
Id = Guid.NewGuid(), DonorId = User.GetUserId(), PerformerId = performer.PerformerId,
|
||||
RecipientEmail = performer.DonationPayPalEmail!, Amount = model.Amount, CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.PerformerDonations.Add(donation);
|
||||
await db.SaveChangesAsync(ct);
|
||||
donation.CheckoutToken = await paypal.CreateAsync(donation,
|
||||
urls.Return + donation.Id, urls.Cancel + donation.Id, urls.Notify, ct);
|
||||
donation.State = PerformerDonationState.AwaitingApproval;
|
||||
await SaveAsync(donation, ct);
|
||||
return Redirect(paypal.CheckoutUrl(donation.CheckoutToken));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Return(Guid id, string token, CancellationToken ct)
|
||||
{
|
||||
var donation = await OwnedAsync(id, ct);
|
||||
if (donation == null || string.IsNullOrWhiteSpace(token) || donation.CheckoutToken != token) return NotFound();
|
||||
if (donation.State == PerformerDonationState.AwaitingApproval)
|
||||
await paypal.ApproveAsync(donation, ct);
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(Guid id, CancellationToken ct)
|
||||
{
|
||||
var donation = await OwnedAsync(id, ct);
|
||||
if (donation == null) return NotFound();
|
||||
var name = await db.Users.Where(u => u.Id == donation.PerformerId).Select(u => u.UserName).SingleAsync(ct);
|
||||
return View(new DonationDetailsViewModel(donation, name ?? donation.PerformerId));
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||
{
|
||||
var donation = await OwnedAsync(id, ct);
|
||||
if (donation == null) return NotFound();
|
||||
if (donation.State == PerformerDonationState.Completed)
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
if (donation.State != PerformerDonationState.AwaitingApproval)
|
||||
throw new DonationPaymentException(409, "Ce paiement est deja traite ou en cours de verification. Ne creez pas un second don.");
|
||||
var approval = await paypal.ApproveAsync(donation, ct);
|
||||
donation.State = PerformerDonationState.Processing;
|
||||
await SaveAsync(donation, ct);
|
||||
var receipt = await paypal.CaptureAsync(donation, approval.PayerId, ct);
|
||||
// An IPN may have confirmed the payment while the capture request was in flight.
|
||||
await db.Entry(donation).ReloadAsync(ct);
|
||||
if (donation.TransactionId != null && donation.TransactionId != receipt.TransactionId)
|
||||
throw new DonationPaymentException(409, "La reference PayPal ne correspond pas au don.");
|
||||
if (donation.State is PerformerDonationState.Processing or PerformerDonationState.Pending)
|
||||
{
|
||||
donation.TransactionId = receipt.TransactionId;
|
||||
donation.State = receipt.Completed ? PerformerDonationState.Completed : PerformerDonationState.Pending;
|
||||
donation.CompletedAt = receipt.Completed ? DateTimeOffset.UtcNow : null;
|
||||
await SaveAsync(donation, ct);
|
||||
}
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||
{
|
||||
var donation = await OwnedAsync(id, ct);
|
||||
if (donation == null) return NotFound();
|
||||
return View(donation);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CancelConfirm(Guid id, CancellationToken ct)
|
||||
{
|
||||
var donation = await OwnedAsync(id, ct);
|
||||
if (donation == null) return NotFound();
|
||||
if (donation.State == PerformerDonationState.AwaitingApproval)
|
||||
{
|
||||
donation.State = PerformerDonationState.Cancelled;
|
||||
await SaveAsync(donation, ct);
|
||||
}
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[AllowAnonymous, HttpPost, IgnoreAntiforgeryToken, RawPayPalNotification, RequestSizeLimit(64 * 1024)]
|
||||
public async Task<IActionResult> Notify(CancellationToken ct)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var fields = await paypal.VerifyNotificationAsync(body, ct);
|
||||
if (!Guid.TryParseExact(fields.GetValueOrDefault("invoice"), "N", out var id))
|
||||
throw new DonationPaymentException(400, "Reference de don PayPal invalide.");
|
||||
var donation = await db.PerformerDonations.FindAsync([id], ct);
|
||||
if (donation == null) return NotFound();
|
||||
var transactionId = fields.GetValueOrDefault("txn_id");
|
||||
if (fields.GetValueOrDefault("txn_type") != "express_checkout"
|
||||
|| !string.Equals(fields.GetValueOrDefault("receiver_email"), donation.RecipientEmail, StringComparison.OrdinalIgnoreCase)
|
||||
|| !PayPalDonationGateway.SameAmount(fields.GetValueOrDefault("mc_gross"), donation.Amount)
|
||||
|| fields.GetValueOrDefault("mc_currency") != donation.Currency
|
||||
|| string.IsNullOrWhiteSpace(transactionId) || transactionId.Length > 32
|
||||
|| (donation.TransactionId != null && donation.TransactionId != transactionId)
|
||||
|| donation.State is PerformerDonationState.Created or PerformerDonationState.AwaitingApproval or PerformerDonationState.Cancelled)
|
||||
throw new DonationPaymentException(409, "La notification PayPal ne correspond pas au don.");
|
||||
var paymentState = fields.GetValueOrDefault("payment_status");
|
||||
if (paymentState is not ("Completed" or "Pending" or "Denied" or "Failed" or "Expired" or "Voided"))
|
||||
{
|
||||
logger.LogWarning("PayPal donation {DonationId} notification has unsupported status {Status}", id, paymentState);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (donation.State != PerformerDonationState.Completed)
|
||||
{
|
||||
donation.TransactionId = transactionId;
|
||||
donation.State = paymentState switch
|
||||
{
|
||||
"Completed" => PerformerDonationState.Completed,
|
||||
"Pending" => PerformerDonationState.Pending,
|
||||
_ => PerformerDonationState.Failed
|
||||
};
|
||||
donation.CompletedAt = paymentState == "Completed" ? DateTimeOffset.UtcNow : null;
|
||||
await SaveAsync(donation, ct);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private Task<PerformerProfile?> RecipientAsync(string id, CancellationToken ct)
|
||||
=> db.Performers.Include(p => p.Performer).SingleOrDefaultAsync(p => p.PerformerId == id
|
||||
&& p.Active && p.DonationPayPalEmail != null && p.DonationPayPalEmail != "", ct);
|
||||
|
||||
private Task<PerformerDonation?> OwnedAsync(Guid id, CancellationToken ct)
|
||||
=> db.PerformerDonations.SingleOrDefaultAsync(d => d.Id == id && d.DonorId == User.GetUserId(), ct);
|
||||
|
||||
private async Task SaveAsync(PerformerDonation donation, CancellationToken ct)
|
||||
{
|
||||
donation.Revision = Guid.NewGuid();
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private (string Return, string Cancel, string Notify) PaymentUrls()
|
||||
{
|
||||
if (!Uri.TryCreate(siteOptions.Value.ExternalUrl, UriKind.Absolute, out var origin)
|
||||
|| origin.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(origin.UserInfo))
|
||||
throw new DonationPaymentException(503, "L'adresse HTTPS du site n'est pas configuree.");
|
||||
var root = siteOptions.Value.ExternalUrl.TrimEnd('/') + "/";
|
||||
return (root + "Donation/Return?id=", root + "Donation/Cancel?id=", root + "Donation/Notify");
|
||||
}
|
||||
|
||||
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var executed = await next();
|
||||
var error = executed.Exception;
|
||||
if (error == null || error is not (DonationPaymentException or DbUpdateConcurrencyException) || Response.HasStarted)
|
||||
return;
|
||||
logger.LogError(error, "Donation request failed for {Action}", context.ActionDescriptor.DisplayName);
|
||||
executed.ExceptionHandled = true;
|
||||
Response.StatusCode = error is DonationPaymentException payment ? payment.StatusCode : 409;
|
||||
ViewData["PaymentError"] = error is DonationPaymentException known
|
||||
? known.Message : "Ce don a ete modifie par une autre requete. Consultez son etat avant de recommencer.";
|
||||
if (context.ActionArguments.TryGetValue("id", out var id) && id is Guid donationId)
|
||||
ViewData["DonationId"] = donationId;
|
||||
executed.Result = View("Error");
|
||||
}
|
||||
}
|
||||
104
src/Yavsc.Org/Controllers/PodcastController.cs
Normal file
104
src/Yavsc.Org/Controllers/PodcastController.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.ViewModels.Podcast;
|
||||
using Yavsc.Web.Services;
|
||||
|
||||
namespace Yavsc.Controllers;
|
||||
|
||||
[Authorize(Policy = "WebUser")]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class PodcastController(
|
||||
PodcastWebClient api,
|
||||
ApplicationDbContext db,
|
||||
ILogger<PodcastController> logger) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(CancellationToken ct)
|
||||
{
|
||||
var podcasts = await api.GetAsync<List<PodcastResponse>>("", ct);
|
||||
var ownerIds = podcasts.Select(p => p.OwnerId).Distinct().ToArray();
|
||||
var performers = await db.Performers.AsNoTracking()
|
||||
.Where(p => ownerIds.Contains(p.PerformerId) && p.Active && p.DonationPayPalEmail != null
|
||||
&& p.DonationPayPalEmail != "")
|
||||
.Select(p => p.PerformerId).ToArrayAsync(ct);
|
||||
return View(new PodcastCatalogueViewModel(
|
||||
podcasts.OrderByDescending(p => p.CreatedAt).ToArray(), performers.ToHashSet(StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[HttpGet("Podcast/{id:guid}/index.m3u8")]
|
||||
public async Task<IActionResult> Playlist(Guid id, CancellationToken ct)
|
||||
{
|
||||
var manifest = await api.GetAsync<PodcastManifestResponse>(id.ToString(), ct);
|
||||
var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:15\n")
|
||||
.Append("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:EVENT\n");
|
||||
foreach (var segment in manifest.Segments)
|
||||
{
|
||||
text.Append(CultureInfo.InvariantCulture, $"#EXTINF:{segment.DurationSeconds:0.######},\n");
|
||||
text.Append(Url.Action(nameof(Segment), new { id, sequence = segment.Sequence })).Append('\n');
|
||||
}
|
||||
if (manifest.Podcast.State != PodcastState.Live) text.Append("#EXT-X-ENDLIST\n");
|
||||
return Content(text.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("Podcast/{id:guid}/segments/{sequence:int:min(0)}.ts")]
|
||||
public Task<IActionResult> Segment(Guid id, int sequence, CancellationToken ct)
|
||||
=> ProxyAsync($"{id}/segments/{sequence}.ts", "video/mp2t", ct);
|
||||
|
||||
[HttpGet("Podcast/{id:guid}/replay.mp4")]
|
||||
[HttpHead("Podcast/{id:guid}/replay.mp4")]
|
||||
public Task<IActionResult> Replay(Guid id, CancellationToken ct)
|
||||
=> ProxyAsync($"{id}/replay.mp4", "video/mp4", ct);
|
||||
|
||||
private async Task<IActionResult> ProxyAsync(string path, string contentType, CancellationToken ct)
|
||||
{
|
||||
RangeHeaderValue? range = null;
|
||||
if (Request.Headers.TryGetValue("Range", out var value)
|
||||
&& !RangeHeaderValue.TryParse(value.ToString(), out range))
|
||||
return BadRequest(new ProblemDetails { Status = 400, Title = "Plage de lecture invalide." });
|
||||
|
||||
using var response = await api.SendAsync(HttpMethods.IsHead(Request.Method)
|
||||
? HttpMethod.Head : HttpMethod.Get, path, range, ct);
|
||||
Response.StatusCode = (int)response.StatusCode;
|
||||
Response.ContentType = contentType;
|
||||
Response.Headers.CacheControl = "private, no-store";
|
||||
Response.ContentLength = response.Content.Headers.ContentLength;
|
||||
if (response.Content.Headers.ContentRange != null)
|
||||
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
|
||||
if (response.Headers.AcceptRanges.Count > 0)
|
||||
Response.Headers.AcceptRanges = string.Join(", ", response.Headers.AcceptRanges);
|
||||
if (!HttpMethods.IsHead(Request.Method))
|
||||
await response.Content.CopyToAsync(Response.Body, ct);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var executed = await next();
|
||||
var error = executed.Exception;
|
||||
if (error == null || Response.HasStarted || HttpContext.RequestAborted.IsCancellationRequested)
|
||||
return;
|
||||
if (error is not (PodcastException or HttpRequestException or JsonException or TaskCanceledException))
|
||||
return;
|
||||
|
||||
logger.LogError(error, "Podcast web request failed for {Action}", context.ActionDescriptor.DisplayName);
|
||||
var status = error is PodcastException podcastError ? podcastError.StatusCode : 502;
|
||||
var message = error is PodcastException known
|
||||
? known.Message : "Le service de podcasts est indisponible. Reessayez plus tard.";
|
||||
Response.StatusCode = status;
|
||||
executed.ExceptionHandled = true;
|
||||
executed.Result = context.ActionDescriptor.RouteValues["action"] == nameof(Index)
|
||||
? View("Index", new PodcastCatalogueViewModel([], new HashSet<string>(), message))
|
||||
: StatusCode(status, new ProblemDetails { Status = status, Title = message });
|
||||
}
|
||||
}
|
||||
17
src/Yavsc.Org/Controllers/RawPayPalNotificationAttribute.cs
Normal file
17
src/Yavsc.Org/Controllers/RawPayPalNotificationAttribute.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Yavsc.Controllers;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class RawPayPalNotificationAttribute : Attribute, IResourceFilter
|
||||
{
|
||||
public void OnResourceExecuting(ResourceExecutingContext context)
|
||||
{
|
||||
context.ValueProviderFactories.RemoveType<FormValueProviderFactory>();
|
||||
context.ValueProviderFactories.RemoveType<JQueryFormValueProviderFactory>();
|
||||
context.ValueProviderFactories.RemoveType<FormFileValueProviderFactory>();
|
||||
}
|
||||
|
||||
public void OnResourceExecuted(ResourceExecutedContext context) { }
|
||||
}
|
||||
|
|
@ -92,6 +92,10 @@ public static class HostingExtensions
|
|||
// Add the system clock service
|
||||
_ = services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
_ = services.AddSingleton<ILiveProcessor, LiveProcessor>();
|
||||
services.AddHttpClient<Yavsc.Web.Services.PodcastWebClient>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
services.AddHttpClient<Yavsc.Web.Services.IPayPalDonationGateway, Yavsc.Web.Services.PayPalDonationGateway>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
_ = services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
|
||||
|
||||
AddIdentityDBAndStores(builder)
|
||||
|
|
@ -278,6 +282,8 @@ public static class HostingExtensions
|
|||
// options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
|
||||
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
|
||||
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy("WebUser", new AuthorizationPolicyBuilder(IdentityConstants.ApplicationScheme)
|
||||
.RequireAuthenticatedUser().Build());
|
||||
options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission()));
|
||||
});
|
||||
|
||||
|
|
|
|||
5053
src/Yavsc.Org/Migrations/20261102093713_PerformerPayPalDonations.Designer.cs
generated
Normal file
5053
src/Yavsc.Org/Migrations/20261102093713_PerformerPayPalDonations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Yavsc.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PerformerPayPalDonations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DonationPayPalEmail",
|
||||
table: "Performers",
|
||||
type: "character varying(127)",
|
||||
maxLength: 127,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PerformerDonations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DonorId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
PerformerId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
RecipientEmail = table.Column<string>(type: "character varying(127)", maxLength: 127, nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
State = table.Column<int>(type: "integer", nullable: false),
|
||||
CheckoutToken = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
TransactionId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
Revision = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PerformerDonations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PerformerDonations_AspNetUsers_DonorId",
|
||||
column: x => x.DonorId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PerformerDonations_Performers_PerformerId",
|
||||
column: x => x.PerformerId,
|
||||
principalTable: "Performers",
|
||||
principalColumn: "PerformerId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PerformerDonations_CheckoutToken",
|
||||
table: "PerformerDonations",
|
||||
column: "CheckoutToken",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PerformerDonations_DonorId",
|
||||
table: "PerformerDonations",
|
||||
column: "DonorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PerformerDonations_PerformerId",
|
||||
table: "PerformerDonations",
|
||||
column: "PerformerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PerformerDonations_TransactionId",
|
||||
table: "PerformerDonations",
|
||||
column: "TransactionId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PerformerDonations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DonationPayPalEmail",
|
||||
table: "Performers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2738,6 +2738,71 @@ namespace Yavsc.Migrations
|
|||
b.ToTable("PayPalPayment");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Payment.PerformerDonation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<string>("CheckoutToken")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("character varying(3)");
|
||||
|
||||
b.Property<string>("DonorId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("PerformerId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("RecipientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(127)
|
||||
.HasColumnType("character varying(127)");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TransactionId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheckoutToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("DonorId");
|
||||
|
||||
b.HasIndex("PerformerId");
|
||||
|
||||
b.HasIndex("TransactionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PerformerDonations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
|
@ -3266,6 +3331,10 @@ namespace Yavsc.Migrations
|
|||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DonationPayPalEmail")
|
||||
.HasMaxLength(127)
|
||||
.HasColumnType("character varying(127)");
|
||||
|
||||
b.Property<string>("ExerciseCountryCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
@ -4514,6 +4583,21 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("Executor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Payment.PerformerDonation", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DonorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PerformerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.ApplicationUser", null)
|
||||
|
|
|
|||
174
src/Yavsc.Org/Services/PayPalDonationGateway.cs
Normal file
174
src/Yavsc.Org/Services/PayPalDonationGateway.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Models.Payment;
|
||||
|
||||
namespace Yavsc.Web.Services;
|
||||
|
||||
public sealed class DonationPaymentException(int statusCode, string message) : Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
}
|
||||
|
||||
public sealed record DonationApproval(string PayerId);
|
||||
public sealed record DonationReceipt(string TransactionId, bool Completed);
|
||||
|
||||
public interface IPayPalDonationGateway
|
||||
{
|
||||
Task<string> CreateAsync(PerformerDonation donation, string returnUrl, string cancelUrl, string notifyUrl, CancellationToken ct);
|
||||
Task<DonationApproval> ApproveAsync(PerformerDonation donation, CancellationToken ct);
|
||||
Task<DonationReceipt> CaptureAsync(PerformerDonation donation, string payerId, CancellationToken ct);
|
||||
Task<IReadOnlyDictionary<string, string>> VerifyNotificationAsync(string body, CancellationToken ct);
|
||||
string CheckoutUrl(string token);
|
||||
}
|
||||
|
||||
public sealed class PayPalDonationGateway(HttpClient http, IOptions<PayPalSettings> options) : IPayPalDonationGateway
|
||||
{
|
||||
private PayPalSettings Settings
|
||||
{
|
||||
get
|
||||
{
|
||||
var settings = options.Value;
|
||||
if (settings.Mode is not ("production" or "sandbox"))
|
||||
throw new DonationPaymentException(503, "Le paiement PayPal n'est pas configure.");
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public string CheckoutUrl(string token)
|
||||
=> $"https://{(Settings.Mode == "production" ? "www" : "www.sandbox")}.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token={Uri.EscapeDataString(token)}";
|
||||
|
||||
public async Task<string> CreateAsync(PerformerDonation donation, string returnUrl, string cancelUrl,
|
||||
string notifyUrl, CancellationToken ct)
|
||||
{
|
||||
var fields = PaymentFields(donation);
|
||||
fields["RETURNURL"] = returnUrl;
|
||||
fields["CANCELURL"] = cancelUrl;
|
||||
fields["PAYMENTREQUEST_0_NOTIFYURL"] = notifyUrl;
|
||||
fields["PAYMENTREQUEST_0_DESC"] = "Don au performer sur Yavsc";
|
||||
fields["NOSHIPPING"] = "1";
|
||||
var result = await CallAsync("SetExpressCheckout", fields, ct);
|
||||
var token = Required(result, "TOKEN");
|
||||
return token.Length <= 100 ? token : throw new DonationPaymentException(502, "Jeton PayPal invalide.");
|
||||
}
|
||||
|
||||
public async Task<DonationApproval> ApproveAsync(PerformerDonation donation, CancellationToken ct)
|
||||
{
|
||||
var fields = await CallAsync("GetExpressCheckoutDetails", new()
|
||||
{
|
||||
["TOKEN"] = donation.CheckoutToken ?? throw new InvalidOperationException("Missing donation checkout token.")
|
||||
}, ct);
|
||||
if (Required(fields, "TOKEN") != donation.CheckoutToken
|
||||
|| Required(fields, "PAYMENTREQUEST_0_INVNUM") != donation.Id.ToString("N")
|
||||
|| Required(fields, "PAYMENTREQUEST_0_CURRENCYCODE") != donation.Currency
|
||||
|| !SameAmount(fields.GetValueOrDefault("PAYMENTREQUEST_0_AMT"), donation.Amount)
|
||||
|| !string.Equals(Required(fields, "PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"),
|
||||
donation.RecipientEmail, StringComparison.OrdinalIgnoreCase))
|
||||
throw new DonationPaymentException(409, "La confirmation PayPal ne correspond pas a ce don.");
|
||||
return new DonationApproval(Required(fields, "PAYERID"));
|
||||
}
|
||||
|
||||
public async Task<DonationReceipt> CaptureAsync(PerformerDonation donation, string payerId, CancellationToken ct)
|
||||
{
|
||||
var fields = PaymentFields(donation);
|
||||
fields["TOKEN"] = donation.CheckoutToken!;
|
||||
fields["PAYERID"] = payerId;
|
||||
fields["MSGSUBID"] = donation.Id.ToString("N");
|
||||
var result = await CallAsync("DoExpressCheckoutPayment", fields, ct);
|
||||
if (!SameAmount(result.GetValueOrDefault("PAYMENTINFO_0_AMT"), donation.Amount)
|
||||
|| Required(result, "PAYMENTINFO_0_CURRENCYCODE") != donation.Currency
|
||||
|| !string.Equals(Required(result, "PAYMENTINFO_0_SELLERPAYPALACCOUNTID"),
|
||||
donation.RecipientEmail, StringComparison.OrdinalIgnoreCase)
|
||||
|| Required(result, "PAYMENTINFO_0_PAYMENTSTATUS") is not ("Completed" or "Pending"))
|
||||
throw new DonationPaymentException(409, "Le paiement PayPal n'est pas confirme pour ce don.");
|
||||
var transactionId = Required(result, "PAYMENTINFO_0_TRANSACTIONID");
|
||||
if (transactionId.Length > 32)
|
||||
throw new DonationPaymentException(502, "Reference de transaction PayPal invalide.");
|
||||
return new DonationReceipt(transactionId,
|
||||
result["PAYMENTINFO_0_PAYMENTSTATUS"] == "Completed");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> VerifyNotificationAsync(string body, CancellationToken ct)
|
||||
{
|
||||
var endpoint = Settings.Mode == "production"
|
||||
? "https://ipnpb.paypal.com/cgi-bin/webscr" : "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr";
|
||||
using var content = new StringContent("cmd=_notify-validate&" + body, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
using var response = await PostAsync(endpoint, content, ct);
|
||||
if ((await response.Content.ReadAsStringAsync(ct)).Trim() != "VERIFIED")
|
||||
throw new DonationPaymentException(400, "Notification PayPal non verifiee.");
|
||||
return Parse(body);
|
||||
}
|
||||
|
||||
public static bool SameAmount(string? value, decimal amount)
|
||||
=> decimal.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var actual)
|
||||
&& actual == amount;
|
||||
|
||||
private static Dictionary<string, string> PaymentFields(PerformerDonation donation) => new()
|
||||
{
|
||||
["PAYMENTREQUEST_0_PAYMENTACTION"] = "Sale",
|
||||
["PAYMENTREQUEST_0_AMT"] = donation.Amount.ToString("F2", CultureInfo.InvariantCulture),
|
||||
["PAYMENTREQUEST_0_CURRENCYCODE"] = donation.Currency,
|
||||
["PAYMENTREQUEST_0_INVNUM"] = donation.Id.ToString("N"),
|
||||
["PAYMENTREQUEST_0_PAYMENTREQUESTID"] = donation.Id.ToString("N"),
|
||||
["PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"] = donation.RecipientEmail
|
||||
};
|
||||
|
||||
private async Task<Dictionary<string, string>> CallAsync(string method, Dictionary<string, string> fields, CancellationToken ct)
|
||||
{
|
||||
var settings = Settings;
|
||||
var accounts = settings.Accounts?.Where(a => a.ApiUsername == settings.MerchantAccountUserName).ToArray();
|
||||
if (accounts is not { Length: 1 } || string.IsNullOrWhiteSpace(accounts[0].ApiUsername)
|
||||
|| string.IsNullOrWhiteSpace(accounts[0].ApiPassword) || string.IsNullOrWhiteSpace(accounts[0].Signature))
|
||||
throw new DonationPaymentException(503, "Le compte marchand PayPal n'est pas configure.");
|
||||
fields["METHOD"] = method;
|
||||
fields["VERSION"] = "124.0";
|
||||
fields["USER"] = accounts[0].ApiUsername;
|
||||
fields["PWD"] = accounts[0].ApiPassword;
|
||||
fields["SIGNATURE"] = accounts[0].Signature;
|
||||
using var content = new FormUrlEncodedContent(fields);
|
||||
using var response = await PostAsync(settings.Mode == "production"
|
||||
? "https://api-3t.paypal.com/nvp" : "https://api-3t.sandbox.paypal.com/nvp", content, ct);
|
||||
var result = Parse(await response.Content.ReadAsStringAsync(ct));
|
||||
if (result.GetValueOrDefault("ACK") is not ("Success" or "SuccessWithWarning"))
|
||||
throw new DonationPaymentException(502, "PayPal a refuse cette operation. Aucun don n'est marque comme paye.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PostAsync(string endpoint, HttpContent content, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await http.PostAsync(endpoint, content, ct);
|
||||
if (response.IsSuccessStatusCode) return response;
|
||||
response.Dispose();
|
||||
throw new DonationPaymentException(502, "Le service PayPal est indisponible.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new DonationPaymentException(502, $"La connexion PayPal a echoue ({ex.HttpRequestError}). Verifiez l'etat du don avant de recommencer.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DonationPaymentException(504, "La reponse PayPal a expire. Verifiez l'etat du don avant de recommencer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Parse(string body)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in body.Split('&'))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
if (parts.Length != 2 || !result.TryAdd(Decode(parts[0]), Decode(parts[1])))
|
||||
throw new DonationPaymentException(502, "Reponse PayPal invalide.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " "));
|
||||
private static string Required(IReadOnlyDictionary<string, string> fields, string name)
|
||||
=> fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value : throw new DonationPaymentException(502, "Reponse PayPal incomplete.");
|
||||
}
|
||||
76
src/Yavsc.Org/Services/PodcastWebClient.cs
Normal file
76
src/Yavsc.Org/Services/PodcastWebClient.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using IdentityServer8;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.Web.Services;
|
||||
|
||||
public sealed class PodcastWebClient(HttpClient http, IOptions<SiteSettings> options,
|
||||
IdentityServerTools authority, IHttpContextAccessor contextAccessor)
|
||||
{
|
||||
private string? _accessToken;
|
||||
|
||||
public async Task<T> GetAsync<T>(string path, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(HttpMethod.Get, path, null, ct);
|
||||
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||
?? throw new JsonException("Empty podcast API response.");
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path,
|
||||
RangeHeaderValue? range, CancellationToken ct)
|
||||
{
|
||||
var settings = options.Value;
|
||||
if (!Uri.TryCreate(settings.ApiUrl, UriKind.Absolute, out var address)
|
||||
|| address.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(address.UserInfo))
|
||||
throw new PodcastException(503, "La connexion aux podcasts n'est pas configuree.");
|
||||
|
||||
if (_accessToken == null)
|
||||
{
|
||||
var user = contextAccessor.HttpContext?.User;
|
||||
var userId = user?.GetUserId();
|
||||
if (user?.Identity?.IsAuthenticated != true || string.IsNullOrWhiteSpace(userId))
|
||||
throw new PodcastException(401, "Une connexion est requise pour lire les podcasts.");
|
||||
_accessToken = await authority.IssueJwtAsync(300, new List<Claim>
|
||||
{
|
||||
new("sub", userId),
|
||||
new("name", user.Identity.Name ?? userId),
|
||||
new("scope", "api"),
|
||||
new("aud", "api")
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(_accessToken))
|
||||
throw new InvalidOperationException("Yavsc.Org did not issue a podcast access token.");
|
||||
}
|
||||
|
||||
address = new Uri(settings.ApiUrl.TrimEnd('/') + "/");
|
||||
if (!address.AbsolutePath.EndsWith("/" + Constants.APIPrefix + "/", StringComparison.Ordinal))
|
||||
address = new Uri(address, Constants.APIPrefix + "/");
|
||||
using var request = new HttpRequestMessage(method, new Uri(address, "podcasts/" + path));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
request.Headers.Range = range;
|
||||
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||
{
|
||||
var status = response.StatusCode;
|
||||
response.Dispose();
|
||||
throw new PodcastException(status switch
|
||||
{
|
||||
HttpStatusCode.NotFound => 404,
|
||||
HttpStatusCode.Conflict => 409,
|
||||
_ => 502
|
||||
}, status switch
|
||||
{
|
||||
HttpStatusCode.NotFound => "Podcast introuvable.",
|
||||
HttpStatusCode.Conflict => "Ce replay n'est pas encore disponible.",
|
||||
_ => "Le service de podcasts est indisponible."
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
26
src/Yavsc.Org/ViewModels/Podcast/DonationViewModels.cs
Normal file
26
src/Yavsc.Org/ViewModels/Podcast/DonationViewModels.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Yavsc.Models.Payment;
|
||||
|
||||
namespace Yavsc.ViewModels.Podcast;
|
||||
|
||||
public sealed class CreateDonationViewModel
|
||||
{
|
||||
[Required]
|
||||
public string PerformerId { get; set; } = "";
|
||||
[BindNever]
|
||||
public string PerformerName { get; set; } = "";
|
||||
[Range(typeof(decimal), "0.01", "9999999999999999.99",
|
||||
ParseLimitsInInvariantCulture = true, ConvertValueInInvariantCulture = true)]
|
||||
public decimal Amount { get; set; } = 5m;
|
||||
}
|
||||
|
||||
public sealed class DonationSettingsViewModel
|
||||
{
|
||||
[EmailAddress, StringLength(127)]
|
||||
public string? PayPalEmail { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DonationDetailsViewModel(PerformerDonation Donation, string PerformerName);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#nullable enable
|
||||
|
||||
using Yavsc.Abstract.Streaming;
|
||||
|
||||
namespace Yavsc.ViewModels.Podcast;
|
||||
|
||||
public sealed record PodcastCatalogueViewModel(
|
||||
IReadOnlyList<PodcastResponse> Podcasts,
|
||||
IReadOnlySet<string> DonationPerformerIds,
|
||||
string? Error = null);
|
||||
10
src/Yavsc.Org/Views/Donation/Cancel.cshtml
Normal file
10
src/Yavsc.Org/Views/Donation/Cancel.cshtml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@model Yavsc.Models.Payment.PerformerDonation
|
||||
@{
|
||||
ViewBag.Title = Localizer["Annuler le don"];
|
||||
}
|
||||
<h1>@ViewBag.Title</h1>
|
||||
<p>@Localizer["L'annulation est possible tant que le paiement n'a pas été lancé."]</p>
|
||||
<form asp-action="CancelConfirm" asp-route-id="@Model.Id" method="post">
|
||||
<button class="btn btn-outline-danger" type="submit">@Localizer["Confirmer l'annulation"]</button>
|
||||
</form>
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary mt-3">@Localizer["Voir l'état du don"]</a>
|
||||
18
src/Yavsc.Org/Views/Donation/Create.cshtml
Normal file
18
src/Yavsc.Org/Views/Donation/Create.cshtml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
@model Yavsc.ViewModels.Podcast.CreateDonationViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Soutenir un performer"];
|
||||
}
|
||||
<h1>@Localizer["Faire un don à {0}", Model.PerformerName]</h1>
|
||||
<p>@Localizer["Votre don sera payé directement sur le compte PayPal du performer. Yavsc ne l'encaisse pas."]</p>
|
||||
<p>@Localizer["Le don est facultatif et indépendant de l'accès aux podcasts. Aucun reçu fiscal n'est délivré."]</p>
|
||||
<form asp-action="Create" method="post">
|
||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
||||
<input asp-for="PerformerId" type="hidden" />
|
||||
<div class="mb-3">
|
||||
<label asp-for="Amount" class="form-label">@Localizer["Montant en euros"]</label>
|
||||
<input asp-for="Amount" type="text" inputmode="decimal" class="form-control" />
|
||||
<span asp-validation-for="Amount" class="text-danger"></span>
|
||||
</div>
|
||||
<button class="btn btn-success" type="submit">@Localizer["Continuer avec PayPal"]</button>
|
||||
<a asp-controller="Podcast" asp-action="Index" class="btn btn-outline-secondary">@Localizer["Retour aux podcasts"]</a>
|
||||
</form>
|
||||
36
src/Yavsc.Org/Views/Donation/Details.cshtml
Normal file
36
src/Yavsc.Org/Views/Donation/Details.cshtml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
@using Yavsc.Models.Payment
|
||||
@model Yavsc.ViewModels.Podcast.DonationDetailsViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Votre don"];
|
||||
var donation = Model.Donation;
|
||||
}
|
||||
<h1>@Localizer["Votre don à {0}", Model.PerformerName]</h1>
|
||||
<p>@donation.Amount.ToString("F2") @donation.Currency</p>
|
||||
<p>@Localizer["Référence"] : @donation.Id</p>
|
||||
@switch (donation.State)
|
||||
{
|
||||
case PerformerDonationState.AwaitingApproval:
|
||||
<p>@Localizer["Après votre accord sur PayPal, confirmez le paiement ci-dessous. Le montant et le bénéficiaire sont vérifiés par Yavsc auprès de PayPal."]</p>
|
||||
<form asp-action="Confirm" asp-route-id="@donation.Id" method="post">
|
||||
<button type="submit" class="btn btn-success">@Localizer["Confirmer le paiement du don"]</button>
|
||||
</form>
|
||||
break;
|
||||
case PerformerDonationState.Completed:
|
||||
<p class="alert alert-success" role="status">@Localizer["Merci ! PayPal a confirmé le paiement de votre don au performer."]</p>
|
||||
<p>@Localizer["Transaction PayPal"] : @donation.TransactionId</p>
|
||||
break;
|
||||
case PerformerDonationState.Cancelled:
|
||||
<p class="alert alert-info" role="status">@Localizer["Ce don a été annulé avant paiement."]</p>
|
||||
break;
|
||||
case PerformerDonationState.Created:
|
||||
<p class="alert alert-warning" role="status">@Localizer["Le paiement de ce don n'a pas été lancé."]</p>
|
||||
break;
|
||||
case PerformerDonationState.Failed:
|
||||
<p class="alert alert-danger" role="status">@Localizer["PayPal a signalé l'échec de ce paiement. Ce don n'est pas payé."]</p>
|
||||
break;
|
||||
default:
|
||||
<p class="alert alert-warning" role="status">@Localizer["Le paiement n'est pas confirmé. Il peut être en cours de traitement par PayPal. Ne créez pas de second don et actualisez cette page. Si cet état persiste, contactez Yavsc avec la référence du don."]</p>
|
||||
break;
|
||||
}
|
||||
<a asp-action="Details" asp-route-id="@donation.Id" class="btn btn-outline-primary mt-3">@Localizer["Actualiser l'état"]</a>
|
||||
<a asp-controller="Podcast" asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Retour aux podcasts"]</a>
|
||||
13
src/Yavsc.Org/Views/Donation/Error.cshtml
Normal file
13
src/Yavsc.Org/Views/Donation/Error.cshtml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@{
|
||||
ViewBag.Title = Localizer["Paiement indisponible"];
|
||||
}
|
||||
<h1>@ViewBag.Title</h1>
|
||||
<p class="alert alert-danger" role="alert">@ViewData["PaymentError"]</p>
|
||||
<p>@Localizer["Un retour du navigateur ne prouve pas qu'un paiement a été effectué. Consultez l'état du don avant de recommencer."]</p>
|
||||
@if (ViewData["DonationId"] is Guid id)
|
||||
{
|
||||
<a asp-action="Details" asp-route-id="@id" class="btn btn-outline-primary">@Localizer["Voir l'état du don"]</a>
|
||||
}
|
||||
<a asp-controller="Podcast" asp-action="Index" class="btn btn-outline-secondary">@Localizer["Retour aux podcasts"]</a>
|
||||
<a asp-action="Index" class="btn btn-outline-primary">@Localizer["Mes dons"]</a>
|
||||
<a asp-controller="Manage" asp-action="SetActivity" class="btn btn-outline-secondary">@Localizer["Mon profil performer"]</a>
|
||||
24
src/Yavsc.Org/Views/Donation/Index.cshtml
Normal file
24
src/Yavsc.Org/Views/Donation/Index.cshtml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
@model IReadOnlyList<Yavsc.ViewModels.Podcast.DonationDetailsViewModel>
|
||||
@{
|
||||
ViewBag.Title = Localizer["Mes dons"];
|
||||
}
|
||||
<h1>@ViewBag.Title</h1>
|
||||
@if (Model.Count == 0)
|
||||
{
|
||||
<p>@Localizer["Vous n'avez pas encore initié de don."]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="list-group">
|
||||
@foreach (var entry in Model)
|
||||
{
|
||||
<li class="list-group-item">
|
||||
<a asp-action="Details" asp-route-id="@entry.Donation.Id">
|
||||
@entry.PerformerName · @entry.Donation.Amount.ToString("F2") @entry.Donation.Currency
|
||||
· @entry.Donation.CreatedAt.ToLocalTime().ToString("g")
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<a asp-controller="Podcast" asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Retour aux podcasts"]</a>
|
||||
21
src/Yavsc.Org/Views/Donation/Settings.cshtml
Normal file
21
src/Yavsc.Org/Views/Donation/Settings.cshtml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
@model Yavsc.ViewModels.Podcast.DonationSettingsViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Dons PayPal"];
|
||||
}
|
||||
<h1>@ViewBag.Title</h1>
|
||||
@if (TempData["DonationSettingsSaved"] != null)
|
||||
{
|
||||
<p class="alert alert-success" role="status">@Localizer["Les paramètres de dons ont été enregistrés."]</p>
|
||||
}
|
||||
<p>@Localizer["Configurez votre propre compte PayPal pour recevoir directement les dons. Vérifiez soigneusement cette adresse : elle désigne le bénéficiaire du paiement."]</p>
|
||||
<p>@Localizer["Laissez le champ vide pour désactiver les nouveaux dons. Les dons déjà initiés conservent leur bénéficiaire."]</p>
|
||||
<form asp-action="Settings" method="post">
|
||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="PayPalEmail" class="form-label">@Localizer["Adresse e-mail de votre compte PayPal"]</label>
|
||||
<input asp-for="PayPalEmail" type="email" class="form-control" autocomplete="email" />
|
||||
<span asp-validation-for="PayPalEmail" class="text-danger"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">@Localizer["Enregistrer"]</button>
|
||||
<a asp-controller="Manage" asp-action="Index" class="btn btn-outline-secondary">@Localizer["Mon compte"]</a>
|
||||
</form>
|
||||
|
|
@ -115,6 +115,13 @@ Html.DisplayText("Modify");
|
|||
<dt>Bank info:</dt>
|
||||
<dd>@Html.DisplayFor(m => m.BankInfo) [<a asp-controller="Manage" asp-action="AddBankInfo"
|
||||
>@(Model.BankInfo==null?"Set":"Modify")</a>]</dd>
|
||||
@if (Model.HaveProfessionalSettings)
|
||||
{
|
||||
<dt>@Localizer["Dons PayPal"]</dt>
|
||||
<dd><a asp-controller="Donation" asp-action="Settings">@Localizer["Configurer la réception des dons"]</a></dd>
|
||||
}
|
||||
<dt>@Localizer["Mes dons"]</dt>
|
||||
<dd><a asp-controller="Donation" asp-action="Index">@Localizer["Suivre mes dons aux performers"]</a></dd>
|
||||
|
||||
<dt><a href="/Blog/@Model.UserName">Your posts:</a></dt>
|
||||
<dd>@Model.PostsCounter</dd>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@
|
|||
|
||||
@Html.DisplayFor(model => model)
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.PerformerId))
|
||||
{
|
||||
<p><a asp-controller="Donation" asp-action="Settings" class="btn btn-outline-primary">@Localizer["Configurer mes dons PayPal"]</a></p>
|
||||
}
|
||||
|
||||
<form id="FrmSetAct" asp-controller="Manage" asp-action="SetActivity" method="post" class="form-horizontal" role="form">
|
||||
<h4>Setup below your activity parameters:</h4>
|
||||
<hr />
|
||||
|
|
|
|||
105
src/Yavsc.Org/Views/Podcast/Index.cshtml
Normal file
105
src/Yavsc.Org/Views/Podcast/Index.cshtml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
@using Yavsc.Abstract.Streaming
|
||||
@model Yavsc.ViewModels.Podcast.PodcastCatalogueViewModel
|
||||
@{
|
||||
ViewBag.Title = Localizer["Podcasts"];
|
||||
}
|
||||
|
||||
<header class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<h1>@Localizer["Podcasts"]</h1>
|
||||
<p class="text-muted">@Localizer["Retrouvez les directs et les replays de la communauté Yavsc."]</p>
|
||||
<p>@Localizer["Lecture réservée aux utilisateurs connectés. Les flux sont fournis par Yavsc.Api."]</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a asp-action="Index" class="btn btn-outline-primary">@Localizer["Actualiser"]</a>
|
||||
<a asp-controller="Donation" asp-action="Settings" class="btn btn-outline-success">@Localizer["Configurer mes dons PayPal"]</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (Model.Error != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@Model.Error</div>
|
||||
}
|
||||
else if (Model.Podcasts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info" role="status">@Localizer["Aucun podcast n'est disponible pour le moment."]</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-4">
|
||||
@foreach (var podcast in Model.Podcasts)
|
||||
{
|
||||
var ready = podcast.State == PodcastState.Completed && podcast.Replay?.State == PodcastReplayState.Ready;
|
||||
var stateLabel = podcast.State switch
|
||||
{
|
||||
PodcastState.Live => Localizer["En direct"],
|
||||
PodcastState.Completed => Localizer["Replay"],
|
||||
_ => Localizer["Direct interrompu"]
|
||||
};
|
||||
<article class="col-12 col-lg-6">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<h2 class="h4">@podcast.Title</h2>
|
||||
<span class="badge @(podcast.State == PodcastState.Live ? "bg-danger" : "bg-secondary")">@stateLabel</span>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
@podcast.OwnerName · <time datetime="@podcast.CreatedAt.ToString("O")">@podcast.CreatedAt.ToLocalTime().ToString("g")</time>
|
||||
</p>
|
||||
<p>@Localizer["Durée : {0} s", podcast.DurationSeconds.ToString("0.##")]</p>
|
||||
@if (podcast.SegmentCount > 0)
|
||||
{
|
||||
<video controls playsinline preload="none" class="w-100"
|
||||
aria-label="@podcast.Title" data-podcast-player
|
||||
data-hls="@(!ready ? "true" : "false")">
|
||||
<source src="@(ready ? Url.Action("Replay", new { id = podcast.Id }) : Url.Action("Playlist", new { id = podcast.Id }))"
|
||||
type="@(ready ? "video/mp4" : "application/vnd.apple.mpegurl")" />
|
||||
@Localizer["Votre navigateur ne prend pas en charge la lecture vidéo."]
|
||||
</video>
|
||||
@if (!ready)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary mt-2 d-none" data-start-playback>
|
||||
@Localizer["Lire le podcast"]
|
||||
</button>
|
||||
}
|
||||
<p class="alert alert-warning mt-2 d-none" role="alert" data-playback-error>
|
||||
@Localizer["Lecture indisponible. Actualisez la page ou utilisez PostIt."]
|
||||
</p>
|
||||
@if (!ready && podcast.State == PodcastState.Completed)
|
||||
{
|
||||
<p class="text-muted">
|
||||
@(podcast.Replay?.State == PodcastReplayState.Failed
|
||||
? Localizer["La préparation du replay MP4 a échoué. L'auteur peut la relancer depuis PostIt."]
|
||||
: Localizer["Le replay MP4 est en préparation. La lecture HLS reste disponible."])
|
||||
</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="alert alert-info">@Localizer["Aucun segment n'est encore disponible. Actualisez la page pour suivre ce podcast."]</p>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<h3 class="h6">@Localizer["Soutenir l'auteur"]</h3>
|
||||
@if (Model.DonationPerformerIds.Contains(podcast.OwnerId))
|
||||
{
|
||||
<a asp-controller="Donation" asp-action="Create" asp-route-id="@podcast.OwnerId" class="btn btn-outline-success">
|
||||
@Localizer["Faire un don à {0}", podcast.OwnerName]
|
||||
</a>
|
||||
<p class="small text-muted mt-2 mb-0">@Localizer["Don facultatif géré par Yavsc, payé via PayPal directement à l'auteur."]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted mb-0">@Localizer["Cet auteur n'a pas encore activé les dons PayPal."]</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@section scripts {
|
||||
<script src="~/js/hls.light.min.js" asp-append-version="true"></script>
|
||||
<script src="~/js/podcasts.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ background-attachment: fixed;">
|
|||
<ul class="navbar-nav me-auto" >
|
||||
<li class="nav-item"><a class="nav-link @PageHelpers.ActivePage(ViewContext, "")" href="/">@Localizer["Home"]</a></li>
|
||||
<li class="nav-item"><a asp-controller="Blogspot" asp-action="Index" class="nav-link @PageHelpers.ActivePage(ViewContext, "Blogspot")">@Localizer["Blogs"]</a></li>
|
||||
<li class="nav-item"><a asp-controller="Podcast" asp-action="Index" class="nav-link @PageHelpers.ActivePage(ViewContext, "Podcast")">@Localizer["Podcasts"]</a></li>
|
||||
<li class="nav-item"><a asp-controller="Home" asp-action="Contact" class="nav-link @PageHelpers.ActivePage(ViewContext, "Home", "Contact")">@Localizer["Contact"]</a></li>
|
||||
<li class="nav-item"><a asp-controller="Home" asp-action="About" class="nav-link @PageHelpers.ActivePage(ViewContext, "Home", "About")">@Localizer["About"]</a></li>
|
||||
<partial name="_LoginPartial" />
|
||||
|
|
|
|||
2
src/Yavsc.Org/wwwroot/js/hls.light.min.js
vendored
Normal file
2
src/Yavsc.Org/wwwroot/js/hls.light.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
32
src/Yavsc.Org/wwwroot/js/podcasts.js
Normal file
32
src/Yavsc.Org/wwwroot/js/podcasts.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
document.querySelectorAll("[data-podcast-player]").forEach(player => {
|
||||
const warning = player.parentElement.querySelector("[data-playback-error]");
|
||||
const showError = () => warning.classList.remove("d-none");
|
||||
const source = player.querySelector("source");
|
||||
if (player.dataset.hls === "true" && !player.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
if (typeof Hls === "undefined" || !Hls.isSupported()) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
const url = source.src;
|
||||
source.remove();
|
||||
const start = player.parentElement.querySelector("[data-start-playback]");
|
||||
start.classList.remove("d-none");
|
||||
start.addEventListener("click", () => {
|
||||
start.classList.add("d-none");
|
||||
const hls = new Hls();
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
showError();
|
||||
hls.destroy();
|
||||
}
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(player);
|
||||
player.play().catch(showError);
|
||||
window.addEventListener("pagehide", () => hls.destroy(), { once: true });
|
||||
}, { once: true });
|
||||
} else {
|
||||
source.addEventListener("error", showError);
|
||||
}
|
||||
player.addEventListener("error", showError);
|
||||
});
|
||||
Loading…
Reference in a new issue