Add performer public permalink actions
All checks were successful
Dotnet build and test / build (push) Successful in 15m8s
Forgejo Release / release (push) Successful in 8m23s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-21 17:25:07 +01:00
commit d192705f29
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
5 changed files with 187 additions and 24 deletions

View file

@ -1,12 +1,12 @@
# Changelog
## [1.0.8-rc30] - unstable
## [1.0.8-rc31] - unstable
### Added
* [Yavsc.Org] Permalien public de podcasts performer avec jeton a duree
limitee, page Razor de presentation et bandeau de prospection vers
l'inscription Yavsc et le telechargement de PostIt.
* [Yavsc.Org] Permalien public de podcasts performer avec jeton a durée
limitée, page Razor de presentation et bandeau de prospection vers
l'inscription Yavsc et le téléchargement de PostIt.
### Changed
@ -16,20 +16,31 @@
* avoid NRE in MailSender on rdv query notifications
* Android emulator smoke test skipped in CI because Calabash instrumentation signature mismatches the target APK on hosted runners
## [1.0.8-rc30] - unstable
### Added
### Changed
### Fixed
* Podcast availability from Android
## [1.0.8-rc29] - unstable
### Added
* [PostIt.Android] Lecture des podcasts directs et replays dans la page
video partagee avec le desktop : surface LibVLC native Android et moteur
embarque, transport authentifie commun et controles uniques. Liberation
du lecteur en arriere-plan ; reouverture du flux au retour dans l'application.
video partagée avec le desktop : surface LibVLC native Android et moteur
embarque, transport authentifie commun et contrôles uniques. Liberation
du lecteur en arrière-plan ; réouverture du flux au retour dans l'application.
* [PostIt.Android] Emission depuis la camera et le microphone Android
(Android 8 minimum), encodage H.264/AAC natif et segmentation MPEG-TS
sur images cles. Interface, envoi authentifie, retries et finalisation
sur images clés. Interface, envoi authentifie, retries et finalisation
du replay partages avec le desktop. Service de premier plan avec
notification d'arret et maintien de la capture en arriere-plan.
notification d'arrêt et maintien de la capture en arrière-plan.
### Changed
@ -39,7 +50,7 @@
* [Yavsc.Api, Yavsc.Server] Une demande de rendez-vous ne provoque plus
d'erreur 500 (NullReferenceException dans MailSender) : l'API lie
desormais la section Smtp, le modele appsettings-api fournit Site.Owner
désormais la section Smtp, le modèle appsettings-api fournit Site.Owner
et Smtp, et un envoi sans configuration mail complete est journalise
puis ignore.
@ -51,38 +62,38 @@
* [Yavsc.Api, Yavsc.Server, Yavsc.Api.Client] Creation explicite et
idempotente d'une facture depuis un devis doublement signe, avec lignes,
total et parties figes. Historique des encaissements hors ligne attestes
par le prestataire et des transactions PayPal verifiees cote serveur ;
references uniques, cumul atomique et protection contre les ecritures
concurrentes. Routes documentees, client HTTP et migration associes.
par le prestataire et des transactions PayPal vérifiées cote serveur ;
references uniques, cumul atomique et protection contre les écritures
concurrentes. Routes documentées, client HTTP et migration associes.
* [Yavsc.Abstract, Yavsc.Server] Modele de solde des commandes facturables :
montants dus et payes persistants, reste a payer et etats non paye,
* [Yavsc.Abstract, Yavsc.Server] Modèle de solde des commandes facturables :
montants dus et payes persistants, reste a payer et états non paye,
partiellement paye ou acquitte. L'acquittement depend des montants,
et non du seul statut PayPal ; migration sans montant historique invente.
* [PostIt.Desktop, Yavsc.Api] Podcasts en direct avec replay HLS conserve :
capture camera et microphone par LibVLC (H.264/AAC, segments MPEG-TS),
envoi ordonne et idempotent, catalogue et lecture authentifiee dans PostIt.
envoi ordonne et idempotent, catalogue et lecture authentifiée dans PostIt.
L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur
disque, quotas dedies et conservation des segments recus apres interruption.
disque, quotas dédiés et conservation des segments reçus apres interruption.
### Changed
### Fixed
* [Facturation] Les commandes facturees et leur devis ne peuvent plus etre
* [Facturation] Les commandes facturées et leur devis ne peuvent plus être
supprimes via les routes de commande usuelles. Les additions distinguent
montant verse, solde restant et acquittement ; les anciennes routes de
generation exigent une facture etablie depuis un devis signe.
generation exigent une facture établie depuis un devis signe.
## [1.0.8-rc27] - unstable
### Added
* [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans
elevation : publication autonome, raccourci du menu Demarrer,
protocole `postit://` pour la connexion et desinstallation via Windows.
Instructions de generation ajoutees dans `INSTALL.md`.
elevation : publication autonome, raccourci du menu Démarrer,
protocole `postit://` pour la connexion et désinstallation via Windows.
Instructions de generation ajoutées dans `INSTALL.md`.
* [PostIt, Yavsc.Api] Portage des outils des contrôleurs web
d’administration : tableau de bord, rôles et affectations, clients OIDC

View file

@ -65,6 +65,20 @@ Rituels recommandes:
- Mensuel: mise a jour du present guide et du playbook.
- A chaque incident: ajouter un anti-pattern dans le playbook.
### Reseau de versionnement / git flow pour le CHANGELOG
Regle obligatoire:
- La section courante du CHANGELOG représente la prochaine version a publier, pas un tag deja créé.
- Un tag deja publie bloque définitivement la section correspondante du CHANGELOG.
- Au commit suivant, si la modification impacte le CHANGELOG, il faut incrémenter la version exposée dans le changelog (version du prochain tag attendu), par exemple: `1.0.8-rc30 -> 1.0.8-rc31`.
Ordre a respecter a chaque commit qui modifie le produit:
1. Verifier si le dernier tag du CHANGELOG existe deja; si oui, incrémenter la version de la section suivante au lieu de re-écrire la dernière section bloquée.
2. Ouvrir une nouvelle section du CHANGELOG avec ce numero de version incrémenté.
3. Apres seulement, alimenter cette section avec la description du changement.
En pratique: la version du CHANGELOG est la version "future" a tagger, et elle est incrémentée avant l'écriture de la description; le tag n'est pas créé avant la section, il est seulement établi ensuite lors de la publication effective.
## Check-list de validation
- Le changement indique son impact architecture.

View file

@ -113,6 +113,37 @@ namespace Yavsc.Controllers
return View(podcasts);
}
[AllowAnonymous]
public async Task<ActionResult> PerformerActivity(string id, string activityCode, string? token)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(activityCode)) return NotFound();
if (!TryValidateToken(id, activityCode, token, out var expireAt))
return BadRequest("Ce lien public pour cette activité a expire ou est invalide.");
var performer = await _context.Performers
.Include(p => p.Performer)
.FirstOrDefaultAsync(p => p.PerformerId == id);
var activity = await _context.Activities
.FirstOrDefaultAsync(a => a.Code == activityCode);
if (performer == null || activity == null) return NotFound();
var profile = (await _context.ListPerformersAsync(_billing, activityCode))
.Where(p => string.Equals(p.PerformerId, id, StringComparison.Ordinal))
.ToList();
ViewBag.Activity = activity;
ViewBag.PerformerName = performer.Performer?.UserName ?? performer.PerformerId;
ViewBag.PerformerId = id;
ViewBag.PermalinkExpiry = expireAt;
ViewBag.Permalink = Url.Action(nameof(PerformerActivity), "FrontOffice", new { id, activityCode, token = CreateActivityToken(id, activityCode) }, Request.Scheme, Request.Host.Value);
return View("Profiles", profile);
}
[HttpPost]
[Authorize]
public IActionResult GeneratePerformerPodcastPermalink(string id)
{
@ -124,6 +155,18 @@ namespace Yavsc.Controllers
return RedirectToAction(nameof(PerformerPodcasts), new { id, token });
}
[HttpPost]
[Authorize]
public IActionResult GeneratePerformerActivityPermalink(string id, string activityCode)
{
var currentUserId = User.GetUserId();
if (string.IsNullOrWhiteSpace(currentUserId) || !string.Equals(currentUserId, id, StringComparison.Ordinal))
return Forbid();
var token = CreateActivityToken(id, activityCode);
return RedirectToAction(nameof(PerformerActivity), new { id, activityCode, token });
}
private string CreateToken(string performerId)
{
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
@ -136,20 +179,45 @@ namespace Yavsc.Controllers
return protector.Protect(JsonSerializer.Serialize(payload));
}
private string CreateActivityToken(string performerId, string activityCode)
{
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
var protector = _dataProtectionProvider.CreateProtector("Yavsc.Org.PerformerActivityLink");
var payload = new
{
performerId,
activityCode,
exp = expiresAt.ToUnixTimeSeconds(),
};
return protector.Protect(JsonSerializer.Serialize(payload));
}
private bool TryValidateToken(string performerId, string? token, out DateTimeOffset validUntil)
=> TryValidateTokenInternal(performerId, null, token, out validUntil, "Yavsc.Org.PerformerPodcastLink");
private bool TryValidateToken(string performerId, string activityCode, string? token, out DateTimeOffset validUntil)
=> TryValidateTokenInternal(performerId, activityCode, token, out validUntil, "Yavsc.Org.PerformerActivityLink");
private bool TryValidateTokenInternal(string performerId, string? activityCode, string? token, out DateTimeOffset validUntil, string protectorName)
{
validUntil = DateTimeOffset.MinValue;
if (string.IsNullOrWhiteSpace(token)) return false;
try
{
var protector = _dataProtectionProvider.CreateProtector("Yavsc.Org.PerformerPodcastLink");
var protector = _dataProtectionProvider.CreateProtector(protectorName);
var payload = JsonDocument.Parse(protector.Unprotect(token));
var ownerId = payload.RootElement.GetProperty("performerId").GetString();
var expiry = payload.RootElement.GetProperty("exp").GetInt64();
if (!string.Equals(ownerId, performerId, StringComparison.Ordinal)) return false;
if (!string.IsNullOrWhiteSpace(activityCode))
{
if (!payload.RootElement.TryGetProperty("activityCode", out var activityPayload)) return false;
if (!string.Equals(activityPayload.GetString(), activityCode, StringComparison.Ordinal)) return false;
}
validUntil = DateTimeOffset.FromUnixTimeSeconds(expiry);
return validUntil > DateTimeOffset.UtcNow;
}

View file

@ -2,6 +2,7 @@
@{
ViewBag.Title = $"Podcasts de {ViewBag.PerformerName}";
var performerName = ViewBag.PerformerName ?? "ce performer";
var permalinkExpiry = ViewBag.PermalinkExpiry is DateTimeOffset expiry ? expiry : DateTimeOffset.MinValue;
}
<div class="container py-4">
@ -9,10 +10,11 @@
<div>
<h1 class="h3 mb-1">Découvrez les podcasts de @performerName</h1>
<p class="mb-0 text-muted">
Ce lien public est valable jusqu'au @(((DateTimeOffset)ViewBag.PermalinkExpiry).ToLocalTime().ToString("g")).
Ce lien public est valable jusqu'au @permalinkExpiry.ToLocalTime().ToString("g").
</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" onclick="copyPermalink()">Copier le permalien</button>
<a class="btn btn-primary" href="https://www.yavsc.fr/" target="_blank" rel="noreferrer">S'inscrire</a>
<a class="btn btn-outline-primary" href="https://postit.yavsc.fr/" target="_blank" rel="noreferrer">Installer PostIt</a>
</div>
@ -57,3 +59,29 @@
</div>
}
</div>
<script>
function copyPermalink() {
const url = '@(ViewBag.Permalink ?? string.Empty)';
if (!url) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).catch(() => fallbackCopy(url));
return;
}
fallbackCopy(url);
}
function fallbackCopy(value) {
const temp = document.createElement('textarea');
temp.value = value;
temp.setAttribute('readonly', '');
temp.style.position = 'fixed';
temp.style.left = '-9999px';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
}
</script>

View file

@ -2,10 +2,26 @@
@{
ViewBag.Title = (ViewBag.Activity?.Name ?? "Any") ;
var activityCode = ViewBag.Activity?.Code as string;
var performerId = ViewBag.PerformerId as string;
var permalinkExpiry = ViewBag.PermalinkExpiry is DateTimeOffset expiry ? expiry : DateTimeOffset.MinValue;
}
<h1>@ViewBag.Title</h1>
<em>@ViewBag.Activity.Description</em>
@if (!string.IsNullOrWhiteSpace(performerId) && !string.IsNullOrWhiteSpace(activityCode))
{
<div class="alert alert-info d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mt-3" role="alert">
<div>
<div class="fw-semibold">Permalien public</div>
<small class="text-muted">Valable jusqu'au @permalinkExpiry.ToLocalTime().ToString("g")</small>
</div>
<button type="button" class="btn btn-primary" onclick="copyPermalink()">
Copier le permalien
</button>
</div>
}
@foreach (var profile in Model) {
<hr/>
@Html.DisplayFor(m=>profile)
@ -15,3 +31,29 @@
</a>
}
<script>
function copyPermalink() {
const url = '@(ViewBag.Permalink ?? string.Empty)';
if (!url) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).catch(() => fallbackCopy(url));
return;
}
fallbackCopy(url);
}
function fallbackCopy(value) {
const temp = document.createElement('textarea');
temp.value = value;
temp.setAttribute('readonly', '');
temp.style.position = 'fixed';
temp.style.left = '-9999px';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
}
</script>