Dons PayPal
This commit is contained in:
parent
20a44bfde1
commit
e3b4414ab1
45 changed files with 7476 additions and 9 deletions
|
|
@ -5,7 +5,13 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [PostIt/API/Yavsc.Org] Expose the private PayPal recipient setting in PostIt's performer editor and authenticated profile API; validate addresses, allow disabling with an empty value and preserve the setting for older clients. Make Web donation settings discoverable from the performer profile and podcast catalogue.
|
||||
* [API] Initialize the required address field when creating a performer profile so a first configuration can be saved.
|
||||
* [Yavsc.Org] Add an authenticated Razor podcast catalogue with same-origin API-backed HLS/MP4 playback and replay byte-range support. Reuse Site.ApiUrl and the authority's signing credentials to issue short-lived user tokens, without a separate OAuth client or PodcastWeb configuration. Distinguish an empty catalogue from API/configuration failures.
|
||||
* [Yavsc.Org] Integrate direct PayPal donations to performers: self-service recipient settings, Yavsc donation pages/history, server-verified checkout and capture, antiforgery/concurrency protection, and validated IPN reconciliation. Reuse the existing merchant settings and persist donation receipts in a dedicated migration; keep uncertain and pending payments distinct from completed donations.
|
||||
* [PostIt] Open the Yavsc.Org podcast catalogue in the browser instead of the authenticated API playlist; preserve embedded playback and external blog-video links.
|
||||
* [PostIt.Android/Desktop] Extend LAN/VPN SRTP calls to Android 8+ using the existing front-camera/microphone Podcast capture, rotated native preview and embedded LibVLC remote video, without libwebrtc or new dependencies. Pass the live MediaRecorder pipe directly to the shared SRTP engine without HLS files or transcoding; add a call-specific foreground notification, stop on backgrounding, and allow incoming callers to configure devices before acceptance. Use separate RTP/RTCP UDP ports to avoid a diagnosed Android VLC `rtcp-mux` SDP-generation crash. Add debug-only physical camera/SRTP checks and shared control/pipe-input regressions; Internet/TURN/group calls remain outside this transport.
|
||||
|
||||
* [PostIt.Desktop/API] Add opt-in Linux LAN/VPN peer calls through the existing ChatHub, reusing Podcast LibVLC camera/microphone capture and H.264/AAC encoding with direct SRTP instead of HLS. Add consent, embedded local/remote video, hangup/interruption cleanup, private IPv4 validation and per-direction ephemeral keys over HTTPS-only signaling. Reject IP-masking accounts rather than silently bypassing TURN; keep this transport disabled by default and separate from the future Internet/group-call design. Test real bidirectional encrypted audio/video, wrong-key rejection, preview output, signaling and UI lifecycle; physical camera/two-machine rendering remains to validate.
|
||||
* [PostIt/API] Begin Android/Linux call integration on the existing authenticated ChatHub: typed invitation, single-device consent, bounded SDP/ICE exchange and hangup; derive relay policy from account preferences and issue temporary coturn credentials. Attach the shared PostIt signaling client to the chat connection, end calls on disconnect/room departure/privacy activation, and add targeted regression tests. Calls remain disabled by default; native media adapters and Android/Linux end-to-end validation are not implemented yet.
|
||||
* [PostIt] Clarify reuse of the existing ChatHub for call signaling and document the inconclusive Android native WebRTC experiment; discard the isolated laboratory without changing production chat or media behavior.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,91 @@ sudo adduser --ingroup yavsc --add-extra-groups www-data \
|
|||
--disabled-password --system yavsc
|
||||
```
|
||||
|
||||
## Catalogue Web de podcasts
|
||||
|
||||
`/Podcast` (vue `src/Yavsc.Org/Views/Podcast/Index.cshtml`) exige une
|
||||
session Identity Yavsc.Org, y compris pour chaque requête de lecture.
|
||||
Yavsc.Org interroge Yavsc.Api côté serveur ; aucun jeton n'est transmis
|
||||
au navigateur et aucun second catalogue de stockage n'est créé.
|
||||
Les replays MP4 prêts prennent en charge GET, HEAD et les plages
|
||||
d'octets. Les directs et replays non encore convertis utilisent HLS
|
||||
natif ou hls.js (servi localement, installé par `npm install` et copié par
|
||||
`npm run build:podcasts`, également inclus dans la copie d'assets de
|
||||
`npm run build:js`). Sans support vidéo compatible, la page affiche un
|
||||
avertissement et invite à utiliser PostIt. Cette page ne crée ni ne
|
||||
supprime de podcast.
|
||||
Depuis le lecteur PostIt, « Ouvrir dans le navigateur » ouvre `/Podcast`
|
||||
sur l'hôte `Authentication.Authority`, pas la playlist API authentifiée.
|
||||
La session Web reste distincte de celle de PostIt : le navigateur peut
|
||||
donc demander une connexion.
|
||||
|
||||
Il n'y a pas de section de configuration `PodcastWeb`, ni de client OAuth
|
||||
technique à enregistrer. L'adresse API est celle de `Site:ApiUrl`, en HTTPS
|
||||
(avec ou sans suffixe `/api/v1`). Yavsc.Org est l'autorité : comme pour
|
||||
l'envoi d'avatar, `IdentityServerTools.IssueJwtAsync` utilise sa signature
|
||||
existante pour émettre un jeton de cinq minutes au nom de l'utilisateur
|
||||
connecté (`sub`, audience `api`, scope `api` attendu par Yavsc.Api).
|
||||
Ce jeton reste côté serveur. Une adresse API manquante renvoie 503 ;
|
||||
une panne de l'API renvoie une erreur explicite, pas un catalogue vide.
|
||||
|
||||
### Dons PayPal aux performers
|
||||
|
||||
Les dons sont gérés par Yavsc.Org, pas par un lien de paiement externe
|
||||
configuré par l'opérateur :
|
||||
|
||||
1. Depuis **Mon compte > Dons PayPal**, le performer renseigne l'adresse
|
||||
de son propre compte PayPal (`/Donation/Settings`). Un champ vide
|
||||
désactive les nouveaux dons. Seuls les performers actifs ayant
|
||||
configuré leur compte proposent un bouton de don dans le catalogue.
|
||||
Le formulaire est aussi accessible depuis le catalogue de podcasts et
|
||||
le profil professionnel Web. Dans PostIt : **Mon profil > Gérer mon
|
||||
activité professionnelle > Dons PayPal**, puis **Enregistrer**.
|
||||
L'API privée `GET/PUT /api/v1/account/performer-profile` transporte
|
||||
`DonationPayPalEmail` uniquement pour le compte connecté ; une chaîne
|
||||
vide désactive les dons, une propriété omise ou nulle préserve
|
||||
l'adresse existante pour les anciens clients.
|
||||
2. Le donateur connecté choisit un montant en euros (`/Donation/Create`).
|
||||
Yavsc conserve le don et son bénéficiaire avant de créer le checkout.
|
||||
3. Yavsc utilise les paramètres marchands existants
|
||||
`Authentication:PayPal` (mode sandbox/production et identifiants NVP).
|
||||
Express Checkout désigne le compte du performer par
|
||||
`PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID` : le paiement va directement
|
||||
au performer ; Yavsc ne collecte pas les fonds et ne les reverse pas.
|
||||
4. PayPal recueille l'accord du donateur puis le renvoie dans Yavsc.
|
||||
Une confirmation POST avec protection antiforgery déclenche le
|
||||
paiement, après vérification côté serveur du token, du montant,
|
||||
de la devise, de la référence et du bénéficiaire. Un simple retour
|
||||
du navigateur n'est jamais considéré comme une preuve de paiement.
|
||||
5. Seule une réponse PayPal vérifiée permet d'afficher le don comme payé.
|
||||
Les notifications IPN reçues sur `/Donation/Notify` sont revalidées
|
||||
auprès de PayPal, puis rapprochées du bénéficiaire, du montant, de la
|
||||
devise et de la transaction. Elles permettent aussi de confirmer
|
||||
ultérieurement un paiement Pending ou une réponse de capture perdue.
|
||||
|
||||
`Site:ExternalUrl` doit être l'URL HTTPS publique de Yavsc.Org, utilisée
|
||||
pour les retours et notifications (jamais le Host fourni par le client).
|
||||
PayPal doit pouvoir joindre `/Donation/Notify`. Le compte marchand
|
||||
doit être autorisé par PayPal pour les paiements directs à un tiers ;
|
||||
valider ce parcours en sandbox avec deux comptes distincts avant mise
|
||||
en production. Les tests locaux utilisent un serveur PayPal simulé,
|
||||
pas de véritables transactions.
|
||||
|
||||
`PerformerDonations` conserve la référence, le donateur, le performer,
|
||||
le bénéficiaire figé à la création, le montant et l'état du paiement.
|
||||
La migration `PerformerPayPalDonations` ajoute ce suivi et l'adresse
|
||||
PayPal optionnelle du performer. Les profils ayant un historique de
|
||||
dons ne peuvent pas être supprimés. Les rééditions du profil général
|
||||
ne remplacent pas la configuration du bénéficiaire.
|
||||
|
||||
Le paiement n'est capturé qu'une fois : contrôle de concurrence,
|
||||
référence de déduplication PayPal (`MSGSUBID`) et transaction unique.
|
||||
En cas de réponse ambiguë, le don reste en vérification sans nouvelle
|
||||
capture automatique. Le donateur retrouve son suivi dans **Mon compte >
|
||||
Mes dons** (`/Donation`). Si l'IPN ne réconcilie pas cet état, l'opérateur
|
||||
doit vérifier la transaction auprès de PayPal avant toute nouvelle
|
||||
tentative. Les dons sont facultatifs et n'ouvrent pas droit à un reçu
|
||||
fiscal émis par cette page.
|
||||
|
||||
## À documenter ensuite
|
||||
|
||||
- Modèle `ProjetMusical`
|
||||
|
|
|
|||
|
|
@ -78,12 +78,15 @@ pour éviter de monter un host complet.
|
|||
Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/test/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj \
|
||||
--filter-class '*PodcastWebTests' --filter-class '*PerformerDonationTests'
|
||||
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
|
||||
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' \
|
||||
--filter-class '*PodcastReplayTests'
|
||||
--filter-class '*PodcastReplayTests' --filter-class '*PerformerDonationSettingsTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*PodcastTests' --filter-class '*PodcastReplayTests' \
|
||||
--filter-class '*YavscApiClientTests' --filter-class '*PodcastNativeCaptureTests'
|
||||
--filter-class '*YavscApiClientTests' --filter-class '*PodcastNativeCaptureTests' \
|
||||
--filter-class '*PerformerDonationSettingsTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-method '*Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal'
|
||||
```
|
||||
|
|
@ -91,6 +94,9 @@ dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
|||
Les tests serveur couvrent HTTP/authentification, proprietaire, sequences,
|
||||
reprises idempotentes, limites exactes, concurrence et persistance des replays
|
||||
apres interruption/redemarrage, dans des dossiers temporaires isoles.
|
||||
Les tests de configuration PayPal couvrent la lecture privee, la creation,
|
||||
la validation avant toute sauvegarde, l'effacement, la compatibilite des anciens
|
||||
clients, ainsi que le chargement, la liaison du champ et l'enregistrement PostIt.
|
||||
Installer FFmpeg 5+ et ffprobe pour ces tests serveur : ils generent une
|
||||
source H.264/AAC synthetique de quatre secondes, verifient le remuxage reel
|
||||
(pistes, duree, faststart, decodage et seek a deux secondes), puis les reponses
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ const bundleNames = [
|
|||
// of bootstrap, jquery-ui, etc., which are NOT touched by this
|
||||
// toolchain).
|
||||
const staticAssets = [
|
||||
{
|
||||
src: 'node_modules/hls.js/dist/hls.light.min.js',
|
||||
dst: 'hls.light.min.js',
|
||||
},
|
||||
{
|
||||
src: 'node_modules/@aspnet/signalr/dist/browser/signalr.min.js',
|
||||
dst: 'signalr.min.js',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"description": "Yavsc front-end toolchain (esbuild bundles for Yavsc.Org).",
|
||||
"scripts": {
|
||||
"build:js": "node esbuild.config.mjs",
|
||||
"build:podcasts": "node -e \"require('node:fs').copyFileSync('node_modules/hls.js/dist/hls.light.min.js', 'src/Yavsc.Org/wwwroot/js/hls.light.min.js')\"",
|
||||
"build:js:watch": "node esbuild.config.mjs --watch",
|
||||
"clean:js": "node esbuild.config.mjs --clean"
|
||||
},
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
"bootstrap": "^4.6.2",
|
||||
"dropzone": "^5.9.3",
|
||||
"eonasdan-bootstrap-datetimepicker": "^4.17.49",
|
||||
"hls.js": "1.6.13",
|
||||
"jquery": "^3.7.1",
|
||||
"jquery-timepicker": "^1.3.3",
|
||||
"jquery-ui": "^1.13.3",
|
||||
|
|
|
|||
|
|
@ -89,6 +89,20 @@ public class BlogReaderTests
|
|||
{
|
||||
var vm = new VideoPageViewModel(new Uri(source), "video/mp4");
|
||||
Assert.Equal(expected, vm.CanOpenExternal);
|
||||
Assert.Equal(vm.Source, vm.ExternalUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://org.example.test/Podcast", true)]
|
||||
[InlineData("about:blank", false)]
|
||||
public void Video_external_action_uses_the_web_resource_instead_of_the_playback_stream(string externalUrl, bool expected)
|
||||
{
|
||||
var source = new Uri("https://api.example.test/api/v1/podcasts/123/index.m3u8");
|
||||
var externalUri = new Uri(externalUrl);
|
||||
var vm = new VideoPageViewModel(source, "application/vnd.apple.mpegurl", externalUri);
|
||||
Assert.Equal(source, vm.Source);
|
||||
Assert.Equal(externalUri, vm.ExternalUri);
|
||||
Assert.Equal(expected, vm.CanOpenExternal);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
|
|
|
|||
72
src/PostIt/PostIt.Tests/PerformerDonationSettingsTests.cs
Normal file
72
src/PostIt/PostIt.Tests/PerformerDonationSettingsTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PerformerDonationSettingsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("performer@payments.example", "performer@payments.example")]
|
||||
[InlineData(" performer@payments.example ", "performer@payments.example")]
|
||||
[InlineData("", "")]
|
||||
public void Performer_editor_includes_the_paypal_recipient_in_the_save_payload(string value, string expected)
|
||||
{
|
||||
var vm = new PerformerConfigurationPageViewModel { DonationPayPalEmail = value };
|
||||
Assert.Equal(expected, vm.BuildPerformerProfileSettings().DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Performer_editor_loads_displays_edits_and_saves_the_private_recipient()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetTempPath(), "postit-donation-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
try
|
||||
{
|
||||
await using var api = new PerformerApi(tokenPath);
|
||||
var vm = new PerformerConfigurationPageViewModel(api, new ActivityApiClient(api, api.Settings.ApiUrl));
|
||||
await vm.RefreshAsync();
|
||||
Assert.Equal("initial@payments.example", vm.DonationPayPalEmail);
|
||||
var page = new PerformerConfigurationPage { DataContext = vm };
|
||||
var input = page.FindControl<TextBox>("DonationPayPalEmailInput")!;
|
||||
Assert.Equal("initial@payments.example", input.Text);
|
||||
input.Text = "updated@payments.example";
|
||||
Assert.Equal("updated@payments.example", vm.DonationPayPalEmail);
|
||||
await vm.SaveAsync();
|
||||
Assert.Equal("updated@payments.example", api.Saved?.DonationPayPalEmail);
|
||||
input.Text = "";
|
||||
await vm.SaveAsync();
|
||||
Assert.Equal("", api.Saved?.DonationPayPalEmail);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tokenPath)) File.Delete(tokenPath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PerformerApi(string tokenPath) : YavscApiClient(new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://org.example.test", ClientId = "postit", Scopes = ["openid", "api"]
|
||||
}
|
||||
}, new TokenStore(tokenPath))
|
||||
{
|
||||
public PerformerProfileSettings? Saved { get; private set; }
|
||||
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
if (typeof(T) == typeof(List<ActivityInfo>))
|
||||
return Task.FromResult((T)(object)new List<ActivityInfo>());
|
||||
Assert.EndsWith("/account/performer-profile", path);
|
||||
if (method == HttpMethod.Put) Saved = Assert.IsType<PerformerProfileSettings>(body);
|
||||
return Task.FromResult((T)(object)new PerformerProfileSettings
|
||||
{
|
||||
PerformerId = "author", DonationPayPalEmail = "initial@payments.example"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@ using Yavsc.Api.Client;
|
|||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
|
||||
public partial class VideoPageViewModel(Uri source, string mimeType, Uri? externalUri = null) : ViewModelBase
|
||||
{
|
||||
public Uri Source { get; } = source;
|
||||
public Uri ExternalUri { get; } = externalUri ?? source;
|
||||
public string MimeType { get; } = mimeType;
|
||||
public PodcastApiClient? PodcastClient { get; init; }
|
||||
public PodcastResponse? Podcast { get; set; }
|
||||
|
||||
public bool CanOpenExternal => CanLaunchExternally(Source);
|
||||
public bool CanOpenExternal => CanLaunchExternally(ExternalUri);
|
||||
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||
public override bool CanNavigateNext { get => false; protected set { } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { } }
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
public partial string Website { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string DonationPayPalEmail { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool Active { get; set; }
|
||||
|
||||
|
|
@ -101,6 +104,7 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
|
|||
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
|
||||
SIREN = dto?.SIREN ?? string.Empty;
|
||||
Website = dto?.WebSite ?? string.Empty;
|
||||
DonationPayPalEmail = dto?.DonationPayPalEmail ?? string.Empty;
|
||||
Active = dto?.Active ?? false;
|
||||
AcceptNotifications = dto?.AcceptNotifications ?? false;
|
||||
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
|
||||
|
|
@ -142,6 +146,7 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
|
|||
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
|
||||
SIREN = SIREN,
|
||||
WebSite = Website,
|
||||
DonationPayPalEmail = DonationPayPalEmail.Trim(),
|
||||
Active = Active,
|
||||
AcceptNotifications = AcceptNotifications,
|
||||
AcceptPublicContact = AcceptPublicContact,
|
||||
|
|
|
|||
|
|
@ -263,7 +263,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
|
|||
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
|
||||
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
|
||||
var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8");
|
||||
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
|
||||
var catalogueUrl = new Uri(new Uri(_api.Settings.Authentication.Authority.TrimEnd('/') + "/"), "Podcast");
|
||||
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl", catalogueUrl)
|
||||
{
|
||||
PodcastClient = _client, Podcast = podcast
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@
|
|||
<TextBox Text="{Binding Website, Mode=TwoWay}"
|
||||
PlaceholderText="Site web"/>
|
||||
|
||||
<TextBlock Text="Dons PayPal" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Adresse e-mail PayPal du bénéficiaire"/>
|
||||
<TextBox x:Name="DonationPayPalEmailInput"
|
||||
Text="{Binding DonationPayPalEmail, Mode=TwoWay}"
|
||||
PlaceholderText="Adresse e-mail de votre compte PayPal"
|
||||
MaxLength="127"
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
<TextBlock Text="Les dons sont versés directement sur ce compte PayPal. Vérifiez l’adresse. Laissez vide pour désactiver les nouveaux dons, puis enregistrez. Le profil doit être actif pour proposer les dons."
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<CheckBox Content="Disponible / actif"
|
||||
IsChecked="{Binding Active, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Accepter les notifications"
|
||||
|
|
|
|||
|
|
@ -201,13 +201,13 @@ public partial class VideoPage : ContentPage
|
|||
{
|
||||
if (!vm.CanOpenExternal)
|
||||
{
|
||||
PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur.";
|
||||
PlaybackStatus.Text = "Cette ressource ne peut pas être ouverte dans le navigateur.";
|
||||
return;
|
||||
}
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this)
|
||||
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.ExternalUri))
|
||||
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Yavsc.Abstract.Workflow;
|
||||
|
||||
public sealed class PerformerProfileSettings
|
||||
|
|
@ -7,6 +11,9 @@ public sealed class PerformerProfileSettings
|
|||
public string ExerciseCountryCode { get; set; } = "fr";
|
||||
public string SIREN { get; set; } = string.Empty;
|
||||
public string WebSite { get; set; } = string.Empty;
|
||||
/// <summary>Owner-only PayPal recipient. Null preserves the existing setting; empty disables donations.</summary>
|
||||
[MaxLength(127)]
|
||||
public string? DonationPayPalEmail { get; set; }
|
||||
public bool Active { get; set; }
|
||||
public bool AcceptNotifications { get; set; }
|
||||
public bool AcceptPublicContact { get; set; }
|
||||
|
|
|
|||
|
|
@ -573,6 +573,7 @@ namespace Yavsc.WebApi.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("performer-profile")]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public async Task<IActionResult> GetPerformerProfile()
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
|
|
@ -598,6 +599,7 @@ namespace Yavsc.WebApi.Controllers
|
|||
ExerciseCountryCode = profile?.ExerciseCountryCode ?? "fr",
|
||||
SIREN = profile?.SIREN ?? string.Empty,
|
||||
WebSite = profile?.WebSite ?? string.Empty,
|
||||
DonationPayPalEmail = profile?.DonationPayPalEmail ?? string.Empty,
|
||||
Active = profile?.Active ?? false,
|
||||
AcceptNotifications = profile?.AcceptNotifications ?? false,
|
||||
AcceptPublicContact = profile?.AcceptPublicContact ?? false,
|
||||
|
|
@ -630,6 +632,20 @@ namespace Yavsc.WebApi.Controllers
|
|||
return Forbid();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return ValidationProblem(ModelState);
|
||||
if (model.DonationPayPalEmail is { } email)
|
||||
{
|
||||
model.DonationPayPalEmail = email.Trim();
|
||||
if (model.DonationPayPalEmail.Length > 0
|
||||
&& (model.DonationPayPalEmail.Length > 127
|
||||
|| !new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(model.DonationPayPalEmail)))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.DonationPayPalEmail), "Une adresse e-mail PayPal valide est requise.");
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
}
|
||||
|
||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
|
||||
if (user is null)
|
||||
{
|
||||
|
|
@ -655,7 +671,7 @@ namespace Yavsc.WebApi.Controllers
|
|||
AcceptNotifications = model.AcceptNotifications,
|
||||
AcceptPublicContact = model.AcceptPublicContact,
|
||||
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
|
||||
OrganizationAddress = new Yavsc.Models.Relationship.Location()
|
||||
OrganizationAddress = new Yavsc.Models.Relationship.Location { Address = string.Empty }
|
||||
};
|
||||
_dbContext.Performers.Add(profile);
|
||||
}
|
||||
|
|
@ -670,6 +686,9 @@ namespace Yavsc.WebApi.Controllers
|
|||
profile.UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients;
|
||||
}
|
||||
|
||||
if (model.DonationPayPalEmail != null)
|
||||
profile.DonationPayPalEmail = model.DonationPayPalEmail.Length == 0 ? null : model.DonationPayPalEmail;
|
||||
|
||||
var desiredCodes = (model.SelectedActivityCodes ?? [])
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -58,6 +58,16 @@ namespace Yavsc.Models
|
|||
}
|
||||
builder.UseIdentityByDefaultColumns();
|
||||
|
||||
builder.Entity<PerformerDonation>(entity =>
|
||||
{
|
||||
entity.HasOne<ApplicationUser>().WithMany().HasForeignKey(d => d.DonorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<PerformerProfile>().WithMany().HasForeignKey(d => d.PerformerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasIndex(d => d.CheckoutToken).IsUnique();
|
||||
entity.HasIndex(d => d.TransactionId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<NominativeServiceCommand>()
|
||||
.Property(command => command.AmountPaid)
|
||||
.HasDefaultValue(0m);
|
||||
|
|
@ -458,6 +468,7 @@ namespace Yavsc.Models
|
|||
public DbSet<BankIdentity> BankIdentity { get; set; }
|
||||
|
||||
public DbSet<PayPalPayment> PayPalPayment { get; set; }
|
||||
public DbSet<PerformerDonation> PerformerDonations { get; set; }
|
||||
|
||||
public DbSet<HyperLink> HyperLink { get; set; }
|
||||
|
||||
|
|
|
|||
32
src/Yavsc.Server/Models/Payment/PerformerDonation.cs
Normal file
32
src/Yavsc.Server/Models/Payment/PerformerDonation.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Yavsc.Models.Payment;
|
||||
|
||||
public enum PerformerDonationState { Created, AwaitingApproval, Processing, Pending, Completed, Cancelled, Failed }
|
||||
|
||||
public sealed class PerformerDonation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[Required, MaxLength(450)]
|
||||
public string DonorId { get; set; } = "";
|
||||
[Required, MaxLength(450)]
|
||||
public string PerformerId { get; set; } = "";
|
||||
[Required, EmailAddress, MaxLength(127)]
|
||||
public string RecipientEmail { get; set; } = "";
|
||||
[Column(TypeName = "numeric(18,2)")]
|
||||
public decimal Amount { get; set; }
|
||||
[Required, MaxLength(3)]
|
||||
public string Currency { get; set; } = "EUR";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public PerformerDonationState State { get; set; }
|
||||
[MaxLength(100)]
|
||||
public string? CheckoutToken { get; set; }
|
||||
[MaxLength(32)]
|
||||
public string? TransactionId { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
[ConcurrencyCheck]
|
||||
public Guid Revision { get; set; } = Guid.NewGuid();
|
||||
}
|
||||
|
|
@ -51,6 +51,9 @@ namespace Yavsc.Models.Workflow
|
|||
[Display(Name="Active")]
|
||||
public bool Active { get; set; }
|
||||
|
||||
[EmailAddress, MaxLength(127), ScaffoldColumn(false), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? DonationPayPalEmail { get; set; }
|
||||
|
||||
[Obsolete("Implement and use a new specialization setting")]
|
||||
[Display(Name="Maximal Daily Cost (euro/day)"),DisplayFormat(DataFormatString="{0:C}")]
|
||||
public int? MaxDailyCost { get; set; }
|
||||
|
|
|
|||
172
src/test/Yavsc.Api.Tests/PerformerDonationSettingsTests.cs
Normal file
172
src/test/Yavsc.Api.Tests/PerformerDonationSettingsTests.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class PerformerDonationSettingsTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private const string Endpoint = "/api/v1/account/performer-profile";
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private HttpClient Client(string? subject, string scope = "api")
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
}) { BaseAddress = new Uri(fixture.BaseAddress) };
|
||||
if (subject != null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
|
||||
TestTokenIssuer.Issue(subject, scope, extraClaims: [new Claim(Yavsc.Constants.RoleClaimType, "Performer")]));
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task<string> CreatePerformerAsync(bool withProfile = true)
|
||||
{
|
||||
var id = "donation-settings-" + Guid.NewGuid().ToString("N");
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Users.Add(new ApplicationUser { Id = id, UserName = id, Email = id + "@example.test" });
|
||||
if (withProfile) db.Performers.Add(new PerformerProfile
|
||||
{
|
||||
PerformerId = id, SIREN = "123456789", WebSite = "", Active = true,
|
||||
DonationPayPalEmail = "initial@payments.example",
|
||||
OrganizationAddress = new Location { Address = "Test" }
|
||||
});
|
||||
await db.SaveChangesAsync(Ct);
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_performer_can_set_a_recipient_when_creating_their_profile()
|
||||
{
|
||||
var id = await CreatePerformerAsync(withProfile: false);
|
||||
using var client = Client(id);
|
||||
var profile = await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("", profile.DonationPayPalEmail);
|
||||
profile.DonationPayPalEmail = "new@payments.example";
|
||||
using var response = await client.PutAsJsonAsync(Endpoint, profile, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("new@payments.example",
|
||||
(await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct))?.DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" new@payments.example ", "new@payments.example")]
|
||||
[InlineData(" ", "")]
|
||||
public async Task Recipient_is_trimmed_and_whitespace_disables_donations(string input, string expected)
|
||||
{
|
||||
var id = await CreatePerformerAsync();
|
||||
using var client = Client(id);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint, new { donationPayPalEmail = input }, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(expected,
|
||||
(await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct))?.DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Performer_can_read_update_and_disable_their_paypal_recipient_across_devices()
|
||||
{
|
||||
var id = await CreatePerformerAsync();
|
||||
using var client = Client(id);
|
||||
var profile = await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("initial@payments.example", profile.DonationPayPalEmail);
|
||||
using var read = await client.GetAsync(Endpoint, Ct);
|
||||
Assert.True(read.Headers.CacheControl?.NoStore);
|
||||
foreach (var email in new[] { "new@payments.example", "" })
|
||||
{
|
||||
profile.DonationPayPalEmail = email;
|
||||
using var response = await client.PutAsJsonAsync(Endpoint, profile, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var otherDevice = Client(id);
|
||||
var reloaded = await otherDevice.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(email, reloaded.DonationPayPalEmail);
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var stored = await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
||||
.Performers.AsNoTracking().SingleAsync(p => p.PerformerId == id, Ct);
|
||||
Assert.Equal(email.Length == 0 ? null : email, stored.DonationPayPalEmail);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Older_clients_omitting_or_sending_null_preserve_the_paypal_recipient(bool explicitNull)
|
||||
{
|
||||
var id = await CreatePerformerAsync();
|
||||
using var client = Client(id);
|
||||
using var response = explicitNull
|
||||
? await client.PutAsJsonAsync(Endpoint, new { performerId = id, donationPayPalEmail = (string?)null }, Ct)
|
||||
: await client.PutAsJsonAsync(Endpoint, new { performerId = id }, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var reloaded = await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct);
|
||||
Assert.Equal("initial@payments.example", reloaded?.DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("https://payments.example/donate")]
|
||||
[InlineData("a@b@c.example")]
|
||||
public async Task Invalid_recipient_returns_400_without_changing_the_profile(string email)
|
||||
{
|
||||
var id = await CreatePerformerAsync();
|
||||
using var client = Client(id);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint,
|
||||
new { performerId = id, donationPayPalEmail = email, webSite = "must-not-be-saved" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var profile = await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct);
|
||||
Assert.Equal("initial@payments.example", profile?.DonationPayPalEmail);
|
||||
Assert.Equal("", profile?.WebSite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Too_long_recipient_returns_400_without_saving()
|
||||
{
|
||||
var id = await CreatePerformerAsync();
|
||||
using var client = Client(id);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint,
|
||||
new { donationPayPalEmail = new string('a', 128) + "@example.test" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("initial@payments.example",
|
||||
(await client.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct))?.DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Performer_cannot_write_another_accounts_recipient()
|
||||
{
|
||||
var owner = await CreatePerformerAsync();
|
||||
var other = await CreatePerformerAsync();
|
||||
using var client = Client(owner);
|
||||
using var response = await client.PutAsJsonAsync(Endpoint,
|
||||
new { performerId = other, donationPayPalEmail = "attacker@payments.example" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
using var otherClient = Client(other);
|
||||
Assert.Equal("initial@payments.example",
|
||||
(await otherClient.GetFromJsonAsync<PerformerProfileSettings>(Endpoint, Ct))?.DonationPayPalEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "api", HttpStatusCode.Unauthorized)]
|
||||
[InlineData("anyone", "profile", HttpStatusCode.Forbidden)]
|
||||
public async Task Read_and_write_require_authentication_and_api_scope(string? subject, string scope, HttpStatusCode expected)
|
||||
{
|
||||
using var client = Client(subject, scope);
|
||||
using var read = await client.GetAsync(Endpoint, Ct);
|
||||
using var write = await client.PutAsJsonAsync(Endpoint, new { donationPayPalEmail = "test@payments.example" }, Ct);
|
||||
Assert.Equal(expected, read.StatusCode);
|
||||
Assert.Equal(expected, write.StatusCode);
|
||||
}
|
||||
}
|
||||
538
src/test/Yavsc.Org.Tests/Controllers/PerformerDonationTests.cs
Normal file
538
src/test/Yavsc.Org.Tests/Controllers/PerformerDonationTests.cs
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Payment;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Web.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Yavsc.Org.Tests.Controllers;
|
||||
|
||||
public sealed class PerformerDonationTests : IClassFixture<PerformerDonationTests.Factory>
|
||||
{
|
||||
private readonly Factory _factory;
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
public PerformerDonationTests(Factory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
factory.PayPal.Reset();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.PerformerDonations.RemoveRange(db.PerformerDonations);
|
||||
if (!db.Users.Any(u => u.Id == "author"))
|
||||
{
|
||||
db.Users.AddRange(
|
||||
new ApplicationUser { Id = "author", UserName = "Author", Email = "author@example.test" },
|
||||
new ApplicationUser { Id = "listener", UserName = "Listener", Email = "listener@example.test" });
|
||||
}
|
||||
if (!db.Performers.Any(p => p.PerformerId == "author"))
|
||||
{
|
||||
db.Performers.Add(new PerformerProfile
|
||||
{
|
||||
PerformerId = "author", Active = true, SIREN = "123456789", WebSite = "",
|
||||
OrganizationAddress = new Location { Address = "Test" }
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
var performer = db.Performers.Single(p => p.PerformerId == "author");
|
||||
performer.DonationPayPalEmail = "author@payments.example";
|
||||
performer.Active = true;
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/Donation/Create/author")]
|
||||
[InlineData("/Donation/Settings")]
|
||||
public async Task Donation_pages_require_a_web_session(string path)
|
||||
{
|
||||
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var response = await client.GetAsync(path, Ct);
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Contains("/signin", response.Headers.Location?.ToString());
|
||||
Assert.Empty(_factory.PayPal.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Performer_configures_only_their_own_recipient_and_can_disable_new_donations()
|
||||
{
|
||||
using var client = _factory.SignedInClient("author");
|
||||
using var response = await PostFormAsync(client, "/Donation/Settings", "/Donation/Settings", new()
|
||||
{
|
||||
["PayPalEmail"] = "new-recipient@payments.example", ["PerformerId"] = "listener"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("new-recipient@payments.example", await RecipientEmailAsync());
|
||||
using var disabled = await PostFormAsync(client, "/Donation/Settings", "/Donation/Settings", new() { ["PayPalEmail"] = "" });
|
||||
Assert.Equal(HttpStatusCode.Redirect, disabled.StatusCode);
|
||||
Assert.Null(await RecipientEmailAsync());
|
||||
using var listener = _factory.SignedInClient();
|
||||
using var create = await listener.GetAsync("/Donation/Create/author", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, create.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("https://external.example/donate")]
|
||||
public async Task Invalid_recipient_setting_is_rejected(string value)
|
||||
{
|
||||
using var client = _factory.SignedInClient("author");
|
||||
using var response = await PostFormAsync(client, "/Donation/Settings", "/Donation/Settings", new() { ["PayPalEmail"] = value });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("author@payments.example", await RecipientEmailAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_performer_cannot_configure_a_recipient()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
using var response = await client.GetAsync("/Donation/Settings", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Contains("/Manage/SetActivity", await response.Content.ReadAsStringAsync(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Checkout_uses_the_stored_recipient_and_existing_paypal_configuration()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
Assert.Equal("listener", donation.DonorId);
|
||||
Assert.Equal("author@payments.example", donation.RecipientEmail);
|
||||
Assert.Equal(5m, donation.Amount);
|
||||
Assert.Equal(PerformerDonationState.AwaitingApproval, donation.State);
|
||||
var request = Assert.Single(_factory.PayPal.Requests);
|
||||
Assert.Equal("SetExpressCheckout", request["METHOD"]);
|
||||
Assert.Equal("author@payments.example", request["PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"]);
|
||||
Assert.Equal("5.00", request["PAYMENTREQUEST_0_AMT"]);
|
||||
Assert.Equal("EUR", request["PAYMENTREQUEST_0_CURRENCYCODE"]);
|
||||
Assert.Equal("https://org.example/Donation/Notify", request["PAYMENTREQUEST_0_NOTIFYURL"]);
|
||||
Assert.StartsWith("https://org.example/Donation/Return?id=", request["RETURNURL"]);
|
||||
Assert.Equal(donation.Id.ToString("N"), request["PAYMENTREQUEST_0_INVNUM"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
[InlineData("5.001")]
|
||||
public async Task Invalid_amount_does_not_initiate_paypal(string amount)
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
using var response = await PostFormAsync(client, "/Donation/Create/author", "/Donation/Create", new()
|
||||
{
|
||||
["PerformerId"] = "author", ["Amount"] = amount
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(_factory.PayPal.Requests);
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
Assert.Empty(scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().PerformerDonations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Checkout_is_not_a_payment_and_confirmation_is_post_only_and_idempotent()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
using var returned = await client.GetAsync($"/Donation/Return/{donation.Id}?token={donation.CheckoutToken}&PayerID=untrusted", Ct);
|
||||
Assert.Equal(HttpStatusCode.Redirect, returned.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.AwaitingApproval, (await FindAsync(donation.Id)).State);
|
||||
using var get = await client.GetAsync($"/Donation/Confirm/{donation.Id}", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, get.StatusCode);
|
||||
using var confirmed = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Redirect, confirmed.StatusCode);
|
||||
var completed = await FindAsync(donation.Id);
|
||||
Assert.Equal(PerformerDonationState.Completed, completed.State);
|
||||
Assert.NotNull(completed.TransactionId);
|
||||
Assert.NotNull(completed.CompletedAt);
|
||||
var payment = _factory.PayPal.Requests.Single(r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
Assert.Equal("approved-payer", payment["PAYERID"]);
|
||||
Assert.Equal(donation.Id.ToString("N"), payment["MSGSUBID"]);
|
||||
using var again = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Redirect, again.StatusCode);
|
||||
Assert.Single(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_user_and_a_substituted_checkout_token_cannot_confirm_the_donation()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
using var author = _factory.SignedInClient("author");
|
||||
using var details = await author.GetAsync($"/Donation/Details/{donation.Id}", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, details.StatusCode);
|
||||
using var wrong = await client.GetAsync($"/Donation/Return/{donation.Id}?token=another-token", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, wrong.StatusCode);
|
||||
Assert.DoesNotContain(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payment_posts_require_antiforgery()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
using var response = await client.PostAsync("/Donation/Create",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["PerformerId"] = "author", ["Amount"] = "5" }), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(_factory.PayPal.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Changed_recipient_or_amount_in_paypal_approval_prevents_capture()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.MismatchApproval = true;
|
||||
using var response = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.DoesNotContain(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
Assert.Equal(PerformerDonationState.AwaitingApproval, (await FindAsync(donation.Id)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_capture_is_not_displayed_as_paid_and_verified_ipn_completes_it_once()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.Pending = true;
|
||||
using var captured = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Redirect, captured.StatusCode);
|
||||
var pending = await FindAsync(donation.Id);
|
||||
Assert.Equal(PerformerDonationState.Pending, pending.State);
|
||||
Assert.Null(pending.CompletedAt);
|
||||
using var ipn = await NotifyAsync(donation.Id);
|
||||
Assert.Equal(HttpStatusCode.OK, ipn.StatusCode);
|
||||
using var duplicate = await NotifyAsync(donation.Id);
|
||||
Assert.Equal(HttpStatusCode.OK, duplicate.StatusCode);
|
||||
var completed = await FindAsync(donation.Id);
|
||||
Assert.Equal(PerformerDonationState.Completed, completed.State);
|
||||
Assert.NotNull(completed.CompletedAt);
|
||||
Assert.Single(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_timeout_keeps_a_reconcilable_processing_state_without_charging_again()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.CaptureTimeout = true;
|
||||
using var response = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.Processing, (await FindAsync(donation.Id)).State);
|
||||
using var retry = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, retry.StatusCode);
|
||||
using var ipn = await NotifyAsync(donation.Id);
|
||||
Assert.Equal(HttpStatusCode.OK, ipn.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.Completed, (await FindAsync(donation.Id)).State);
|
||||
Assert.Single(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "author@payments.example", "5.00", "EUR", 400)]
|
||||
[InlineData(true, "someone-else@payments.example", "5.00", "EUR", 409)]
|
||||
[InlineData(true, "author@payments.example", "6.00", "EUR", 409)]
|
||||
[InlineData(true, "author@payments.example", "5.00", "USD", 409)]
|
||||
public async Task Forged_or_mismatched_ipn_cannot_mark_a_donation_paid(bool verified, string recipient, string amount, string currency, int status)
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.Pending = true;
|
||||
using var captured = await ConfirmAsync(client, donation.Id);
|
||||
_factory.PayPal.ValidNotification = verified;
|
||||
using var ipn = await NotifyAsync(donation.Id, recipient, amount, currency);
|
||||
Assert.Equal(status, (int)ipn.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.Pending, (await FindAsync(donation.Id)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_confirmation_sends_only_one_capture()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.HoldCapture = true;
|
||||
var first = ConfirmAsync(client, donation.Id);
|
||||
await _factory.PayPal.CaptureStarted.Task.WaitAsync(Ct);
|
||||
try
|
||||
{
|
||||
using var second = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
finally { _factory.PayPal.ReleaseCapture.TrySetResult(); }
|
||||
using var response = await first;
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Single(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Completed", PerformerDonationState.Completed)]
|
||||
[InlineData("Denied", PerformerDonationState.Failed)]
|
||||
public async Task Notification_received_during_capture_is_not_overwritten_by_a_stale_pending_response(
|
||||
string paymentStatus, PerformerDonationState expected)
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
_factory.PayPal.Pending = true;
|
||||
_factory.PayPal.HoldCapture = true;
|
||||
var capturing = ConfirmAsync(client, donation.Id);
|
||||
await _factory.PayPal.CaptureStarted.Task.WaitAsync(Ct);
|
||||
try
|
||||
{
|
||||
using var notification = await NotifyAsync(donation.Id, paymentStatus: paymentStatus);
|
||||
Assert.Equal(HttpStatusCode.OK, notification.StatusCode);
|
||||
}
|
||||
finally { _factory.PayPal.ReleaseCapture.TrySetResult(); }
|
||||
using var response = await capturing;
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal(expected, (await FindAsync(donation.Id)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Donation_keeps_its_original_recipient_when_the_performer_updates_their_settings()
|
||||
{
|
||||
using var donor = _factory.SignedInClient();
|
||||
var donation = await StartAsync(donor);
|
||||
using var author = _factory.SignedInClient("author");
|
||||
using var settings = await PostFormAsync(author, "/Donation/Settings", "/Donation/Settings", new()
|
||||
{
|
||||
["PayPalEmail"] = "new-recipient@payments.example"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Redirect, settings.StatusCode);
|
||||
using var confirmation = await ConfirmAsync(donor, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Redirect, confirmation.StatusCode);
|
||||
var payment = _factory.PayPal.Requests.Single(r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
Assert.Equal("author@payments.example", payment["PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"]);
|
||||
Assert.Equal("author@payments.example", (await FindAsync(donation.Id)).RecipientEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_exposes_only_the_current_donors_payments()
|
||||
{
|
||||
using var donor = _factory.SignedInClient();
|
||||
var donation = await StartAsync(donor);
|
||||
var ownHistory = await donor.GetStringAsync("/Donation", Ct);
|
||||
Assert.Contains(donation.Id.ToString(), ownHistory);
|
||||
using var other = _factory.SignedInClient("author");
|
||||
var otherHistory = await other.GetStringAsync("/Donation", Ct);
|
||||
Assert.DoesNotContain(donation.Id.ToString(), otherHistory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recipient_email_is_not_exposed_in_performer_json()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var profile = await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
||||
.Performers.AsNoTracking().SingleAsync(p => p.PerformerId == "author", Ct);
|
||||
Assert.DoesNotContain("author@payments.example", System.Text.Json.JsonSerializer.Serialize(profile));
|
||||
Assert.DoesNotContain("author@payments.example", Newtonsoft.Json.JsonConvert.SerializeObject(profile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task General_profile_edit_preserves_the_separately_configured_paypal_recipient()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var profile = await db.Performers.AsNoTracking().Include(p => p.OrganizationAddress)
|
||||
.SingleAsync(p => p.PerformerId == "author", Ct);
|
||||
profile.DonationPayPalEmail = null;
|
||||
profile.ExerciseCountryCode = "en";
|
||||
profile.SIREN = "AB12CD34";
|
||||
var controller = ManageController(scope.ServiceProvider);
|
||||
Assert.IsType<RedirectToActionResult>(await controller.SetActivity(profile));
|
||||
Assert.Equal("author@payments.example", await RecipientEmailAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Performer_with_donation_history_cannot_be_deleted_by_unsetting_their_activity()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
await StartAsync(client);
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var controller = ManageController(scope.ServiceProvider);
|
||||
Assert.IsType<ConflictObjectResult>(await controller.UnsetActivity());
|
||||
Assert.Equal("author@payments.example", await RecipientEmailAsync());
|
||||
}
|
||||
|
||||
private static Yavsc.Controllers.ManageController ManageController(IServiceProvider services)
|
||||
{
|
||||
var controller = ActivatorUtilities.CreateInstance<Yavsc.Controllers.ManageController>(services);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
RouteData = new Microsoft.AspNetCore.Routing.RouteData(),
|
||||
ActionDescriptor = new Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor(),
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = services,
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim("sub", "author"),
|
||||
new Claim("name", "Author"),
|
||||
new Claim(Yavsc.Constants.RoleClaimType, "Performer")
|
||||
], "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_return_has_no_side_effect_and_post_cancellation_prevents_capture()
|
||||
{
|
||||
using var client = _factory.SignedInClient();
|
||||
var donation = await StartAsync(client);
|
||||
using var get = await client.GetAsync($"/Donation/Cancel/{donation.Id}", Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.AwaitingApproval, (await FindAsync(donation.Id)).State);
|
||||
using var cancelled = await PostFormAsync(client, $"/Donation/Cancel/{donation.Id}", $"/Donation/CancelConfirm/{donation.Id}", new());
|
||||
Assert.Equal(HttpStatusCode.Redirect, cancelled.StatusCode);
|
||||
Assert.Equal(PerformerDonationState.Cancelled, (await FindAsync(donation.Id)).State);
|
||||
using var confirm = await ConfirmAsync(client, donation.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, confirm.StatusCode);
|
||||
Assert.DoesNotContain(_factory.PayPal.Requests, r => r["METHOD"] == "DoExpressCheckoutPayment");
|
||||
}
|
||||
|
||||
private async Task<PerformerDonation> StartAsync(HttpClient client)
|
||||
{
|
||||
using var response = await PostFormAsync(client, "/Donation/Create/author", "/Donation/Create", new()
|
||||
{
|
||||
["PerformerId"] = "author", ["Amount"] = "5",
|
||||
["RecipientEmail"] = "attacker@payments.example", ["Currency"] = "USD", ["DonorId"] = "author"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.StartsWith("https://www.sandbox.paypal.com/", response.Headers.Location?.ToString());
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().PerformerDonations.AsNoTracking().SingleAsync(Ct);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> ConfirmAsync(HttpClient client, Guid id)
|
||||
=> PostFormAsync(client, "/Donation/Create/author", $"/Donation/Confirm/{id}", new());
|
||||
|
||||
private static async Task<HttpResponseMessage> PostFormAsync(HttpClient client, string page, string action, Dictionary<string, string> fields)
|
||||
{
|
||||
var html = await client.GetStringAsync(page, Ct);
|
||||
var match = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"");
|
||||
Assert.True(match.Success, "The page must render an antiforgery-protected form.");
|
||||
fields["__RequestVerificationToken"] = WebUtility.HtmlDecode(match.Groups[1].Value);
|
||||
return await client.PostAsync(action, new FormUrlEncodedContent(fields), Ct);
|
||||
}
|
||||
|
||||
private async Task<PerformerDonation> FindAsync(Guid id)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().PerformerDonations.AsNoTracking().SingleAsync(d => d.Id == id, Ct);
|
||||
}
|
||||
|
||||
private async Task<string?> RecipientEmailAsync()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Performers
|
||||
.Where(p => p.PerformerId == "author").Select(p => p.DonationPayPalEmail).SingleAsync(Ct);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> NotifyAsync(Guid id, string recipient = "author@payments.example",
|
||||
string amount = "5.00", string currency = "EUR", string paymentStatus = "Completed")
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
return await client.PostAsync("/Donation/Notify", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["invoice"] = id.ToString("N"), ["receiver_email"] = recipient, ["mc_gross"] = amount,
|
||||
["mc_currency"] = currency, ["txn_id"] = "payment-" + id.ToString("N")[..16],
|
||||
["payment_status"] = paymentStatus, ["txn_type"] = "express_checkout"
|
||||
}), Ct);
|
||||
}
|
||||
|
||||
public sealed class Factory : PodcastWebTests.Factory
|
||||
{
|
||||
public FakePayPal PayPal { get; } = new();
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.Configure<PayPalSettings>(settings =>
|
||||
{
|
||||
settings.Mode = "sandbox";
|
||||
settings.MerchantAccountUserName = "test-merchant";
|
||||
settings.Accounts = [new()
|
||||
{
|
||||
ApiUsername = "test-merchant", ApiPassword = "test-password", Signature = "test-signature"
|
||||
}];
|
||||
});
|
||||
services.AddTransient<IPayPalDonationGateway>(provider => new PayPalDonationGateway(
|
||||
new HttpClient(PayPal, disposeHandler: false), provider.GetRequiredService<IOptions<PayPalSettings>>()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakePayPal : HttpMessageHandler
|
||||
{
|
||||
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _checkouts = new();
|
||||
public bool Pending { get; set; }
|
||||
public bool MismatchApproval { get; set; }
|
||||
public bool CaptureTimeout { get; set; }
|
||||
public bool ValidNotification { get; set; }
|
||||
public bool HoldCapture { get; set; }
|
||||
public TaskCompletionSource CaptureStarted { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource ReleaseCapture { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Requests.Clear();
|
||||
_checkouts.Clear();
|
||||
Pending = MismatchApproval = CaptureTimeout = HoldCapture = false;
|
||||
ValidNotification = true;
|
||||
CaptureStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ReleaseCapture = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var body = await request.Content!.ReadAsStringAsync(ct);
|
||||
if (request.RequestUri!.Host.StartsWith("ipnpb.", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.StartsWith("cmd=_notify-validate&", body);
|
||||
return new(HttpStatusCode.OK) { Content = new StringContent(ValidNotification ? "VERIFIED" : "INVALID") };
|
||||
}
|
||||
Assert.Equal("https://api-3t.sandbox.paypal.com/nvp", request.RequestUri.AbsoluteUri);
|
||||
var fields = body.Split('&').Select(pair => pair.Split('=', 2))
|
||||
.ToDictionary(p => WebUtility.UrlDecode(p[0]), p => WebUtility.UrlDecode(p[1]));
|
||||
Requests.Enqueue(fields);
|
||||
Dictionary<string, string> result = new() { ["ACK"] = "Success" };
|
||||
switch (fields["METHOD"])
|
||||
{
|
||||
case "SetExpressCheckout":
|
||||
var token = "checkout-" + fields["PAYMENTREQUEST_0_INVNUM"];
|
||||
_checkouts[token] = fields;
|
||||
result["TOKEN"] = token;
|
||||
break;
|
||||
case "GetExpressCheckoutDetails":
|
||||
var checkout = _checkouts[fields["TOKEN"]];
|
||||
result["TOKEN"] = fields["TOKEN"];
|
||||
result["PAYERID"] = "approved-payer";
|
||||
foreach (var name in new[] { "INVNUM", "AMT", "CURRENCYCODE", "SELLERPAYPALACCOUNTID" })
|
||||
result["PAYMENTREQUEST_0_" + name] = checkout["PAYMENTREQUEST_0_" + name];
|
||||
if (MismatchApproval) result["PAYMENTREQUEST_0_AMT"] = "1000.00";
|
||||
break;
|
||||
case "DoExpressCheckoutPayment":
|
||||
CaptureStarted.TrySetResult();
|
||||
if (HoldCapture) await ReleaseCapture.Task.WaitAsync(ct);
|
||||
if (CaptureTimeout) throw new TaskCanceledException("Simulated PayPal timeout.");
|
||||
result["PAYMENTINFO_0_AMT"] = fields["PAYMENTREQUEST_0_AMT"];
|
||||
result["PAYMENTINFO_0_CURRENCYCODE"] = fields["PAYMENTREQUEST_0_CURRENCYCODE"];
|
||||
result["PAYMENTINFO_0_SELLERPAYPALACCOUNTID"] = fields["PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"];
|
||||
result["PAYMENTINFO_0_PAYMENTSTATUS"] = Pending ? "Pending" : "Completed";
|
||||
result["PAYMENTINFO_0_TRANSACTIONID"] = "payment-" + fields["PAYMENTREQUEST_0_INVNUM"][..16];
|
||||
break;
|
||||
default: throw new InvalidOperationException("Unexpected PayPal operation.");
|
||||
}
|
||||
using var content = new FormUrlEncodedContent(result);
|
||||
return new(HttpStatusCode.OK) { Content = new StringContent(await content.ReadAsStringAsync(ct), Encoding.UTF8, "application/x-www-form-urlencoded") };
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/test/Yavsc.Org.Tests/Controllers/PodcastWebTests.cs
Normal file
319
src/test/Yavsc.Org.Tests/Controllers/PodcastWebTests.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using IdentityServer8;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Web.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Yavsc.Org.Tests.Controllers;
|
||||
|
||||
public sealed class PodcastWebTests : IClassFixture<PodcastWebTests.Factory>
|
||||
{
|
||||
private readonly Factory _factory;
|
||||
private static readonly Guid PodcastId = Guid.Parse("bac67914-b844-4a15-94bf-49d82b0b0212");
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
public PodcastWebTests(Factory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
factory.Api.Reset();
|
||||
factory.Services.GetRequiredService<IOptions<SiteSettings>>().Value.ApiUrl = "https://api.example/";
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
if (!db.Users.Any(u => u.Id == "author"))
|
||||
{
|
||||
db.Users.AddRange(new ApplicationUser { Id = "author", UserName = "Author", Email = "author@example.test" },
|
||||
new ApplicationUser { Id = "listener", UserName = "Listener", Email = "listener@example.test" });
|
||||
}
|
||||
if (!db.Performers.Any(p => p.PerformerId == "author"))
|
||||
{
|
||||
db.Performers.Add(new PerformerProfile
|
||||
{
|
||||
PerformerId = "author", Active = true, DonationPayPalEmail = "author@payments.example",
|
||||
SIREN = "123456789", WebSite = "", OrganizationAddress = new Location { Address = "Test" }
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/Podcast")]
|
||||
[InlineData("/Podcast/bac67914-b844-4a15-94bf-49d82b0b0212/index.m3u8")]
|
||||
[InlineData("/Podcast/bac67914-b844-4a15-94bf-49d82b0b0212/segments/0.ts")]
|
||||
[InlineData("/Podcast/bac67914-b844-4a15-94bf-49d82b0b0212/replay.mp4")]
|
||||
public async Task Anonymous_requests_require_login_before_contacting_api(string path)
|
||||
{
|
||||
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var response = await client.GetAsync(path, Ct);
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Contains("/signin", response.Headers.Location?.ToString());
|
||||
Assert.Empty(_factory.Api.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Catalogue_uses_the_authority_user_token_without_an_api_client_or_external_donation_link()
|
||||
{
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/Podcast", Ct);
|
||||
var html = await response.Content.ReadAsStringAsync(Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains($"/Podcast/{PodcastId}/replay.mp4", html);
|
||||
Assert.Contains("/Donation/Create/author", html);
|
||||
Assert.Contains("/Donation/Settings", html);
|
||||
Assert.DoesNotContain("<script>unsafe-title</script>", html);
|
||||
Assert.Contains("<script>unsafe-title</script>", html);
|
||||
Assert.Contains("hls.light.min.js", html);
|
||||
Assert.True(response.Headers.CacheControl?.NoStore);
|
||||
var requests = _factory.Api.Requests.ToArray();
|
||||
var request = Assert.Single(requests);
|
||||
Assert.Equal("/api/v1/podcasts/", request.Path);
|
||||
Assert.StartsWith("Bearer ", request.Authorization);
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(request.Authorization![7..]);
|
||||
Assert.Contains(token.Claims, c => c.Type == "sub" && c.Value == "listener");
|
||||
Assert.Contains(token.Claims, c => c.Type == "scope" && c.Value == "api");
|
||||
Assert.Contains("api", token.Audiences);
|
||||
Assert.True(token.ValidTo - token.ValidFrom <= TimeSpan.FromMinutes(5));
|
||||
Assert.DoesNotContain(request.Authorization[7..], html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_catalogue_is_not_an_api_failure()
|
||||
{
|
||||
_factory.Api.Empty = true;
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/Podcast", Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Aucun podcast", await response.Content.ReadAsStringAsync(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upstream_failure_returns_explicit_error_not_empty_catalogue()
|
||||
{
|
||||
_factory.Api.Status = HttpStatusCode.ServiceUnavailable;
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/Podcast", Ct);
|
||||
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
|
||||
var html = await response.Content.ReadAsStringAsync(Ct);
|
||||
Assert.Contains("alert-danger", html);
|
||||
Assert.DoesNotContain("Aucun podcast", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Playlist_uses_authenticated_same_origin_segment_routes()
|
||||
{
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync($"/Podcast/{PodcastId}/index.m3u8", Ct);
|
||||
var playlist = await response.Content.ReadAsStringAsync(Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/vnd.apple.mpegurl", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Contains("#EXTINF:2.5,\n", playlist);
|
||||
Assert.Contains($"/Podcast/{PodcastId}/segments/0.ts", playlist);
|
||||
Assert.EndsWith("#EXT-X-ENDLIST\n", playlist);
|
||||
Assert.DoesNotContain("access_token", playlist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_catalogue_without_donation_keeps_playlist_open()
|
||||
{
|
||||
_factory.Api.Live = true;
|
||||
using var client = SignedInClient();
|
||||
var html = await client.GetStringAsync("/Podcast", Ct);
|
||||
Assert.Contains($"/Podcast/{PodcastId}/index.m3u8", html);
|
||||
Assert.DoesNotContain("/Donation/Create/other-author", html);
|
||||
Assert.Contains("PayPal", html);
|
||||
var playlist = await client.GetStringAsync($"/Podcast/{PodcastId}/index.m3u8", Ct);
|
||||
Assert.DoesNotContain("#EXT-X-ENDLIST", playlist);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("HEAD")]
|
||||
public async Task Replay_preserves_byte_range_status_headers_and_head_semantics(string method)
|
||||
{
|
||||
using var client = SignedInClient();
|
||||
using var request = new HttpRequestMessage(new HttpMethod(method), $"/Podcast/{PodcastId}/replay.mp4");
|
||||
request.Headers.Range = new RangeHeaderValue(2, 5);
|
||||
using var response = await client.SendAsync(request, Ct);
|
||||
Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode);
|
||||
Assert.Equal("bytes 2-5/10", response.Content.Headers.ContentRange?.ToString());
|
||||
Assert.Equal(4, response.Content.Headers.ContentLength);
|
||||
Assert.Contains("bytes", response.Headers.AcceptRanges);
|
||||
Assert.Equal("video/mp4", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.True(response.Headers.CacheControl?.NoStore);
|
||||
Assert.Equal(method == "HEAD" ? [] : new byte[] { 2, 3, 4, 5 },
|
||||
await response.Content.ReadAsByteArrayAsync(Ct));
|
||||
var forwarded = _factory.Api.Requests.Last();
|
||||
Assert.Equal(method, forwarded.Method);
|
||||
Assert.Equal("bytes=2-5", forwarded.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsatisfiable_replay_range_preserves_416_and_total_size()
|
||||
{
|
||||
using var client = SignedInClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Podcast/{PodcastId}/replay.mp4");
|
||||
request.Headers.Range = new RangeHeaderValue(100, 200);
|
||||
using var response = await client.SendAsync(request, Ct);
|
||||
Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode);
|
||||
Assert.Equal("bytes */10", response.Content.Headers.ContentRange?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_media_returns_404()
|
||||
{
|
||||
_factory.Api.Status = HttpStatusCode.NotFound;
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync($"/Podcast/{PodcastId}/segments/0.ts", Ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Local_hls_asset_is_served_without_a_cdn()
|
||||
{
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/js/hls.light.min.js", Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Hls", await response.Content.ReadAsStringAsync(Ct));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.example/")]
|
||||
[InlineData("https://api.example/api/v1")]
|
||||
public async Task Api_base_url_preserves_the_existing_versioned_prefix(string address)
|
||||
{
|
||||
_factory.Services.GetRequiredService<IOptions<SiteSettings>>().Value.ApiUrl = address;
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/Podcast", Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("/api/v1/podcasts/", _factory.Api.Requests.Last().Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("http://api.example/")]
|
||||
public async Task Invalid_api_configuration_returns_503_without_network_access(string address)
|
||||
{
|
||||
_factory.Services.GetRequiredService<IOptions<SiteSettings>>().Value.ApiUrl = address;
|
||||
using var client = SignedInClient();
|
||||
using var response = await client.GetAsync("/Podcast", Ct);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Empty(_factory.Api.Requests);
|
||||
}
|
||||
|
||||
private HttpClient SignedInClient() => _factory.SignedInClient();
|
||||
|
||||
public class Factory : TestWebApplicationFactory
|
||||
{
|
||||
public FakeApi Api { get; } = new();
|
||||
private readonly string _databaseName = "InMemory-PodcastWeb-" + Guid.NewGuid().ToString("N");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(
|
||||
new Dictionary<string, string?> { ["ConnectionStrings:YavscConnection"] = _databaseName }));
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IAuthorizationPolicyProvider, DefaultAuthorizationPolicyProvider>();
|
||||
services.Configure<SiteSettings>(options =>
|
||||
{
|
||||
options.ApiUrl = "https://api.example/";
|
||||
options.ExternalUrl = "https://org.example/";
|
||||
});
|
||||
services.AddTransient(provider => new PodcastWebClient(
|
||||
new HttpClient(Api, disposeHandler: false),
|
||||
provider.GetRequiredService<IOptions<SiteSettings>>(),
|
||||
provider.GetRequiredService<IdentityServerTools>(),
|
||||
provider.GetRequiredService<IHttpContextAccessor>()));
|
||||
});
|
||||
}
|
||||
|
||||
public HttpClient SignedInClient(string userId = "listener")
|
||||
{
|
||||
var client = CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var options = Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(IdentityConstants.ApplicationScheme);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Name, userId)
|
||||
], IdentityConstants.ApplicationScheme));
|
||||
var ticket = new AuthenticationTicket(principal, new AuthenticationProperties
|
||||
{
|
||||
IssuedUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
}, IdentityConstants.ApplicationScheme);
|
||||
client.DefaultRequestHeaders.Add("Cookie", $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeApi : HttpMessageHandler
|
||||
{
|
||||
public sealed record Request(string Path, string Method, string? Authorization, string? Range);
|
||||
public ConcurrentQueue<Request> Requests { get; } = new();
|
||||
public bool Empty { get; set; }
|
||||
public bool Live { get; set; }
|
||||
public HttpStatusCode Status { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Requests.Clear();
|
||||
Empty = false;
|
||||
Live = false;
|
||||
Status = HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
Requests.Enqueue(new(path, request.Method.Method,
|
||||
request.Headers.Authorization?.ToString(), request.Headers.Range?.ToString()));
|
||||
if (Status != HttpStatusCode.OK)
|
||||
return Task.FromResult(new HttpResponseMessage(Status));
|
||||
var podcast = new PodcastResponse(PodcastId, "<script>unsafe-title</script>",
|
||||
Live ? "other-author" : "author", "Author", DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow, Live ? PodcastState.Live : PodcastState.Completed, 1, 188, 2.5)
|
||||
{
|
||||
Replay = Live ? null : new(PodcastReplayState.Ready, 10)
|
||||
};
|
||||
if (path == "/api/v1/podcasts/")
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(Empty ? Array.Empty<PodcastResponse>() : [podcast])
|
||||
});
|
||||
if (path == $"/api/v1/podcasts/{PodcastId}")
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new PodcastManifestResponse(podcast, [new(0, 2.5, 188)]))
|
||||
});
|
||||
var range = request.Headers.Range?.Ranges.SingleOrDefault();
|
||||
var unsatisfiable = range?.From >= 10;
|
||||
var content = new ByteArrayContent(unsatisfiable ? [] : [2, 3, 4, 5]);
|
||||
if (range != null)
|
||||
content.Headers.ContentRange = unsatisfiable
|
||||
? new ContentRangeHeaderValue(10) : new ContentRangeHeaderValue(2, 5, 10);
|
||||
var result = new HttpResponseMessage(unsatisfiable ? HttpStatusCode.RequestedRangeNotSatisfiable
|
||||
: range != null ? HttpStatusCode.PartialContent : HttpStatusCode.OK) { Content = content };
|
||||
result.Headers.AcceptRanges.Add("bytes");
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,14 @@ public class SetActivityCountryValidationViewTests
|
|||
Assert.Contains("asp-items=\"ViewBag.Countries\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Performer_profile_links_to_the_dedicated_private_donation_settings_form()
|
||||
{
|
||||
var content = File.ReadAllText(ResolveSetActivityViewPath());
|
||||
Assert.Contains("asp-controller=\"Donation\" asp-action=\"Settings\"", content);
|
||||
Assert.DoesNotContain("asp-for=\"DonationPayPalEmail\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetActivity_cshtml_contains_country_aware_siren_javascript_validation()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue