Compare commits

...
13 Commits
Author SHA1 Message Date
nicolas e96edfce4f Merge pull request 'Dev/3.1.0' (#133) from dev/3.1.0 into master
Reviewed-on: #133
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-09 16:43:55 +01:00
nicolas 11721e9260 Version 3.1.0 : captures d'écran, liens signalés, journal d'audit lisible 2026-11-09 16:37:42 +01:00
nicolas 5d9f413f57 UiSnapshot : fenêtre « Aide » et fiche des demandes des joueurs 2026-11-09 16:29:39 +01:00
nicolas cde3b337e7 Aide : fenêtre centrée et formulaire avec ascenseur (taille de base réduite) 2026-11-09 16:19:47 +01:00
nicolas 1c198f1204 Docs : captures d'écran, avertissement sur les liens, signature des requêtes 2026-11-09 16:03:25 +01:00
nicolas 2d26416a7a Tests : liens, captures d'écran et libellés du journal d'audit 2026-11-09 16:02:59 +01:00
nicolas 24de56cdcb Demandes des joueurs : avertissement sur les liens et affichage des captures 2026-11-09 16:01:56 +01:00
nicolas 8a7e51fa07 Aide : ajout de captures d'écran par fichier ou Ctrl+V, réencodées sans métadonnées 2026-11-09 16:01:32 +01:00
nicolas e18ebf818f Client API : envoi des captures (joueur) et lecture des captures (menu) 2026-11-09 16:00:47 +01:00
nicolas 821d31ce30 Déploiement : bloc nginx des captures d'écran et mise à jour de l'étape 3 octies 2026-11-09 16:00:33 +01:00
nicolas db4c874e13 Serveur : captures d'écran des demandes (jeton d'envoi, contrôle de l'image, plafond 1 Go) 2026-11-09 16:00:16 +01:00
nicolas 64fb944038 Contrats : détection des liens et contrôle des captures d'écran (PNG/JPEG sans métadonnées) 2026-11-09 15:59:27 +01:00
nicolas bc3bf4fbba Audit : libellés lisibles pour les actions des demandes des joueurs 2026-11-09 15:58:55 +01:00
40 changed files with 2334 additions and 162 deletions

No files matched your search

+7 -2
View File
@@ -525,11 +525,16 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
l'envoi avec les règles du serveur ; informations techniques facultatives (versions, Windows, langue) montrées avant
l'envoi, cochées par défaut pour un bug. Demande **anonyme** : aucun compte, aucune adresse IP enregistrée ; le
joueur reçoit une référence `FB-XXXX-XXXX`. En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
joueur reçoit une référence `FB-XXXX-XXXX`. **Captures d'écran** : 3 au plus (fichier ou Ctrl+V), image reconnue à
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
seulement, 4 Mo). En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression. Chaque consultation est inscrite au journal d'audit.
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
demande contenant un **lien** (http, https, www, domaine, IP, e-mail, formes déguisées…) est signalée par un
avertissement, liens désamorcés et jamais cliquables. Statut, note et suppression sont des requêtes **signées**.
Chaque consultation est inscrite au journal d'audit.
Purge automatique : 12 mois après la clôture, 24 mois au plus ; le journal d'audit est purgé au-delà de 12 mois.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
+39 -8
View File
@@ -697,10 +697,12 @@ d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; f
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `POST v1/feedback/challenge` | anonyme | | Défi anti-spam sans état (HMAC, secret en mémoire) |
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement |
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement (+ jeton d'envoi si des captures sont annoncées) |
| `PUT v1/feedback/attachments/{n}` | anonyme + jeton | | Capture d'écran n° n (1 à 3) de la demande que le jeton désigne |
| `GET v1/feedback/overview` | `feedback.read` | | Compteurs par jeu, réception activée, plafond atteint, durées de conservation |
| `GET v1/games/{id}/feedback?kind=&status=&cursor=&limit=` | `feedback.read` | | Liste (100 par page, curseur stable, filtres `open`/`closed`/statut) |
| `GET v1/games/{id}/feedback/{id}` | `feedback.read` | | Détail |
| `GET v1/games/{id}/feedback/{id}/attachments/{n}` | `feedback.read` | | Capture d'écran (octets bruts, `nosniff`) |
| `PUT v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Statut et note interne (révision attendue, 409 sinon) |
| `DELETE v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Suppression définitive |
@@ -709,29 +711,56 @@ limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`
travail** (`FeedbackProof` : SHA-256 avec 18 bits à zéro par défaut, défi à usage unique valable 10 min) ; jeu parmi
ceux configurés (`Feedback:Games`, sinon `Games` ; l'identifiant **configuré** sert seul à construire un chemin, `.`
et `..` refusés) ; contenu ; 3 demandes / 10 min et 10 / jour par IP (IPv6 : /64), **comptées en mémoire seulement**
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 100 Mo, 500 Mo libres : 503
`feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 1 Go captures comprises, 500 Mo libres :
503 `feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
et le statut.
**Captures d'écran** (3 au plus par demande, 4 Mo chacune, **PNG ou JPEG uniquement**) : le joueur choisit une image
(PNG, JPEG, BMP, GIF, WebP, reconnue à son **contenu**, jamais à l'extension ; 30 Mo au plus, dimensions lues avant
tout décodage) ou la colle (Ctrl+V) ; Windows la décode (preuve que c'est une image), elle est réduite (3 840 px,
surface de la limite serveur) et **réencodée sans métadonnées** (ni GPS, ni appareil, ni date) ; le nom du fichier
n'est jamais envoyé. Envoi en deux temps : la demande annonce `AttachmentCount`, le serveur répond avec un **jeton
d'envoi** `<id>.<secret>` (128 bits, seule son empreinte SHA-256 est gardée, en mémoire, 15 min, comparaison à temps
constant) valable pour CETTE demande et les numéros annoncés ; chaque capture part en `PUT` (corps 4 Mo + 16 Ko,
en-tête `X-GL-Feedback-Upload`). Le serveur contrôle **réellement** l'image sans bibliothèque de décodage
(`FeedbackImages.Sanitize`) : PNG (signature, IHDR, CRC de chaque bloc, données zlib décompressées et taille exacte
des lignes vérifiée, filtres valides, IEND final sans données après) ou JPEG (marqueurs, SOF0/1/2, tables, SOS, EOI,
rien après) ; dimensions 16 à 4 096 px, 9,4 Mpx au plus ; puis **retire toutes les métadonnées** (blocs PNG
facultatifs : textes, EXIF, profil… ; JPEG : APP1 à APP15 dont EXIF/XMP/GPS, commentaires). Une capture refusée
n'annule pas la demande (le joueur est prévenu). Audit `feedback.attachment` sans IP. Dans le menu, chaque capture est
**décodée à nouveau** par le launcher (contrôle + dimensions) avant affichage ; aucun fichier n'est écrit sur le PC.
**Liens** (`FeedbackLinks`) : toute demande contenant un lien est signalée (icône dans la liste, bandeau
d'avertissement et champs concernés surlignés dans la fiche ; liens affichés **désamorcés**, ex. `hxxps[://]site[.]com`,
jamais cliquables). Formes reconnues : `http(s)://`, `ftp://`, `file:`, schémas dangereux (`javascript:`, `data:`,
`vbscript:`, `ms-*:`, `steam:`…), `www.`, domaines nus avec extension connue (`site.ru`, `exemple.com/chemin`), adresses
IP, chemins réseau `\\serveur\partage`, adresses e-mail, formes déguisées (`hxxp`, `[.]`, `(dot)`, ` dot `,
caractères pleine chasse).
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
Le launcher ne contacte l'API qu'à l'envoi.
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` dans les
données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` (captures :
`<id>-<n>.png|jpg` à côté, supprimées avec la demande) dans les données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
requêtes croisées, volumes non bornés), où SQLite serait légitime (requêtes paramétrées uniquement, fichier 0600 dans
requêtes croisées, volumes non bornés), où SQLite serait légitime. Captures : fichiers également (des blobs binaires
en base alourdiraient sauvegardes et purge sans rien apporter) (requêtes paramétrées uniquement, fichier 0600 dans
les données privées, mode WAL, sauvegarde par l'API de sauvegarde).
**Consultations et traitement** : `feedback.view` inscrit à chaque ouverture de liste (première page) et à chaque
lecture d'une demande (une fois par demande et par clé toutes les 10 min), sans le contenu ; `feedback.update`
(ancien → nouveau statut, note modifiée oui/non), `feedback.delete` (référence). Statuts : Nouvelle, En cours, Traitée,
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur.
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur. Comme partout dans le menu, les **écritures**
(`PUT`, `DELETE`) sont **signées** par la clé de l'administrateur (horodatage + nonce, rejeu refusé) ; les lectures
(liste, détail, captures) exigent la session et `feedback.read`, revérifiés à chaque requête, et sont journalisées.
Le journal d'audit du menu affiche des libellés lisibles (« Demande de joueur reçue », « … consultée », « … traitée »,
« Capture d'écran reçue »…) et une catégorie « Demandes des joueurs ».
**Conservation** (`Hosting/RetentionService`, au démarrage puis toutes les 24 h) : demande close supprimée
`RetentionClosedMonths` (12) après sa clôture, toute demande au plus `RetentionMaxMonths` (24) après réception
@@ -741,7 +770,9 @@ Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur.
**Launcher (joueur)** : bouton **Aide** (bulle avec point d'interrogation) dans la barre de titre, absent sans
`apiUrl` (même règle que le menu : `launcher.json` intégré en Release). Fenêtre modale `FeedbackWindow` (le jeu est
figé à l'ouverture) : type (4 tuiles) › formulaire (contrôle local, compteurs de caractères, étoiles, saisie conservée
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). ViewModel `FeedbackFormViewModel`
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). Formulaire limité à une hauteur de base
(500 px, moins sur un petit écran) avec ascenseur ; la fenêtre reste centrée quand l'étape change ; molette gérée
par `WheelAssist` comme dans le menu. ViewModel `FeedbackFormViewModel`
(`GameLauncher.Modules.Feedback`), envoi par `LauncherApiClient.SendFeedbackAsync` (défi résolu hors du fil de
l'interface).
@@ -157,7 +157,7 @@ public sealed class LauncherApiOptions
/// <summary>Plafonds : demandes reçues par jeu sur 24 heures, demandes conservées par jeu, taille totale (Mo).</summary>
public int MaxPerGamePerDay { get; set; } = 300;
public int MaxPerGame { get; set; } = 5000;
public int MaxTotalMegabytes { get; set; } = 100;
public int MaxTotalMegabytes { get; set; } = 1024; // captures d'écran comprises (4 Mo chacune au plus)
/// <summary>Espace disque libre minimal (Mo) : en dessous, les nouvelles demandes sont refusées.</summary>
public int MinFreeDiskMegabytes { get; set; } = 500;
@@ -37,10 +37,13 @@ public sealed class FeedbackModule : IApiModule
api.MapPost(ApiRoutes.FeedbackChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback);
api.MapPost(ApiRoutes.Feedback, Submit).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.WithBodyLimit(FeedbackRules.MaxRequestBytes);
api.MapPut(ApiRoutes.FeedbackAttachmentPattern, UploadAttachment).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).RequirePermission(AdminPermissions.FeedbackRead);
api.MapPut(ApiRoutes.GameFeedbackItemPattern, Update).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
api.MapDelete(ApiRoutes.GameFeedbackItemPattern, Delete).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
}
@@ -109,8 +112,43 @@ public sealed class FeedbackModule : IApiModule
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
details: $"jeu {gameId} ; {FeedbackForms.KindLabel(record.Kind)}" + (record.Tech is null ? "" : " ; informations techniques jointes"));
return Results.Json(new FeedbackSubmitted { Reference = record.Reference }, ApiJson.Options);
details: $"jeu {gameId} ; {FeedbackForms.KindLabel(record.Kind)}" + (record.Tech is null ? "" : " ; informations techniques jointes") +
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
return Results.Json(new FeedbackSubmitted
{
Reference = record.Reference,
UploadToken = record.AttachmentCount > 0 ? guard.CreateUploadToken(record.Id, record.AttachmentCount) : null
}, ApiJson.Options);
}
/// <summary>
/// Capture d'écran d'une demande qui vient d'être envoyée (jeton d'envoi remis à ce seul launcher). L'image est
/// contrôlée (vraie image PNG ou JPEG, dimensions, structure) puis nettoyée de ses métadonnées avant d'être
/// enregistrée ; sans adresse IP dans l'audit, comme la demande.
/// </summary>
private static IResult UploadAttachment(int index, HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit,
TimeProvider time)
{
if (!store.Enabled) return NotConfigured();
var id = guard.CheckUpload(context.Request.Headers[FeedbackUpload.TokenHeader].ToString(), index);
if (id is null || store.FindById(id) is null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, "jeton d'envoi absent, expiré ou invalide");
return ApiResults.Error(403, ApiErrorCodes.Forbidden, "Envoi de la capture refusé (délai dépassé ?).");
}
var error = FeedbackImages.Sanitize(context.CapturedBody(), out var info, out var clean);
if (error is not null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, $"capture refusée ({error})");
return ApiResults.BadRequest(error);
}
var record = store.AddAttachment(id, index, clean!, info!, time.GetUtcNow());
if (record is null)
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull, "Le service des demandes est momentanément saturé : capture non enregistrée.");
audit.Write("feedback.attachment", AuditResults.Ok, target: record.Reference,
details: $"jeu {record.GameId} ; capture {index} : {info!.Format.ToUpperInvariant()} {info.Width} × {info.Height}, {clean!.Length / 1024} Ko (métadonnées retirées)");
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
}
// ------------------------------------------------------------------ Administrateurs
@@ -160,6 +198,21 @@ public sealed class FeedbackModule : IApiModule
return Results.Json(record.ToDetail(), ApiJson.Options);
}
private static IResult GetAttachment(string gameId, string id, int index, HttpContext context, FeedbackStore store, AuditLog audit,
FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null || store.Find(game, id) is not { } record || store.ReadAttachment(record, index) is not { } bytes)
return ApiResults.NotFound("Capture introuvable.");
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, $"attachment:{record.Id}:{index}", continuation: true))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
record.Reference, $"jeu {game} ; capture {index}");
// Type générique : le contenu n'est jamais interprété comme une page par un navigateur.
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.Bytes(bytes, FeedbackUpload.ContentType);
}
private static IResult Update(string gameId, string id, FeedbackUpdateRequest request, HttpContext context, FeedbackStore store,
AuditLog audit, TimeProvider time)
{
@@ -122,6 +122,52 @@ public sealed class FeedbackGuard
}
}
// ------------------------------------------------------------------ Jetons d'envoi des captures
private sealed record PendingUpload(byte[] SecretHash, DateTimeOffset Expires, int Count);
private readonly ConcurrentDictionary<string, PendingUpload> _uploads = new(StringComparer.Ordinal);
/// <summary>
/// Jeton d'envoi des captures d'une demande qui vient d'être enregistrée : « &lt;id&gt;.&lt;secret 256 bits&gt; ». Seule
/// l'empreinte du secret est gardée, en mémoire (un redémarrage du service annule les envois en attente).
/// </summary>
public string CreateUploadToken(string id, int count)
{
var now = _time.GetUtcNow();
if (_uploads.Count >= MaxTracked)
{
foreach (var (key, pending) in _uploads)
if (pending.Expires <= now) _uploads.TryRemove(key, out _);
if (_uploads.Count >= MaxTracked) throw new StoreBusyException("Trop d'envois de captures en attente.");
}
var secret = RandomNumberGenerator.GetBytes(32);
_uploads[id] = new PendingUpload(SHA256.HashData(secret), now + TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes), count);
return id + "." + Base64Url.Encode(secret);
}
/// <summary>
/// Identifiant de la demande si le jeton est valable pour cette capture (numéro dans le nombre annoncé, délai non
/// dépassé), sinon null. Comparaison en temps constant.
/// </summary>
public string? CheckUpload(string? token, int index)
{
if (token is not { Length: > 33 and < 120 }) return null;
var dot = token.IndexOf('.');
if (dot != 32) return null;
var id = token[..dot];
if (!FeedbackRules.IsValidId(id) || !_uploads.TryGetValue(id, out var pending)) return null;
if (pending.Expires <= _time.GetUtcNow())
{
_uploads.TryRemove(id, out _);
return null;
}
if (!Base64Url.TryDecode(token[(dot + 1)..], out var secret) || secret.Length != 32 ||
!CryptographicOperations.FixedTimeEquals(SHA256.HashData(secret), pending.SecretHash))
return null;
return index >= 1 && index <= pending.Count ? id : null;
}
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
@@ -25,6 +25,12 @@ public sealed record FeedbackRecord
public string Title { get; init; } = "";
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures annoncées à l'envoi (celles réellement reçues : <see cref="Attachments"/>).</summary>
public int AttachmentCount { get; init; }
/// <summary>Captures reçues, nettoyées (fichiers <c>&lt;id&gt;-&lt;n&gt;.png|jpg</c> à côté de la demande).</summary>
public List<FeedbackAttachmentInfo> Attachments { get; init; } = new();
public string Status { get; init; } = FeedbackStatuses.New;
public long Revision { get; init; } = 1;
public string? AdminNote { get; init; }
@@ -49,6 +55,8 @@ public sealed record FeedbackRecord
_ => null
},
HasNote = !string.IsNullOrEmpty(AdminNote),
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
AttachmentCount = Attachments.Count,
Revision = Revision
};
@@ -58,6 +66,7 @@ public sealed record FeedbackRecord
FormVersion = FormVersion,
Fields = Fields,
Tech = Tech,
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
AdminNote = AdminNote,
StatusChangedBy = StatusChangedBy
};
@@ -115,7 +124,7 @@ public sealed class FeedbackStore
// ------------------------------------------------------------------ Envoi
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
public string? FullReason(string gameId, DateTimeOffset now)
public string? FullReason(string gameId, DateTimeOffset now, long extraBytes = 0)
{
lock (_lock)
{
@@ -127,7 +136,7 @@ public sealed class FeedbackStore
var lastDay = game.Values.Count(r => r.ReceivedAt > now - TimeSpan.FromDays(1));
if (lastDay >= _settings.MaxPerGamePerDay) return $"{lastDay} demandes reçues en 24 h pour ce jeu (plafond {_settings.MaxPerGamePerDay})";
}
if (_totalBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
if (_totalBytes + extraBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
return $"taille totale des demandes {_totalBytes / (1024 * 1024)} Mo (plafond {_settings.MaxTotalMegabytes} Mo)";
if (FreeDiskBytes() is { } free && free < (long)_settings.MinFreeDiskMegabytes * 1024 * 1024)
return $"espace disque libre {free / (1024 * 1024)} Mo (minimum {_settings.MinFreeDiskMegabytes} Mo)";
@@ -159,6 +168,7 @@ public sealed class FeedbackStore
Title = submission.Title,
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
Tech = submission.Tech,
AttachmentCount = submission.AttachmentCount,
Status = FeedbackStatuses.New,
Revision = 1
};
@@ -168,6 +178,57 @@ public sealed class FeedbackStore
}
}
// ------------------------------------------------------------------ Captures d'écran
/// <summary>Demande (tous jeux confondus) d'après son identifiant interne.</summary>
public FeedbackRecord? FindById(string id)
{
if (!FeedbackRules.IsValidId(id)) return null;
lock (_lock)
foreach (var game in Index().Values)
if (game.TryGetValue(id, out var record)) return record;
return null;
}
/// <summary>
/// Enregistre une capture DÉJÀ contrôlée et nettoyée (<see cref="FeedbackImages.Sanitize"/>). Null : plafond atteint.
/// Une capture de même numéro est remplacée (nouvel essai après une coupure).
/// </summary>
public FeedbackRecord? AddAttachment(string id, int index, byte[] clean, FeedbackImages.ImageInfo info, DateTimeOffset now)
{
lock (_lock)
{
var record = FindById(id) ?? throw ApiRejection.NotFound("Demande introuvable.");
if (index < 1 || index > record.AttachmentCount) throw ApiRejection.BadRequest("Numéro de capture invalide.");
var previous = record.Attachments.FirstOrDefault(a => a.Index == index);
if (FullReason(record.GameId, now, clean.Length - (previous?.Bytes ?? 0)) is not null) return null;
var path = AttachmentPath(record, index, info.Format);
if (previous is not null && previous.Format != info.Format) DeleteAttachmentFile(record, previous);
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
SecureFiles.WriteAllBytesAtomic(path, clean);
_totalBytes += clean.Length - oldSize;
var attachments = record.Attachments.Where(a => a.Index != index)
.Append(new FeedbackAttachmentInfo { Index = index, Format = info.Format, Width = info.Width, Height = info.Height, Bytes = clean.Length })
.OrderBy(a => a.Index).ToList();
var updated = record with { Attachments = attachments };
Write(updated);
Index()[record.GameId][id] = updated;
return updated;
}
}
/// <summary>Contenu d'une capture (null : absente).</summary>
public byte[]? ReadAttachment(FeedbackRecord record, int index)
{
var info = record.Attachments.FirstOrDefault(a => a.Index == index);
if (info is null) return null;
var path = AttachmentPath(record, index, info.Format);
if (!File.Exists(path) || IsLink(path) || new FileInfo(path).Length > FeedbackImages.MaxBytes) return null;
return File.ReadAllBytes(path);
}
// ------------------------------------------------------------------ Consultation
public IReadOnlyList<FeedbackGameCounts> Counts()
@@ -342,8 +403,25 @@ public sealed class FeedbackStore
_totalBytes += new FileInfo(path).Length - oldSize;
}
private string AttachmentPath(FeedbackRecord record, int index, string format)
{
if (format is not (FeedbackImages.Png or FeedbackImages.Jpeg) || index is < 1 or > FeedbackImages.MaxAttachments)
throw new InvalidOperationException("Capture invalide.");
return Path.Combine(Path.GetDirectoryName(PathOf(record))!, $"{record.Id}-{index}.{format}");
}
private void DeleteAttachmentFile(FeedbackRecord record, FeedbackAttachmentInfo attachment)
{
var path = AttachmentPath(record, attachment.Index, attachment.Format);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
File.Delete(path);
_totalBytes -= size;
}
private void DeleteFile(FeedbackRecord record)
{
foreach (var attachment in record.Attachments) DeleteAttachmentFile(record, attachment);
var path = PathOf(record);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
@@ -414,8 +492,16 @@ public sealed class FeedbackStore
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
throw new InvalidDataException("contenu inattendu");
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>() };
var attachments = (record.Attachments ?? new List<FeedbackAttachmentInfo>())
.Where(a => a.Format is FeedbackImages.Png or FeedbackImages.Jpeg && a.Index is >= 1 and <= FeedbackImages.MaxAttachments)
.ToList();
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>(), Attachments = attachments };
total += info.Length;
foreach (var attachment in attachments)
{
var attachmentPath = Path.Combine(month, $"{id}-{attachment.Index}.{attachment.Format}");
if (File.Exists(attachmentPath)) total += new FileInfo(attachmentPath).Length;
}
}
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or UnauthorizedAccessException)
{
@@ -33,6 +33,18 @@ internal static class SecureFiles
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
}
/// <summary>Écrit un fichier binaire de façon atomique (fichier temporaire 0600 puis renommage).</summary>
public static void WriteAllBytesAtomic(string path, ReadOnlySpan<byte> bytes)
{
var temp = path + ".tmp";
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, path, overwrite: true);
}
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
public static bool TryCreateNew(string path, string content)
{
+1 -1
View File
@@ -63,7 +63,7 @@
"RequestsPerMinute": 10,
"MaxPerGamePerDay": 300,
"MaxPerGame": 5000,
"MaxTotalMegabytes": 100,
"MaxTotalMegabytes": 1024,
"MinFreeDiskMegabytes": 500,
"RetentionClosedMonths": 12,
"RetentionMaxMonths": 24
+7 -4
View File
@@ -372,8 +372,10 @@ d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives p
Protections en place : défi anti-spam à résoudre par le launcher (preuve de calcul, une fraction de seconde), 3
demandes par 10 minutes et 10 par jour par adresse IP (comptées **en mémoire seulement**), 300 demandes par jour et
par jeu, 5 000 demandes et 100 Mo au plus, 500 Mo d'espace disque libre minimum, corps de 16 Ko, contrôle complet du
contenu (caractères invisibles refusés). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
par jeu, 5 000 demandes et 1 Go au plus (captures comprises), 500 Mo d'espace disque libre minimum, corps de 16 Ko,
contrôle complet du contenu (caractères invisibles refusés). **Captures d'écran** : 3 par demande, PNG ou JPEG de
4 Mo au plus, envoyées avec un jeton remis au seul launcher qui a envoyé la demande (15 min) ; le serveur vérifie
que ce sont de vraies images (structure complète) et **retire leurs métadonnées** (EXIF, position GPS…). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
1. Active la réception dans `/opt/launcher-api/appsettings.Production.json` (section `LauncherApi`) :
@@ -390,8 +392,8 @@ son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
```
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
```
puis colle les **deux** blocs `location = /api/v1/feedback…` de `server/deploy/nginx-api.conf` à côté du bloc
`/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
puis colle les **trois** blocs `location … /api/v1/feedback…` de `server/deploy/nginx-api.conf` (défi, envoi,
captures d'écran à 5 Mo) à côté du bloc `/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
3. Redémarre le service : `sudo systemctl restart launcher-api`.
4. **Droits des administrateurs existants** : le groupe **Propriétaire** reçoit automatiquement les nouvelles
permissions. Pour un serveur déjà installé, les autres groupes ne changent pas tout seuls (aucune élévation de droits
@@ -540,6 +542,7 @@ recréer les administrateurs.
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ publications/ publications en cours (version, fichiers restant à envoyer)
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
+20
View File
@@ -99,3 +99,23 @@ location = /api/v1/feedback {
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Captures d'écran d'une demande (jeton d'envoi remis au seul launcher qui a envoyé la demande) : 4 Mo par image.
location ^~ /api/v1/feedback/attachments/ {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 5m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
+8 -2
View File
@@ -295,6 +295,11 @@ public sealed class AdminSession : IAsyncDisposable
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
signed: false, ct, maxResponseBytes: FeedbackImages.MaxBytes);
private static string CheckFeedbackId(string id) =>
FeedbackRules.IsValidId(id) ? id : throw new ArgumentException("Identifiant de demande invalide.", nameof(id));
@@ -319,7 +324,8 @@ public sealed class AdminSession : IAsyncDisposable
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
@@ -345,7 +351,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct, contentType, timeout).ConfigureAwait(false);
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -113,7 +113,49 @@ public sealed class LauncherApiClient : IDisposable
/// Envoie une demande de joueur (bouton « Aide ») : défi anti-spam demandé au serveur, résolu sur ce PC (une fraction de
/// seconde, hors du fil de l'interface), puis envoi. Aucune session, aucune clé, aucun identifiant du PC.
/// </summary>
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default)
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default) =>
(await SendFeedbackAsync(submission, Array.Empty<byte[]>(), ct).ConfigureAwait(false)).Submitted;
/// <summary>
/// Envoie une demande puis ses captures d'écran (déjà nettoyées par <see cref="FeedbackImages.Sanitize"/>), une par
/// une, avec le jeton remis par le serveur. Une capture refusée ou perdue n'annule pas la demande : le résultat
/// indique combien ont été reçues et pourquoi les autres ne l'ont pas été.
/// </summary>
public async Task<FeedbackSendResult> SendFeedbackAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments,
CancellationToken ct = default)
{
if (attachments.Count > FeedbackImages.MaxAttachments) throw new ArgumentException("Trop de captures.", nameof(attachments));
var submitted = await SendSubmissionAsync(submission with { AttachmentCount = attachments.Count }, ct).ConfigureAwait(false);
var sent = 0;
string? error = null;
for (var i = 0; i < attachments.Count && submitted.UploadToken is { } token; i++)
{
var index = i + 1;
for (var attempt = 1; ; attempt++)
{
try
{
await SendAsync<FeedbackAttachmentStored>(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index), attachments[i],
request => request.Headers.Add(FeedbackUpload.TokenHeader, token), ct, FeedbackUpload.ContentType,
TimeSpan.FromMinutes(2)).ConfigureAwait(false);
sent++;
break;
}
catch (ApiException ex) when (ex.Status == 0 && attempt < 2)
{
// coupure : un nouvel essai (même numéro : la capture est remplacée si elle était arrivée)
}
catch (ApiException ex)
{
error = ex.Message;
break;
}
}
}
return new FeedbackSendResult(submitted, sent, error);
}
private async Task<FeedbackSubmitted> SendSubmissionAsync(FeedbackSubmission submission, CancellationToken ct)
{
var challenge = await SendAsync<FeedbackChallenge>(HttpMethod.Post, ApiRoutes.FeedbackChallenge, null, null, ct).ConfigureAwait(false);
if (challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty || !Base64Url.TryDecode(challenge.Nonce, out var nonce) ||
@@ -149,7 +191,8 @@ public sealed class LauncherApiClient : IDisposable
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
@@ -182,7 +225,7 @@ public sealed class LauncherApiClient : IDisposable
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
@@ -194,6 +237,7 @@ public sealed class LauncherApiClient : IDisposable
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
if (typeof(T) == typeof(byte[])) return (T)(object)bytes; // contenu binaire (capture d'écran…)
try
{
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
@@ -207,9 +251,9 @@ public sealed class LauncherApiClient : IDisposable
}
}
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
{
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
if (response.Content.Headers.ContentLength > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var buffer = new MemoryStream();
@@ -217,7 +261,7 @@ public sealed class LauncherApiClient : IDisposable
int read;
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > ApiJson.MaxResponseBytes)
if (buffer.Length + read > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
buffer.Write(chunk, 0, read);
}
@@ -279,3 +323,8 @@ public sealed class NoContent
public static readonly NoContent Value = new();
private NoContent() { }
}
/// <summary>Résultat de l'envoi d'une demande et de ses captures d'écran.</summary>
/// <param name="AttachmentsSent">Captures reçues par le serveur.</param>
/// <param name="AttachmentError">Raison de l'échec de la première capture non reçue (null : toutes reçues).</param>
public sealed record FeedbackSendResult(FeedbackSubmitted Submitted, int AttachmentsSent, string? AttachmentError);
+10
View File
@@ -115,6 +115,11 @@ public static class ApiRoutes
/// <summary>Envoi d'une demande par un joueur (anonyme, corps de 16 Ko au plus).</summary>
public const string Feedback = "v1/feedback";
/// <summary>Capture d'écran d'une demande (anonyme, jeton d'envoi ; corps : l'image, 4 Mo au plus).</summary>
public const string FeedbackAttachmentPattern = "v1/feedback/attachments/{index:int}";
public static string FeedbackAttachment(int index) => $"v1/feedback/attachments/{index}";
/// <summary>Compteurs par jeu (feedback.read).</summary>
public const string FeedbackOverview = "v1/feedback/overview";
@@ -134,6 +139,11 @@ public static class ApiRoutes
/// <summary>Une demande (identifiant : 32 caractères hexadécimaux, rien à encoder).</summary>
public static string GameFeedbackItem(string gameId, string id) => $"v1/games/{gameId}/feedback/{id}";
public const string GameFeedbackAttachmentPattern = "v1/games/{gameId}/feedback/{id}/attachments/{index:int}";
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -49,6 +49,9 @@ public sealed record FeedbackSubmission
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures d'écran annoncées (0 à <see cref="FeedbackImages.MaxAttachments"/>), envoyées ensuite une par une.</summary>
public int AttachmentCount { get; init; }
/// <summary>Défi résolu (<see cref="FeedbackChallenge.ChallengeId"/>) et sa solution.</summary>
public string ChallengeId { get; init; } = "";
@@ -59,6 +62,40 @@ public sealed record FeedbackSubmission
public sealed record FeedbackSubmitted
{
public string Reference { get; init; } = "";
/// <summary>
/// Jeton d'envoi des captures annoncées (null sans capture) : à joindre à chaque envoi (en-tête
/// <see cref="FeedbackUpload.TokenHeader"/>). Valable <see cref="FeedbackUpload.TokenLifetimeMinutes"/> minutes,
/// seulement pour cette demande et le nombre de captures annoncé ; connu du seul launcher qui a envoyé la demande.
/// </summary>
public string? UploadToken { get; init; }
}
/// <summary>Envoi des captures d'écran d'une demande (PUT v1/feedback/attachments/{index}, corps : l'image).</summary>
public static class FeedbackUpload
{
public const string TokenHeader = "X-GL-Feedback-Upload";
public const int TokenLifetimeMinutes = 15;
public const string ContentType = "application/octet-stream";
}
/// <summary>Capture d'écran enregistrée (nettoyée de ses métadonnées par le serveur).</summary>
public sealed record FeedbackAttachmentInfo
{
/// <summary>Numéro (1 à <see cref="FeedbackImages.MaxAttachments"/>).</summary>
public int Index { get; init; }
/// <summary>« png » ou « jpg ».</summary>
public string Format { get; init; } = "";
public int Width { get; init; }
public int Height { get; init; }
public long Bytes { get; init; }
}
/// <summary>Capture reçue (réponse du serveur).</summary>
public sealed record FeedbackAttachmentStored
{
public int Index { get; init; }
}
// ------------------------------------------------------------------ Menu d'administration
@@ -106,6 +143,13 @@ public sealed record FeedbackSummary
public string? Badge { get; init; }
public bool HasNote { get; init; }
/// <summary>Le titre ou un champ contient un lien ou une adresse (<see cref="FeedbackLinks"/>) : prudence.</summary>
public bool HasLinks { get; init; }
/// <summary>Captures d'écran reçues.</summary>
public int AttachmentCount { get; init; }
public long Revision { get; init; }
}
@@ -129,6 +173,9 @@ public sealed record FeedbackDetail
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures d'écran reçues (contenu : GET …/attachments/{index}).</summary>
public IReadOnlyList<FeedbackAttachmentInfo> Attachments { get; init; } = Array.Empty<FeedbackAttachmentInfo>();
/// <summary>Note interne des administrateurs (jamais visible du joueur).</summary>
public string? AdminNote { get; init; }
@@ -0,0 +1,333 @@
using System.Buffers.Binary;
using System.IO.Compression;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Captures d'écran jointes à une demande : contrôle ET nettoyage, mêmes règles au launcher (avant l'envoi), au serveur
/// (refus de tout fichier non conforme) et dans le menu d'administration (avant l'affichage).
/// • <b>PNG ou JPEG uniquement</b>, reconnus à leur contenu, jamais à l'extension. Le launcher convertit les autres
/// formats courants (BMP, GIF, WebP) en PNG avant l'envoi ;
/// • <b>vraie image</b> : structure complète vérifiée. PNG : blocs et CRC, en-tête cohérent, palette si nécessaire,
/// données décompressées de la taille exacte de l'image, filtres de lignes valides. JPEG : segments, tables,
/// une trame baseline ou progressive, scans et fin d'image ;
/// • dimensions bornées (<see cref="MaxSide"/>, <see cref="MaxPixels"/>), taille bornée (<see cref="MaxBytes"/>),
/// aucune donnée après la fin de l'image, décompression interrompue au-delà de la taille attendue (bombe zlib) ;
/// • <b>métadonnées retirées</b> : PNG : blocs texte, eXIf, date… (seuls IHDR, PLTE, tRNS, IDAT, IEND sont
/// gardés) ; JPEG : EXIF (position GPS, appareil…), XMP, commentaires… (seuls JFIF et Adobe sont gardés).
/// </summary>
public static class FeedbackImages
{
public const int MaxAttachments = 3;
public const int MaxBytes = 4 * 1024 * 1024;
/// <summary>Corps d'une requête d'envoi d'une capture (image + marge) ; voir nginx-api.conf.</summary>
public const int MaxRequestBytes = MaxBytes + 16 * 1024;
public const int MinSide = 16;
public const int MaxSide = 4096;
public const long MaxPixels = 4096L * 2304;
/// <summary>Le launcher réduit les captures plus grandes à cette taille (côté le plus long) avant l'envoi.</summary>
public const int TargetMaxSide = 3840;
public const string Png = "png";
public const string Jpeg = "jpg";
/// <summary>Formats acceptés en entrée par le launcher (convertis en PNG/JPEG avant l'envoi).</summary>
public static readonly IReadOnlyList<string> InputExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
public sealed record ImageInfo(string Format, int Width, int Height);
/// <summary>Format reconnu au contenu parmi les formats d'entrée du launcher (null : autre chose).</summary>
public static string? DetectInputFormat(ReadOnlySpan<byte> data)
{
if (data.StartsWith(PngSignature)) return Png;
if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return Jpeg;
if (data.Length >= 6 && (data[..6].SequenceEqual("GIF87a"u8) || data[..6].SequenceEqual("GIF89a"u8))) return "gif";
if (data.Length >= 14 && data[0] == (byte)'B' && data[1] == (byte)'M') return "bmp";
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "webp";
return null;
}
/// <summary>
/// Contrôle complet et nettoyage. Null si l'image est acceptée (<paramref name="clean"/> : l'image sans
/// métadonnées, à enregistrer ou afficher à la place de l'original), sinon la raison du refus (français).
/// </summary>
public static string? Sanitize(ReadOnlySpan<byte> data, out ImageInfo? info, out byte[]? clean)
{
info = null;
clean = null;
if (data.Length == 0) return "Fichier vide.";
if (data.Length > MaxBytes) return $"Image trop volumineuse : {MaxBytes / 1024 / 1024} Mo au plus.";
string? error;
if (data.StartsWith(PngSignature)) (info, clean, error) = SanitizePng(data);
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) (info, clean, error) = SanitizeJpeg(data);
else return "Ce fichier n'est pas une image PNG ou JPEG.";
if (error is not null)
{
info = null;
clean = null;
return error;
}
if (info!.Width < MinSide || info.Height < MinSide || info.Width > MaxSide || info.Height > MaxSide ||
(long)info.Width * info.Height > MaxPixels)
{
var refused = info;
info = null;
clean = null;
return $"Dimensions refusées : {refused.Width} × {refused.Height} px ({MinSide} à {MaxSide} px de côté).";
}
return null;
}
// ------------------------------------------------------------------ PNG
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private static (ImageInfo?, byte[]?, string?) SanitizePng(ReadOnlySpan<byte> data)
{
const string invalid = "Image PNG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(PngSignature);
var idat = new MemoryStream();
int width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
bool header = false, palette = false, ended = false, idatDone = false, inIdat = false;
var pos = 8;
while (pos < data.Length)
{
if (ended) return (null, null, "Image PNG suivie de données étrangères à l'image.");
if (pos + 12 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt32BigEndian(data[pos..]);
if (length > int.MaxValue || pos + 12 + (long)length > data.Length) return (null, null, invalid);
var type = data.Slice(pos + 4, 4);
foreach (var b in type)
if (b is not ((>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z'))) return (null, null, invalid);
var body = data.Slice(pos + 8, (int)length);
var crc = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 8 + (int)length)..]);
if (Crc32(type, body) != crc) return (null, null, "Image PNG endommagée (somme de contrôle incorrecte).");
var chunk = data.Slice(pos, 12 + (int)length);
pos += 12 + (int)length;
var name = System.Text.Encoding.ASCII.GetString(type);
if (!header && name != "IHDR") return (null, null, invalid);
if (inIdat && name != "IDAT") idatDone = true;
inIdat = false;
switch (name)
{
case "IHDR":
if (header || length != 13) return (null, null, invalid);
header = true;
var w = BinaryPrimitives.ReadUInt32BigEndian(body);
var h = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
if (w is 0 or > MaxSide || h is 0 or > MaxSide)
return (null, null, $"Dimensions refusées ({MinSide} à {MaxSide} px de côté).");
(width, height, depth, colorType, interlace) = ((int)w, (int)h, body[8], body[9], body[12]);
var validDepth = colorType switch
{
0 => depth is 1 or 2 or 4 or 8 or 16,
3 => depth is 1 or 2 or 4 or 8,
2 or 4 or 6 => depth is 8 or 16,
_ => false
};
if (!validDepth || body[10] != 0 || body[11] != 0 || interlace > 1) return (null, null, invalid);
output.Write(chunk);
break;
case "PLTE":
if (palette || idat.Length > 0 || length == 0 || length % 3 != 0 || length > 768 || colorType is 0 or 4)
return (null, null, invalid);
palette = true;
output.Write(chunk);
break;
case "tRNS":
if (idat.Length > 0) return (null, null, invalid);
output.Write(chunk);
break;
case "IDAT":
if (idatDone) return (null, null, invalid); // blocs IDAT non consécutifs
inIdat = true;
idat.Write(body);
output.Write(chunk);
break;
case "IEND":
if (length != 0 || idat.Length == 0) return (null, null, invalid);
ended = true;
output.Write(chunk);
break;
default:
// Bloc critique inconnu (majuscule) : image non standard, refusée. Bloc auxiliaire (texte, eXIf,
// date, profil…) : retiré.
if (char.IsUpper(name[0])) return (null, null, "Image PNG non standard.");
break;
}
}
if (!ended) return (null, null, "Image PNG incomplète.");
if (colorType == 3 && !palette) return (null, null, invalid);
var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 };
var bitsPerPixel = channels * depth;
if (!CheckPixels(idat.ToArray(), width, height, bitsPerPixel, interlace == 1))
return (null, null, "Image PNG endommagée (données de l'image incorrectes).");
return (new ImageInfo(Png, width, height), output.ToArray(), null);
}
/// <summary>
/// Décompresse les données (sans dépasser la taille attendue + 1 octet) et vérifie qu'elles ont exactement la taille
/// de l'image, ligne par ligne, avec un type de filtre valide (0 à 4) au début de chaque ligne.
/// </summary>
private static bool CheckPixels(byte[] compressed, int width, int height, int bitsPerPixel, bool interlaced)
{
var rows = new List<long>(); // octets par ligne (filtre compris), dans l'ordre
if (!interlaced) AddRows(rows, width, height, bitsPerPixel);
else
{
int[] x0 = { 0, 4, 0, 2, 0, 1, 0 }, y0 = { 0, 0, 4, 0, 2, 0, 1 }, dx = { 8, 8, 4, 4, 2, 2, 1 }, dy = { 8, 8, 8, 4, 4, 2, 2 };
for (var p = 0; p < 7; p++)
{
var pw = width > x0[p] ? (width - x0[p] + dx[p] - 1) / dx[p] : 0;
var ph = height > y0[p] ? (height - y0[p] + dy[p] - 1) / dy[p] : 0;
if (pw > 0 && ph > 0) AddRows(rows, pw, ph, bitsPerPixel);
}
}
try
{
using var zlib = new ZLibStream(new MemoryStream(compressed, writable: false), CompressionMode.Decompress);
var buffer = new byte[64 * 1024];
var row = 0;
long left = rows.Count > 0 ? rows[0] : 0; // octets restants dans la ligne en cours
int read;
while ((read = zlib.Read(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read;)
{
if (row >= rows.Count) return false; // plus de données que l'image
if (left == rows[row] && buffer[i] > 4) return false; // type de filtre de la ligne
var take = (int)Math.Min(left, read - i);
i += take;
left -= take;
if (left == 0 && ++row < rows.Count) left = rows[row];
}
}
return row == rows.Count;
}
catch (InvalidDataException)
{
return false;
}
}
private static void AddRows(List<long> rows, int width, int height, int bitsPerPixel)
{
var bytesPerRow = 1 + ((long)width * bitsPerPixel + 7) / 8;
for (var y = 0; y < height; y++) rows.Add(bytesPerRow);
}
private static readonly uint[] CrcTable = BuildCrcTable();
private static uint[] BuildCrcTable()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
{
var c = 0xFFFFFFFFu;
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
// ------------------------------------------------------------------ JPEG
private static (ImageInfo?, byte[]?, string?) SanitizeJpeg(ReadOnlySpan<byte> data)
{
const string invalid = "Image JPEG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(data[..2]); // SOI
int width = 0, height = 0, scans = 0;
bool frame = false, tables = false, ended = false;
var pos = 2;
while (pos < data.Length)
{
if (ended) return (null, null, "Image JPEG suivie de données étrangères à l'image.");
if (pos + 2 > data.Length || data[pos] != 0xFF) return (null, null, invalid);
var marker = data[pos + 1];
if (marker == 0xFF)
{
pos++; // octet de remplissage
continue;
}
if (marker == 0xD9)
{
if (!frame || scans == 0) return (null, null, invalid);
output.Write(data.Slice(pos, 2));
pos += 2;
ended = true;
continue;
}
if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) return (null, null, invalid);
if (pos + 4 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
if (length < 2 || pos + 2 + length > data.Length) return (null, null, invalid);
var segment = data.Slice(pos, 2 + length);
var body = data.Slice(pos + 4, length - 2);
pos += 2 + length;
switch (marker)
{
case 0xE0: // APP0 (JFIF) : gardé
case 0xEE: // APP14 (Adobe : transformation des couleurs) : gardé
output.Write(segment);
break;
case >= 0xE1 and <= 0xEF: // APP1 à APP15 : EXIF (position, appareil), XMP, profils… : retirés
case 0xFE: // commentaire : retiré
break;
case 0xDB: // tables de quantification
case 0xC4: // tables de Huffman
case 0xDD: // intervalle de resynchronisation
if (marker != 0xDD) tables = true;
output.Write(segment);
break;
case 0xC0 or 0xC1 or 0xC2: // trame baseline, étendue ou progressive (Huffman)
if (frame || body.Length < 6) return (null, null, invalid);
frame = true;
if (body[0] != 8) return (null, null, "Image JPEG non prise en charge (précision).");
height = BinaryPrimitives.ReadUInt16BigEndian(body[1..]);
width = BinaryPrimitives.ReadUInt16BigEndian(body[3..]);
var components = body[5];
if (width == 0 || height == 0 || components is not (1 or 3) || body.Length != 6 + 3 * components)
return (null, null, invalid);
output.Write(segment);
break;
case 0xDA: // début d'un scan : en-tête, puis données compressées jusqu'au marqueur suivant
if (!frame || !tables || body.Length < 6) return (null, null, invalid);
scans++;
output.Write(segment);
var start = pos;
while (true)
{
if (pos + 1 >= data.Length) return (null, null, "Image JPEG incomplète.");
if (data[pos] == 0xFF && data[pos + 1] != 0x00 && data[pos + 1] is not (>= 0xD0 and <= 0xD7) && data[pos + 1] != 0xFF)
break;
pos++;
}
if (pos == start) return (null, null, invalid); // scan vide
output.Write(data[start..pos]);
break;
default:
// Autres trames (sans perte, arithmétiques, hiérarchiques), marqueurs réservés : refusés.
return (null, null, "Image JPEG non prise en charge.");
}
}
if (!ended) return (null, null, "Image JPEG incomplète.");
return (new ImageInfo(Jpeg, width, height), output.ToArray(), null);
}
}
@@ -0,0 +1,149 @@
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Repère les liens et adresses dans le texte d'une demande (envoyé par un inconnu) pour avertir l'administrateur
/// qui la lit : un lien peut mener à un site d'hameçonnage ou à un fichier malveillant. Rien n'est jamais rendu
/// cliquable ; l'avertissement montre les liens « désamorcés » (<see cref="Defang"/>).
///
/// Formes reconnues, y compris déguisées (« hxxp », « http[:]// », « [.] », « (dot) », caractères pleine chasse) :
/// • adresses avec schéma : http://, https://, ftp://, file://, ws://… et toute forme « schéma:// » ;
/// • schémas dangereux sans « // » : javascript:, data:, vbscript:, file:, mailto:, ms-…:, search-ms:, steam:… ;
/// • « www. » suivi d'un nom ;
/// • noms de domaine nus avec une extension courante (« exemple.com/page », « bit.ly/x », « discord.gg/… ») ;
/// • adresses IP (avec port ou chemin éventuels) ; chemins réseau Windows (\\serveur\partage) ;
/// • adresses e-mail.
/// </summary>
public static partial class FeedbackLinks
{
/// <summary>Liens rapportés au plus (le nombre total reste exact).</summary>
public const int MaxReported = 10;
// « . » collé, ou déguisé : « [.] », « (.) », « {.} », « [dot] », « (dot) » (espaces permis autour), « dot » entre espaces.
private const string Dot = @"(?:\.|\s*(?:\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\))\s*|\s+dot\s+)";
private const string Tlds =
"com|net|org|info|biz|xyz|top|site|online|app|dev|io|gg|co|me|tv|cc|ly|to|ai|gl|link|click|live|shop|store|" +
"page|zip|mov|pw|tk|ml|ga|cf|gq|su|ru|cn|fr|be|ch|ca|de|uk|eu|us|es|it|nl|pl|pt|br|in|jp|kr|au|io|lu|re|tech|" +
"space|website|club|fun|icu|win|bid|work|ink|lol|zone|cloud|download|stream|games|game|social|chat|email";
[GeneratedRegex(@"\b[a-z][a-z0-9+.\-]{0,20}\s*(?::|\[:\])\s*(?:/|\\){2}\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SchemeUrl();
[GeneratedRegex(@"\b(?:javascript|vbscript|data|file|mailto|steam|discord|tel|sms|skype|magnet|ms-[a-z\-]+|search-ms|shell)\s*:\s*\S+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DangerousScheme();
[GeneratedRegex(@"\bh(?:xx|\*\*|XX)ps?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DisguisedHttp();
[GeneratedRegex(@"\bwww\d{0,3}\s*" + Dot + @"\s*[a-z0-9\-]+\S*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Www();
[GeneratedRegex(@"\b[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?(?:" + Dot + @"[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*" + Dot +
"(?:" + Tlds + @")\b(?:[/:?#]\S*)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BareDomain();
[GeneratedRegex(@"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:" + Dot + @"(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b(?::\d{1,5})?(?:/\S*)?",
RegexOptions.CultureInvariant)]
private static partial Regex IpAddress();
[GeneratedRegex(@"\\\\[a-z0-9.\-_$]+\\\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UncPath();
[GeneratedRegex(@"\b[a-z0-9._%+\-]+\s*(?:@|\[at\]|\(at\))\s*[a-z0-9\-]+(?:" + Dot + @"[a-z0-9\-]+)+\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Email();
/// <summary>Le texte contient-il au moins un lien ou une adresse ?</summary>
public static bool Contains(string? text) => Find(text).Count > 0;
/// <summary>Liens et adresses trouvés (sans doublon, dans l'ordre d'apparition, <see cref="MaxReported"/> au plus).</summary>
public static IReadOnlyList<string> Find(string? text) => Find(text, out _);
public static IReadOnlyList<string> Find(string? text, out int total)
{
total = 0;
if (string.IsNullOrWhiteSpace(text)) return Array.Empty<string>();
var normalized = Normalize(text);
var found = new List<(int Start, int End, string Value)>();
void Collect(Regex regex)
{
foreach (Match match in regex.Matches(normalized))
{
var value = TrimEnd(match.Value);
if (value.Length == 0) continue;
var end = match.Index + value.Length;
if (found.Any(f => match.Index < f.End && end > f.Start)) continue; // déjà couvert par un motif plus large
found.Add((match.Index, end, value));
}
}
Collect(SchemeUrl());
Collect(DangerousScheme());
Collect(UncPath());
Collect(Email());
Collect(Www());
Collect(IpAddress());
Collect(BareDomain());
Collect(DisguisedHttp());
var distinct = found.OrderBy(f => f.Start).Select(f => f.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
total = distinct.Count;
return distinct.Take(MaxReported).ToList();
}
/// <summary>
/// Forme « désamorcée » à afficher (« hxxps[://]exemple[.]com ») : ni cliquable, ni ouverte par un copier-coller
/// distrait dans la barre d'adresse.
/// </summary>
public static string Defang(string link)
{
var text = link.Length > 120 ? link[..117] + "…" : link;
text = Regex.Replace(text, @"(?i)\bhttp", "hxxp");
text = Regex.Replace(text, @"(?i)\bftp", "fxp");
return text.Replace("://", "[://]").Replace(".", "[.]").Replace("@", "[@]");
}
/// <summary>
/// Caractères pleine chasse (« https:// ») ramenés à leur équivalent ASCII, et « ⁄ ∕ / » à « / » : les
/// déguisements les plus courants ne passent pas inaperçus. (Pas de normalisation Unicode complète : indisponible
/// sur le serveur en mode « culture invariante » ; les caractères invisibles sont déjà refusés à l'envoi.)
/// </summary>
private static string Normalize(string text)
{
var sb = new StringBuilder(text.Length);
foreach (var c in text)
sb.Append(c switch
{
>= '!' and <= '~' => (char)(c - 0xFEE0),
'。' or '。' or '․' => '.', // points idéographique, demi-chasse, « point de conduite »
'⁄' or '∕' => '/',
_ => c
});
return sb.ToString();
}
/// <summary>Ponctuation de fin de phrase retirée ; une parenthèse ou un crochet fermant seulement s'il n'est pas ouvert dans le lien.</summary>
private static string TrimEnd(string value)
{
while (value.Length > 0)
{
var last = value[^1];
var trim = last switch
{
'.' or ',' or ';' or ':' or '!' or '?' or '»' or '"' or '\'' or '>' or '…' => true,
')' => value.Count(c => c == '(') < value.Count(c => c == ')'),
']' => value.Count(c => c == '[') < value.Count(c => c == ']'),
'}' => value.Count(c => c == '{') < value.Count(c => c == '}'),
_ => false
};
if (!trim) break;
value = value[..^1];
}
return value;
}
}
@@ -109,6 +109,9 @@ public static partial class FeedbackRules
}
if (total > MaxTotalLength) errors.Add($"Demande trop longue ({total} caractères, {MaxTotalLength} au plus).");
if (submission.AttachmentCount is < 0 or > FeedbackImages.MaxAttachments)
errors.Add($"Captures d'écran : {FeedbackImages.MaxAttachments} au plus.");
var tech = CheckTech(submission.Tech, errors);
return errors.Count > 0 ? null : submission with
{
@@ -56,7 +56,9 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("audit.", "Consultations et exports du journal"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
@@ -493,12 +495,21 @@ public static class AuditTexts
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"release.sign" => "Version en ligne signée",
"keyring.publish" => "Trousseau de signature publié",
"feedback.submit" => "Demande de joueur reçue",
"feedback.attachment" => "Capture d'écran reçue",
"feedback.view" => "Demande de joueur consultée",
"feedback.update" => "Demande de joueur traitée (statut ou note)",
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
"audit.view" => "Journal d'audit consulté",
"audit.export" => "Journal d'audit exporté (CSV)",
"audit.suppressed" => "Tentatives refusées regroupées",
"audit.purge" => "Anciens mois du journal supprimés (conservation)",
_ => action
};
@@ -518,7 +529,7 @@ public static class AuditTexts
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export",
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -81,6 +81,12 @@ public sealed class FeedbackItem : ObservableObject
public bool HasBadge => !string.IsNullOrEmpty(_summary.Badge);
public bool HasNote => _summary.HasNote;
/// <summary>Contient un lien ou une adresse : icône d'avertissement dans la liste.</summary>
public bool HasLinks => _summary.HasLinks;
public bool HasAttachments => _summary.AttachmentCount > 0;
public string AttachmentsToolTip => $"{_summary.AttachmentCount} capture(s) d'écran";
/// <summary>« 08/11/2026 21:14 » (heure du PC).</summary>
public string DateText => _summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
@@ -94,7 +100,81 @@ public sealed class FeedbackItem : ObservableObject
}
/// <summary>Champ d'une demande, tel qu'affiché dans la fiche (libellé du formulaire, valeur en texte brut).</summary>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong);
/// <param name="HasLink">Le texte contient un lien ou une adresse (cadre d'avertissement).</param>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong, bool HasLink = false);
/// <summary>
/// Capture d'écran d'une demande : chargée après la fiche, REVÉRIFIÉE (<see cref="FeedbackImages.Sanitize"/> : vraie
/// image PNG ou JPEG, sans métadonnées) avant d'être affichée, réduite pour l'aperçu, agrandie à la demande.
/// </summary>
public sealed class FeedbackAttachmentView : ObservableObject
{
private byte[]? _content;
private string? _error;
private bool _isExpanded;
public FeedbackAttachmentView(FeedbackAttachmentInfo info)
{
Info = info;
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded, () => HasContent);
}
public FeedbackAttachmentInfo Info { get; }
/// <summary>« Capture 1 · 1920 × 1080 · PNG · 845 Ko ».</summary>
public string Label => $"Capture {Info.Index} · {Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Info.Bytes / 1024)} Ko";
public byte[]? Content
{
get => _content;
private set
{
if (!SetProperty(ref _content, value)) return;
OnPropertyChanged(nameof(HasContent));
OnPropertyChanged(nameof(IsLoading));
ToggleCommand.RaiseCanExecuteChanged();
}
}
public bool HasContent => _content is not null;
public string? Error
{
get => _error;
private set
{
if (!SetProperty(ref _error, value)) return;
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(IsLoading));
}
}
public bool HasError => !string.IsNullOrEmpty(_error);
public bool IsLoading => _content is null && _error is null;
public bool IsExpanded
{
get => _isExpanded;
set
{
if (SetProperty(ref _isExpanded, value)) OnPropertyChanged(nameof(ToggleText));
}
}
public string ToggleText => _isExpanded ? "Réduire" : "Agrandir";
public RelayCommand ToggleCommand { get; }
/// <summary>Contenu reçu du serveur : affiché seulement s'il passe le contrôle commun.</summary>
public void Load(byte[] received)
{
var error = FeedbackImages.Sanitize(received, out var info, out var clean);
if (error is not null || info is null) Error = $"Image refusée : {error}";
else if (info.Width != Info.Width || info.Height != Info.Height) Error = "Image refusée : dimensions différentes de celles annoncées.";
else Content = clean;
}
public void Fail(string message) => Error = message;
}
/// <summary>Statut proposé dans la fiche.</summary>
public sealed record FeedbackStatusChoice(string Value, string Label);
@@ -119,8 +199,33 @@ public sealed class FeedbackDetailView : ObservableObject
_status = AllStatuses.FirstOrDefault(s => s.Value == detail.Summary.Status) ?? AllStatuses[0];
_note = detail.AdminNote ?? "";
Lines = BuildLines(detail);
Attachments = detail.Attachments.Select(a => new FeedbackAttachmentView(a)).ToList();
var text = detail.Summary.Title + "\n" + string.Join("\n", detail.Fields.Values);
Links = FeedbackLinks.Find(text, out var total).Select(FeedbackLinks.Defang).ToList();
LinkCount = total;
}
// ------------------------------------------------------------------ Liens (avertissement)
/// <summary>Liens et adresses trouvés, sous forme désamorcée (« hxxps[://]exemple[.]com »).</summary>
public IReadOnlyList<string> Links { get; }
public int LinkCount { get; }
public bool HasLinks => LinkCount > 0;
public string LinksWarning => LinkCount == 0 ? "" :
$"Cette demande contient {(LinkCount == 1 ? "un lien ou une adresse" : $"{LinkCount} liens ou adresses")}. Elle vient d'un " +
"inconnu : n'ouvrez pas ces liens et ne les copiez pas dans un navigateur, ils peuvent mener à un site d'hameçonnage ou " +
"à un fichier malveillant. En cas de doute, supprimez la demande.";
public string LinksText => string.Join("\n", Links.Select(l => "• " + l)) +
(LinkCount > Links.Count ? $"\n… et {LinkCount - Links.Count} autre(s)" : "");
// ------------------------------------------------------------------ Captures
public IReadOnlyList<FeedbackAttachmentView> Attachments { get; }
public bool HasAttachments => Attachments.Count > 0;
private static readonly IReadOnlyList<FeedbackStatusChoice> AllStatuses =
FeedbackStatuses.All.Select(s => new FeedbackStatusChoice(s, FeedbackStatuses.Label(s))).ToList();
@@ -200,10 +305,11 @@ public sealed class FeedbackDetailView : ObservableObject
var lines = new List<FeedbackFieldLine>();
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline));
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
field.Kind == FeedbackFieldKind.Multiline && FeedbackLinks.Contains(value)));
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n')));
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
return lines;
}
}
@@ -308,9 +308,28 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
var detail = await Context.Session.GetFeedbackAsync(gameId, item.Id);
if (!ReferenceEquals(item, _selectedItem)) return; // autre demande choisie entre-temps
item.Summary = detail.Summary;
Detail = NewDetail(detail);
var view = NewDetail(detail);
Detail = view;
await LoadAttachmentsAsync(gameId, view);
});
/// <summary>Captures de la fiche affichée, une par une (chacune revérifiée avant l'affichage).</summary>
private async Task LoadAttachmentsAsync(string gameId, FeedbackDetailView view)
{
foreach (var attachment in view.Attachments)
{
if (!ReferenceEquals(view, _detail)) return; // autre demande affichée entre-temps
try
{
attachment.Load(await Context.Session.GetFeedbackAttachmentAsync(gameId, view.Id, attachment.Info.Index));
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
attachment.Fail(ApiErrorText.Describe(ex));
}
}
}
private FeedbackDetailView NewDetail(FeedbackDetail detail) =>
new(detail, CanManage, () => SaveCommand.RaiseCanExecuteChanged(), reference => Context.Dialogs.CopyToClipboard(reference));
@@ -366,6 +385,7 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
var saved = await Context.Session.UpdateFeedbackAsync(gameId, detail.Id, detail.Revision, detail.Status.Value, detail.NoteToSend);
if (_loaded.FirstOrDefault(i => i.Id == saved.Summary.Id) is { } item) item.Summary = saved.Summary;
Detail = NewDetail(saved);
await LoadAttachmentsAsync(gameId, _detail!);
StatusMessage = $"Demande {saved.Summary.Reference} enregistrée : {FeedbackStatuses.Label(saved.Summary.Status)}.";
await RefreshCountsAsync();
}
@@ -0,0 +1,46 @@
using GameLauncher.Contracts.Feedback;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Feedback;
/// <summary>Image préparée par le launcher (convertie en PNG ou JPEG, réduite si besoin), ou raison du refus.</summary>
/// <param name="Name">Nom affiché au joueur (jamais envoyé).</param>
public sealed record ScreenshotResult(byte[]? Bytes, string Name, string? Error)
{
public static ScreenshotResult Refused(string name, string error) => new(null, name, error);
}
/// <summary>
/// Source des captures d'écran (fenêtre WPF : explorateur Windows, presse-papiers). Elle décode l'image choisie (PNG,
/// JPEG, BMP, GIF, WebP reconnus à leur contenu), la réduit si besoin et la réencode en PNG ou JPEG SANS ses
/// métadonnées ; le module la revérifie ensuite avec <see cref="FeedbackImages.Sanitize"/>.
/// </summary>
public interface IScreenshotSource
{
/// <summary>Explorateur Windows (null : annulé).</summary>
ScreenshotResult? PickFile();
/// <summary>Image du presse-papiers (Win+Maj+S, Impr. écran) ; null : aucune image.</summary>
ScreenshotResult? FromClipboard();
}
/// <summary>Capture jointe à la demande (déjà contrôlée et nettoyée).</summary>
public sealed class FeedbackAttachmentDraft
{
public FeedbackAttachmentDraft(string name, byte[] content, FeedbackImages.ImageInfo info, Action<FeedbackAttachmentDraft> remove)
{
Name = name;
Content = content;
Info = info;
RemoveCommand = new RelayCommand(() => remove(this));
}
public string Name { get; }
public byte[] Content { get; }
public FeedbackImages.ImageInfo Info { get; }
/// <summary>« 1920 × 1080 · PNG · 845 Ko ».</summary>
public string Details => $"{Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Content.Length / 1024)} Ko";
public RelayCommand RemoveCommand { get; }
}
@@ -11,10 +11,13 @@ namespace GameLauncher.Modules.Feedback;
/// <param name="InstalledVersion">Version installée (null : jeu non installé).</param>
public sealed record FeedbackTarget(string GameId, string GameName, string? InstalledVersion, string LauncherVersion);
/// <summary>Envoi d'une demande (Launcher API en production, simulé dans les tests). Renvoie la référence.</summary>
/// <summary>Résultat d'un envoi : référence, captures reçues, raison de l'échec d'une capture.</summary>
public sealed record FeedbackSent(string Reference, int AttachmentsSent, string? AttachmentError);
/// <summary>Envoi d'une demande et de ses captures (Launcher API en production, simulé dans les tests).</summary>
public interface IFeedbackSender
{
Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct);
Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct);
}
/// <summary>Envoi par la Launcher API (route anonyme : défi anti-spam résolu sur ce PC, aucune session).</summary>
@@ -24,8 +27,11 @@ public sealed class ApiFeedbackSender : IFeedbackSender
public ApiFeedbackSender(LauncherApiClient client) => _client = client;
public async Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) =>
(await _client.SendFeedbackAsync(submission, ct).ConfigureAwait(false)).Reference;
public async Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct)
{
var result = await _client.SendFeedbackAsync(submission, attachments, ct).ConfigureAwait(false);
return new FeedbackSent(result.Submitted.Reference, result.AttachmentsSent, result.AttachmentError);
}
}
/// <summary>Type de demande proposé à la première étape (une tuile par formulaire).</summary>
@@ -62,6 +68,9 @@ public enum FeedbackStep
public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
private readonly IFeedbackSender _sender;
private readonly IScreenshotSource? _screenshots;
private string? _attachmentError;
private string? _attachmentWarning;
private readonly FeedbackTechInfo _tech;
private CancellationTokenSource? _sending;
private FeedbackStep _step = FeedbackStep.ChooseKind;
@@ -73,10 +82,22 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
private string? _errorMessage;
private string? _reference;
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null)
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null,
IScreenshotSource? screenshots = null)
{
Target = target;
_sender = sender;
_screenshots = screenshots;
AddFileCommand = new RelayCommand(() => AddScreenshot(_screenshots?.PickFile()), () => CanAddScreenshot);
PasteCommand = new RelayCommand(() => AddScreenshot(_screenshots?.FromClipboard(), fromClipboard: true), () => CanAddScreenshot);
Attachments.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasAttachments));
OnPropertyChanged(nameof(AttachmentsText));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
PasteCommand.RaiseCanExecuteChanged();
};
_tech = tech ?? FeedbackTech.Collect(target.LauncherVersion, target.InstalledVersion);
Kinds = FeedbackForms.All.Select(f => new FeedbackKindChoice(f, Choose)).ToList();
SendCommand = new AsyncRelayCommand(SendAsync, () => _form is not null && !_isSending && _step == FeedbackStep.Fill);
@@ -200,6 +221,84 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
"Demande anonyme : aucun compte, aucune adresse IP enregistrée avec votre demande. N'écrivez pas d'informations " +
"personnelles (e-mail, nom, téléphone…). Nous lisons toutes les demandes, mais ne pouvons pas y répondre.";
// ------------------------------------------------------------------ Captures d'écran
public ObservableCollection<FeedbackAttachmentDraft> Attachments { get; } = new();
/// <summary>Captures possibles (source disponible : fenêtre WPF).</summary>
public bool ScreenshotsAvailable => _screenshots is not null;
public bool HasAttachments => Attachments.Count > 0;
public bool CanAddScreenshot => _screenshots is not null && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
public string AttachmentsText =>
$"{Attachments.Count} / {FeedbackImages.MaxAttachments} · PNG, JPEG, BMP, GIF ou WebP · {FeedbackImages.MaxBytes / 1024 / 1024} Mo au plus après conversion";
public string ScreenshotPrivacyText =>
"Les captures sont converties et débarrassées de leurs métadonnées (position, appareil, date) avant l'envoi. " +
"Vérifiez qu'elles ne montrent pas d'informations personnelles (pseudo, e-mail, fenêtres ouvertes…).";
public RelayCommand AddFileCommand { get; }
public RelayCommand PasteCommand { get; }
/// <summary>Image refusée (pas une image, format non pris en charge, trop grande…).</summary>
public string? AttachmentError
{
get => _attachmentError;
private set
{
if (SetProperty(ref _attachmentError, value)) OnPropertyChanged(nameof(HasAttachmentError));
}
}
public bool HasAttachmentError => !string.IsNullOrEmpty(_attachmentError);
/// <summary>Après l'envoi : captures non reçues par le serveur (la demande, elle, est enregistrée).</summary>
public string? AttachmentWarning
{
get => _attachmentWarning;
private set
{
if (SetProperty(ref _attachmentWarning, value)) OnPropertyChanged(nameof(HasAttachmentWarning));
}
}
public bool HasAttachmentWarning => !string.IsNullOrEmpty(_attachmentWarning);
/// <summary>
/// Ajoute une image préparée par la source, après le contrôle commun (vraie image PNG ou JPEG, dimensions, taille,
/// structure) et le retrait des métadonnées.
/// </summary>
public void AddScreenshot(ScreenshotResult? result, bool fromClipboard = false)
{
if (result is null)
{
if (fromClipboard) AttachmentError = "Le presse-papiers ne contient pas d'image.";
return;
}
if (!CanAddScreenshot)
{
AttachmentError = $"{FeedbackImages.MaxAttachments} captures au plus.";
return;
}
if (result.Error is not null || result.Bytes is null)
{
AttachmentError = $"{result.Name} : {result.Error ?? "image illisible."}";
return;
}
var error = FeedbackImages.Sanitize(result.Bytes, out var info, out var clean);
if (error is not null)
{
AttachmentError = $"{result.Name} : {error}";
return;
}
AttachmentError = null;
Attachments.Add(new FeedbackAttachmentDraft(result.Name, clean!, info!, draft =>
{
if (!_isSending) Attachments.Remove(draft);
}));
}
// ------------------------------------------------------------------ Envoi
public AsyncRelayCommand SendCommand { get; }
@@ -213,6 +312,9 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
if (!SetProperty(ref _isSending, value)) return;
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
PasteCommand.RaiseCanExecuteChanged();
RaiseCommands();
}
}
@@ -288,8 +390,13 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
_sending = new CancellationTokenSource();
try
{
Reference = await _sender.SendAsync(submission, _sending.Token);
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}");
var sent = await _sender.SendAsync(submission, Attachments.Select(a => a.Content).ToList(), _sending.Token);
Reference = sent.Reference;
AttachmentWarning = sent.AttachmentsSent < Attachments.Count
? $"{Attachments.Count - sent.AttachmentsSent} capture(s) sur {Attachments.Count} n'ont pas pu être envoyées" +
(sent.AttachmentError is null ? "." : $" ({sent.AttachmentError}).") + " La demande, elle, est bien enregistrée."
: null;
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}, {sent.AttachmentsSent} capture(s)");
Step = FeedbackStep.Done;
}
catch (OperationCanceledException) when (_sending.IsCancellationRequested)
+1 -1
View File
@@ -47,7 +47,7 @@ public static class ScrollAssist
}
/// <summary>
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
/// Molette de la souris dans le menu d'administration et la fenêtre « Aide » : elle fait défiler ce qui est SOUS le curseur, jamais une
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.0.0</Version>
<Version>3.1.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -44,7 +44,7 @@ public sealed class FeedbackWindowService
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
new ApiFeedbackSender(_client));
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window));
var window = new FeedbackWindow(viewModel);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
@@ -0,0 +1,109 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Feedback;
using Microsoft.Win32;
namespace GameLauncher.Services;
/// <summary>
/// Captures d'écran du bouton « Aide » : image choisie dans l'explorateur Windows ou collée depuis le presse-papiers.
/// • format reconnu au CONTENU (PNG, JPEG, BMP, GIF, WebP), jamais à l'extension ; fichier de 30 Mo au plus ;
/// • dimensions lues avant tout décodage complet (image démesurée refusée sans être chargée en mémoire) ;
/// • image décodée par Windows (preuve que c'est réellement une image), réduite à 3 840 px au plus, puis réencodée en
/// PNG (JPEG pour une photo JPEG ou si le PNG est trop lourd) SANS métadonnées : ni position GPS, ni appareil, ni date ;
/// • le nom du fichier n'est jamais envoyé (affiché au joueur seulement).
/// </summary>
public sealed class ScreenshotSource : IScreenshotSource
{
private const long MaxInputBytes = 30L * 1024 * 1024;
private const long MaxInputPixels = 120_000_000; // ≈ 12 000 × 10 000 : au-delà, refus avant décodage
private readonly Func<Window?> _owner;
public ScreenshotSource(Func<Window?> owner) => _owner = owner;
public ScreenshotResult? PickFile()
{
var dialog = new OpenFileDialog
{
Title = "Ajouter une capture d'écran",
Filter = "Images (PNG, JPEG, BMP, GIF, WebP)|" + string.Join(";", FeedbackImages.InputExtensions.Select(e => "*" + e)),
CheckFileExists = true,
Multiselect = false,
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)
};
if (dialog.ShowDialog(_owner()) != true) return null;
var name = Path.GetFileName(dialog.FileName);
try
{
var info = new FileInfo(dialog.FileName);
if (info.Length > MaxInputBytes) return ScreenshotResult.Refused(name, "fichier trop volumineux (30 Mo au plus).");
var bytes = File.ReadAllBytes(dialog.FileName);
var format = FeedbackImages.DetectInputFormat(bytes);
if (format is null) return ScreenshotResult.Refused(name, "ce fichier n'est pas une image PNG, JPEG, BMP, GIF ou WebP.");
using var stream = new MemoryStream(bytes, writable: false);
// Décodage différé (BitmapCacheOption.Default) : les dimensions sont lues avant les pixels.
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.Default);
if (decoder.Frames.Count == 0) return ScreenshotResult.Refused(name, "image vide.");
return Encode(name, decoder.Frames[0], preferJpeg: format == FeedbackImages.Jpeg);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or
FileFormatException or ArgumentException or InvalidOperationException or OverflowException or
System.Runtime.InteropServices.COMException)
{
Log.Warn($"Capture refusée ({name}) : {ex.Message}");
return ScreenshotResult.Refused(name, "image illisible ou format non pris en charge par Windows.");
}
}
public ScreenshotResult? FromClipboard()
{
try
{
if (!Clipboard.ContainsImage()) return null;
var image = Clipboard.GetImage();
return image is null ? null : Encode("Image collée", image, preferJpeg: false);
}
catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or System.Runtime.InteropServices.ExternalException or
NotSupportedException or ArgumentException or InvalidOperationException)
{
Log.Warn($"Capture du presse-papiers refusée : {ex.Message}");
return ScreenshotResult.Refused("Image collée", "image du presse-papiers illisible.");
}
}
/// <summary>Réduction éventuelle puis réencodage sans métadonnées, en restant sous la taille maximale.</summary>
private static ScreenshotResult Encode(string name, BitmapSource source, bool preferJpeg)
{
if (source.PixelWidth <= 0 || source.PixelHeight <= 0 || (long)source.PixelWidth * source.PixelHeight > MaxInputPixels)
return ScreenshotResult.Refused(name, "dimensions de l'image refusées.");
// Côté le plus long ramené à 3 840 px ET surface ramenée sous la limite du serveur (ex. capture carrée 4K).
var pixels = (double)source.PixelWidth * source.PixelHeight;
var scale = Math.Min(1.0, Math.Min((double)FeedbackImages.TargetMaxSide / Math.Max(source.PixelWidth, source.PixelHeight),
Math.Sqrt(FeedbackImages.MaxPixels * 0.98 / pixels)));
for (var attempt = 0; attempt < 5; attempt++)
{
BitmapSource frame = scale < 1.0 ? new TransformedBitmap(source, new ScaleTransform(scale, scale)) : source;
if (frame.Format != PixelFormats.Bgra32 && frame.Format != PixelFormats.Bgr32)
frame = new FormatConvertedBitmap(frame, PixelFormats.Bgra32, null, 0);
frame.Freeze();
foreach (var jpeg in preferJpeg ? new[] { true } : new[] { false, true })
{
BitmapEncoder encoder = jpeg ? new JpegBitmapEncoder { QualityLevel = 88 } : new PngBitmapEncoder();
// BitmapFrame.Create(source) : nouvelle image, AUCUNE métadonnée de l'original n'est reprise.
encoder.Frames.Add(BitmapFrame.Create(jpeg ? new FormatConvertedBitmap(frame, PixelFormats.Bgr24, null, 0) : frame));
using var output = new MemoryStream();
encoder.Save(output);
if (output.Length <= FeedbackImages.MaxBytes) return new ScreenshotResult(output.ToArray(), name, null);
}
scale *= 0.75; // trop lourde même en JPEG : réduite
}
return ScreenshotResult.Refused(name, "image trop lourde, même réduite.");
}
}
+80 -5
View File
@@ -2134,6 +2134,14 @@
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
ToolTip="Contient un lien ou une adresse : prudence"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}" Data="{StaticResource Icon.ImageOutline}"
ToolTip="{Binding AttachmentsToolTip}"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" />
<Ellipse x:Name="NewDot" Width="8" Height="8" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Visibility="Collapsed" ToolTip="Nouvelle" />
<TextBlock x:Name="StatusText" Text="{Binding StatusLabel}" FontSize="11" FontWeight="SemiBold"
@@ -2170,6 +2178,20 @@
<!-- Fiche d'une demande -->
<DataTemplate DataType="{x:Type afb:FeedbackDetailView}">
<StackPanel>
<!-- Liens ou adresses dans la demande (envoyée par un inconnu) : avertissement, liens désamorcés -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}" />
<StackPanel Margin="10,0,0,0">
<TextBlock Text="{Binding LinksWarning}" TextWrapping="Wrap" FontSize="12.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding LinksText}" TextWrapping="Wrap" FontSize="12" Margin="0,6,0,0"
FontFamily="Cascadia Mono, Consolas, Courier New" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</DockPanel>
</Border>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel>
@@ -2196,16 +2218,69 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,12">
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,4" />
<TextBox Text="{Binding Value, Mode=OneWay}" IsReadOnly="True" TextWrapping="Wrap" BorderThickness="0"
Background="Transparent" Padding="0" FontSize="13" Foreground="{DynamicResource TextPrimaryBrush}"
CaretBrush="{DynamicResource TextPrimaryBrush}" SelectionBrush="{DynamicResource AccentBrush}"
IsUndoEnabled="False" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0" />
<Path Width="11" Height="11" Stretch="Uniform" Margin="6,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}" ToolTip="Contient un lien ou une adresse"
Visibility="{Binding HasLink, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<Border x:Name="ValueBorder" BorderThickness="0" CornerRadius="8" Padding="0">
<TextBox Text="{Binding Value, Mode=OneWay}" IsReadOnly="True" TextWrapping="Wrap" BorderThickness="0"
Background="Transparent" Padding="0" FontSize="13" Foreground="{DynamicResource TextPrimaryBrush}"
CaretBrush="{DynamicResource TextPrimaryBrush}" SelectionBrush="{DynamicResource AccentBrush}"
IsUndoEnabled="False" />
</Border>
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding HasLink}" Value="True">
<Setter TargetName="ValueBorder" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ValueBorder" Property="BorderThickness" Value="1" />
<Setter TargetName="ValueBorder" Property="Padding" Value="8,6" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Captures d'écran : revérifiées avant l'affichage, aperçu réduit, « Agrandir » -->
<StackPanel Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" Margin="0,0,0,12">
<TextBlock Text="Captures d'écran" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,6" />
<ItemsControl ItemsSource="{Binding Attachments}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,0,10" CornerRadius="12" Padding="8" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<DockPanel Margin="2,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="{Binding ToggleText}"
ui:IconAssist.Icon="{StaticResource Icon.ArrowExpand}" Command="{Binding ToggleCommand}" />
<TextBlock Text="{Binding Label}" FontSize="12" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<TextBlock Text="Chargement…" Style="{StaticResource AdminHintStyle}"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding Error}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
<Border x:Name="Frame" CornerRadius="8" ClipToBounds="True" MaxHeight="180" HorizontalAlignment="Left"
Background="{DynamicResource SurfaceAltBrush}"
Visibility="{Binding HasContent, Converter={StaticResource BoolToVisibility}}">
<Image x:Name="Picture" Stretch="Uniform" StretchDirection="DownOnly"
Source="{Binding Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=480}" />
</Border>
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsExpanded}" Value="True">
<Setter TargetName="Frame" Property="MaxHeight" Value="900" />
<Setter TargetName="Picture" Property="Source"
Value="{Binding Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=1600}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Text="Informations techniques" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,4" />
<TextBlock Text="{Binding TechText}" FontSize="12" TextWrapping="Wrap" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextSecondaryBrush}" />
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:fb="clr-namespace:GameLauncher.Modules.Feedback;assembly=GameLauncher.Modules.Feedback"
xmlns:conv="clr-namespace:GameLauncher.Converters"
Title="Aide"
Width="680"
SizeToContent="Height"
@@ -16,14 +17,20 @@
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True"
PreviewKeyDown="OnPreviewKeyDown">
<!--
Bouton « Aide » de la fenêtre principale : avis, demande d'évolution, bug ou autre, pour le jeu sélectionné.
ViewModel : FeedbackFormViewModel (GameLauncher.Modules.Feedback) ; formulaires décrits dans GameLauncher.Contracts.
Tout le texte saisi est affiché tel quel (jamais interprété) ; l'envoi est anonyme.
Hauteur : formulaire limité à une taille de base (ascenseur au-delà) ; la fenêtre reste centrée quand l'étape
change (voir FeedbackWindow.xaml.cs). Molette : WheelAssist, comme le menu d'administration (une liste
déroulante fermée ne change jamais de valeur, un champ en bout de course laisse défiler le formulaire).
-->
<Window.Resources>
<conv:BytesToImageConverter x:Key="FeedbackBytesToImage" />
<!-- Tuile du choix du type de demande -->
<Style x:Key="FeedbackKindTileStyle" TargetType="Button">
<Setter Property="Margin" Value="0,0,12,12" />
@@ -242,6 +249,55 @@
<!-- Champs prédéfinis du type choisi -->
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
<!-- Captures d'écran : converties et nettoyées de leurs métadonnées avant l'envoi -->
<StackPanel Margin="0,0,0,14" Visibility="{Binding ScreenshotsAvailable, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Captures d'écran (facultatif)" Style="{StaticResource FeedbackLabelStyle}" />
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Ajouter une image…" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.ImageOutline}" Command="{Binding AddFileCommand}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Coller (Ctrl+V)"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding PasteCommand}"
ToolTip="Colle l'image du presse-papiers (capture faite avec Win+Maj+S ou Impr. écran)" />
</StackPanel>
<TextBlock Text="{Binding AttachmentsText}" FontSize="11" Margin="2,6,0,0" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding AttachmentError}" Style="{StaticResource FeedbackFieldErrorStyle}"
Visibility="{Binding HasAttachmentError, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Attachments}" Focusable="False" Margin="0,10,0,0"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="170" Margin="0,0,10,10" CornerRadius="12" Padding="6" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<Grid>
<Border Height="92" CornerRadius="8" ClipToBounds="True" Background="{DynamicResource SurfaceAltBrush}">
<Image Source="{Binding Content, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=320}"
Stretch="Uniform" />
</Border>
<Button Style="{StaticResource IconButtonStyle}" Width="26" Height="26" Padding="7"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,4,4,0"
ToolTip="Retirer cette capture" ui:IconAssist.Icon="{StaticResource Icon.Close}"
Command="{Binding RemoveCommand}" />
</Grid>
<TextBlock Text="{Binding Name}" FontSize="11.5" Margin="2,6,2,0" TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}" />
<TextBlock Text="{Binding Details}" FontSize="10.5" Margin="2,2,2,0"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding ScreenshotPrivacyText}" FontSize="11" Margin="2,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<!-- Informations techniques : facultatives, contenu exact affiché -->
<Border CornerRadius="14" Padding="14,4,14,12" Margin="0,4,0,12" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
@@ -289,6 +345,9 @@
</Border>
<TextBlock x:Name="CopiedText" Text="Référence copiée." FontSize="11.5" Margin="0,6,0,0" HorizontalAlignment="Center"
Foreground="{DynamicResource SuccessBrush}" Visibility="Collapsed" />
<TextBlock Text="{Binding AttachmentWarning}" FontSize="12" Margin="0,12,0,0" HorizontalAlignment="Center"
TextAlignment="Center" TextWrapping="Wrap" Foreground="{DynamicResource WarningBrush}"
Visibility="{Binding HasAttachmentWarning, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
+52 -3
View File
@@ -10,7 +10,11 @@ namespace GameLauncher.Views;
/// </summary>
public partial class FeedbackWindow : Window
{
/// <summary>Hauteur de base de la zone du formulaire : au-delà, il défile (ascenseur) au lieu d'agrandir la fenêtre.</summary>
private const double BodyMaxHeight = 500;
private readonly FeedbackFormViewModel _viewModel;
private bool _shown;
public FeedbackWindow(FeedbackFormViewModel viewModel)
{
@@ -20,11 +24,18 @@ public partial class FeedbackWindow : Window
viewModel.CloseRequested += Close;
viewModel.PropertyChanged += (_, e) =>
{
// Formulaire affiché : le curseur se place dans le titre.
// Nouvelle étape : le contenu repart du haut ; formulaire affiché : le curseur se place dans le titre.
if (e.PropertyName is nameof(FeedbackFormViewModel.IsFilling) or nameof(FeedbackFormViewModel.IsDone))
BodyScroll.ScrollToTop();
if (e.PropertyName == nameof(FeedbackFormViewModel.IsFilling) && viewModel.IsFilling)
Dispatcher.InvokeAsync(() => TitleBox.Focus(), System.Windows.Threading.DispatcherPriority.Input);
};
SourceInitialized += (_, _) => FitToScreen();
ContentRendered += (_, _) => _shown = true;
SizeChanged += (_, e) =>
{
if (_shown && e.HeightChanged) KeepCentered(e.PreviousSize.Height);
};
Closed += (_, _) =>
{
viewModel.CloseRequested -= Close;
@@ -32,11 +43,30 @@ public partial class FeedbackWindow : Window
};
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente : au-delà, le formulaire défile.</summary>
/// <summary>
/// Taille de base raisonnable (<see cref="BodyMaxHeight"/>), jamais plus grande que la zone utile de l'écran de la
/// fenêtre parente : au-delà, le formulaire défile.
/// </summary>
private void FitToScreen()
{
const double chrome = 230; // en-tête, boutons, marges et ombre
BodyScroll.MaxHeight = Math.Max(260, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
var available = Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome;
BodyScroll.MaxHeight = Math.Max(260, Math.Min(BodyMaxHeight, available));
}
/// <summary>
/// La hauteur change avec l'étape (choix du type › formulaire › référence) : la fenêtre grandit ou rétrécit autour de
/// son centre (centre du launcher à l'ouverture), au lieu de s'étendre vers le bas de l'écran ; elle reste
/// entièrement dans la zone utile de l'écran. Une fenêtre placée volontairement hors des écrans (captures de
/// tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private void KeepCentered(double previousHeight)
{
if (previousHeight <= 0 || double.IsNaN(Top) || double.IsNaN(Left) || WindowState != WindowState.Normal) return;
var work = Controls.WindowModeAssist.WorkArea(this);
if (work.Width < 1 || work.Height < 1 || !work.IntersectsWith(new Rect(Left, Top, ActualWidth, previousHeight))) return;
var top = Top + (previousHeight - ActualHeight) / 2;
Top = Math.Max(work.Top, Math.Min(top, work.Bottom - ActualHeight));
}
private void OnCopyReference(object sender, RoutedEventArgs e)
@@ -56,6 +86,25 @@ public partial class FeedbackWindow : Window
/// <summary>Échap ferme la fenêtre, sauf pendant la saisie d'un formulaire (pas de perte du texte par mégarde).</summary>
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Ctrl+V avec une image (et pas de texte) dans le presse-papiers : ajoutée comme capture, même depuis un champ.
if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control && _viewModel.IsFilling && _viewModel.CanAddScreenshot)
{
bool hasImage;
try
{
hasImage = Clipboard.ContainsImage() && !Clipboard.ContainsText();
}
catch (System.Runtime.InteropServices.ExternalException)
{
hasImage = false; // presse-papiers occupé
}
if (hasImage)
{
_viewModel.PasteCommand.Execute(null);
e.Handled = true;
return;
}
}
if (e.Key != Key.Escape || _viewModel.IsFilling || _viewModel.IsSending) return;
Close();
e.Handled = true;
@@ -113,7 +113,77 @@ public class FeedbackPanelTests
private sealed class NeverSender : IFeedbackSender
{
public Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) => throw new InvalidOperationException();
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct) =>
throw new InvalidOperationException();
}
/// <summary>Source simulée : l'image « choisie » ou « collée » est donnée par le test.</summary>
private sealed class FakeScreenshots : IScreenshotSource
{
public ScreenshotResult? Next { get; set; }
public ScreenshotResult? PickFile() => Next;
public ScreenshotResult? FromClipboard() => Next;
}
[Fact]
public async Task Player_screenshots_are_checked_then_sent_and_shown_to_admins_with_a_link_warning()
{
await using var api = await StartApiAsync();
using var client = api.CreateClient();
var screenshots = new FakeScreenshots();
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival Zombie Reborn", "0.9.3", "2.6.0"),
new ApiFeedbackSender(client), screenshots: screenshots);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Other).SelectCommand.Execute(null);
form.Title = "Un joueur m'a envoyé ce lien";
Field(form, "message").Text = "Il disait : www.cadeaux-gratuits.xyz/skin";
// Fichier qui n'est pas une image, presse-papiers vide : refusés avec un message.
screenshots.Next = new ScreenshotResult(System.Text.Encoding.UTF8.GetBytes("MZ"), "virus.png", null);
form.AddFileCommand.Execute(null);
Assert.True(form.HasAttachmentError);
screenshots.Next = null;
form.PasteCommand.Execute(null);
Assert.Contains("presse-papiers", form.AttachmentError);
screenshots.Next = new ScreenshotResult(GameLauncher.Tests.Contracts.FeedbackTestImages.PngWith("tEXt", "Nicolas"), "capture.png", null);
form.AddFileCommand.Execute(null);
Assert.False(form.HasAttachmentError, form.AttachmentError);
Assert.Contains("64 × 48 · PNG", Assert.Single(form.Attachments).Details);
await form.SendCommand.ExecuteAsync();
Assert.True(form.IsDone, form.ErrorMessage);
Assert.False(form.HasAttachmentWarning, form.AttachmentWarning);
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(FeedbackPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<FeedbackPanelViewModel>(workspace.SelectedPanel!.ViewModel);
var item = Assert.Single(page.Items); // un seul jeu déclaré : choisi d'office
Assert.True(item.HasLinks);
Assert.True(item.HasAttachments);
page.SelectedItem = item;
await page.DetailLoad;
var detail = page.Detail!;
Assert.True(detail.HasLinks);
Assert.Contains("www[.]cadeaux-gratuits[.]xyz/skin", detail.LinksText);
Assert.Contains(detail.Lines, l => l.HasLink);
var attachment = Assert.Single(detail.Attachments);
Assert.True(attachment.HasContent, attachment.Error);
attachment.ToggleCommand.Execute(null);
Assert.True(attachment.IsExpanded);
}
[Fact]
public void Feedback_audit_actions_have_friendly_labels()
{
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
"feedback.purge", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
// ------------------------------------------------------------------ Administrateurs
@@ -0,0 +1,167 @@
using System.Buffers.Binary;
using System.Text;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Contracts;
/// <summary>Images fabriquées pour les captures d'écran : métadonnées à retirer, fichiers piégés.</summary>
public static class FeedbackTestImages
{
/// <summary>PNG valide avec un bloc ajouté juste après l'en-tête (texte, eXIf…).</summary>
public static byte[] PngWith(string chunkType, string content, int width = 64, int height = 48)
{
var png = TestImages.Png(width, height);
var chunk = Chunk(chunkType, Encoding.ASCII.GetBytes(content));
const int afterHeader = 8 + 25;
return png[..afterHeader].Concat(chunk).Concat(png[afterHeader..]).ToArray();
}
/// <summary>JPEG structurellement complet (JFIF, EXIF, tables, trame, scan, fin) — suffisant pour le contrôle commun.</summary>
public static byte[] Jpeg(int width = 64, int height = 48, string exif = "Exif\0\0GPS 48.85N 2.35E")
{
var b = new List<byte> { 0xFF, 0xD8 };
b.AddRange(new byte[] { 0xFF, 0xE0, 0x00, 0x10, (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0 });
var exifBytes = Encoding.ASCII.GetBytes(exif);
b.AddRange(new byte[] { 0xFF, 0xE1, (byte)((exifBytes.Length + 2) >> 8), (byte)(exifBytes.Length + 2) });
b.AddRange(exifBytes);
b.AddRange(new byte[] { 0xFF, 0xFE, 0x00, 0x07, (byte)'n', (byte)'o', (byte)'t', (byte)'e', (byte)'!' }); // commentaire
b.AddRange(new byte[] { 0xFF, 0xDB, 0x00, 0x43, 0x00 }.Concat(Enumerable.Repeat((byte)1, 64)));
b.AddRange(new byte[] { 0xFF, 0xC0, 0x00, 0x0B, 8, (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, 1, 1, 0x11, 0 });
b.AddRange(new byte[] { 0xFF, 0xC4, 0x00, 0x14, 0x00 }.Concat(new byte[16] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }).Append((byte)0));
b.AddRange(new byte[] { 0xFF, 0xDA, 0x00, 0x08, 1, 1, 0x00, 0, 63, 0 });
b.AddRange(new byte[] { 0x12, 0x34, 0xFF, 0x00, 0x56 }); // données compressées (avec un FF 00)
b.AddRange(new byte[] { 0xFF, 0xD9 });
return b.ToArray();
}
public static byte[] Chunk(string type, byte[] data)
{
var typeAndData = Encoding.ASCII.GetBytes(type).Concat(data).ToArray();
var result = new byte[12 + data.Length];
BinaryPrimitives.WriteInt32BigEndian(result, data.Length);
typeAndData.CopyTo(result, 4);
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(8 + data.Length), Crc(typeAndData));
return result;
}
private static uint Crc(byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in data)
{
crc ^= b;
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1;
}
return crc ^ 0xFFFFFFFFu;
}
}
/// <summary>Contenu des demandes : liens repérés (avertissement) et captures d'écran (vraie image, métadonnées retirées).</summary>
public class FeedbackContentTests
{
[Theory]
[InlineData("Allez sur https://exemple.com/cadeau", "https://exemple.com/cadeau")]
[InlineData("lien http://192.168.1.10:8080/x)", "http://192.168.1.10:8080/x")]
[InlineData("ftp://serveur.net/fichier.exe", "ftp://serveur.net/fichier.exe")]
[InlineData("voir www.arnaque.fr", "www.arnaque.fr")]
[InlineData("WWW.SITE.COM/promo.", "WWW.SITE.COM/promo")]
[InlineData("rejoins discord.gg/abcd", "discord.gg/abcd")]
[InlineData("bit.ly/3xYz", "bit.ly/3xYz")]
[InlineData("hxxps://mal[.]ru/a", "hxxps://mal[.]ru/a")]
[InlineData("http[:]//exemple.com", "http[:]//exemple.com")]
[InlineData("exemple[.]com", "exemple[.]com")]
[InlineData("exemple (dot) com", "exemple (dot) com")]
[InlineData("javascript:alert(1)", "javascript:alert(1)")]
[InlineData("data:text/html;base64,AAAA", "data:text/html;base64,AAAA")]
[InlineData("ouvrir \\\\serveur\\partage\\x.exe", "\\\\serveur\\partage\\x.exe")]
[InlineData("écrivez à joueur@mail.fr", "joueur@mail.fr")]
[InlineData("serveur 51.75.10.2", "51.75.10.2")]
[InlineData("https://exemple.com", "https://exemple.com")]
public void Links_are_found_in_all_their_forms(string text, string expected) =>
Assert.Contains(expected, FeedbackLinks.Find(text));
[Theory]
[InlineData("Le jeu plante quand j'ouvre l'inventaire.")]
[InlineData("Version 0.9.3, Windows 11, erreur HTTP 500 au lancement.")]
[InlineData("Le fichier Player.log fait 2 Mo ; 3 FPS à 1920×1080.")]
public void Ordinary_text_has_no_link(string text) => Assert.Empty(FeedbackLinks.Find(text));
[Fact]
public void Links_are_defanged_for_display()
{
Assert.Equal("hxxps[://]exemple[.]com/a", FeedbackLinks.Defang("https://exemple.com/a"));
Assert.Equal("joueur[@]mail[.]fr", FeedbackLinks.Defang("joueur@mail.fr"));
}
[Fact]
public void Png_metadata_is_removed_and_the_image_kept()
{
var png = FeedbackTestImages.PngWith("tEXt", "Author\0Nicolas");
Assert.Null(FeedbackImages.Sanitize(png, out var info, out var clean));
Assert.Equal((FeedbackImages.Png, 64, 48), (info!.Format, info.Width, info.Height));
Assert.DoesNotContain("Nicolas", Encoding.ASCII.GetString(clean!));
Assert.Null(FeedbackImages.Sanitize(clean, out _, out var again)); // l'image nettoyée reste valide
Assert.Equal(clean, again);
}
[Fact]
public void Jpeg_exif_and_comments_are_removed()
{
Assert.Null(FeedbackImages.Sanitize(FeedbackTestImages.Jpeg(), out var info, out var clean));
Assert.Equal(FeedbackImages.Jpeg, info!.Format);
var text = Encoding.ASCII.GetString(clean!);
Assert.DoesNotContain("GPS", text);
Assert.DoesNotContain("note!", text);
Assert.Contains("JFIF", text);
}
[Fact]
public void Fake_or_broken_images_are_refused()
{
var png = TestImages.Png(64, 48);
var refused = new Dictionary<string, byte[]>
{
["texte renommé"] = Encoding.UTF8.GetBytes("<html><script>alert(1)</script></html>"),
["GIF"] = Encoding.ASCII.GetBytes("GIF89a").Concat(new byte[64]).ToArray(),
["PNG tronqué"] = png[..^20],
["PNG suivi de données"] = png.Concat(Encoding.ASCII.GetBytes("PK\x03\x04 archive cachée")).ToArray(),
["PNG avec CRC faux"] = Flip(png, 30),
["PNG trop petit pour ses dimensions"] = Header(TestImages.Png(64, 48), width: 640),
["PNG à bloc critique inconnu"] = FeedbackTestImages.PngWith("XYZW", "?"),
["JPEG sans scan"] = TestImages.Jpeg(64, 48),
["JPEG suivi de données"] = FeedbackTestImages.Jpeg().Concat(new byte[] { 1, 2, 3 }).ToArray(),
["image démesurée"] = TestImages.Png(5000, 20)
};
foreach (var (name, data) in refused)
{
Assert.True(FeedbackImages.Sanitize(data, out var info, out var clean) is not null, name);
Assert.Null(clean);
}
}
[Fact]
public void Input_formats_are_recognized_by_content()
{
Assert.Equal("png", FeedbackImages.DetectInputFormat(TestImages.Png(16, 16)));
Assert.Equal("gif", FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("GIF89a......")));
Assert.Equal("webp", FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("RIFF\0\0\0\0WEBPVP8 ")));
Assert.Null(FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("MZ\x90\0 exécutable")));
}
private static byte[] Flip(byte[] data, int index)
{
var copy = (byte[])data.Clone();
copy[index] ^= 0xFF;
return copy;
}
/// <summary>Change la largeur annoncée par l'en-tête (CRC recalculé) : les données ne correspondent plus.</summary>
private static byte[] Header(byte[] png, int width)
{
var body = png.AsSpan(16, 13).ToArray();
BinaryPrimitives.WriteInt32BigEndian(body, width);
var chunk = FeedbackTestImages.Chunk("IHDR", body);
return png[..8].Concat(chunk).Concat(png[(8 + 25)..]).ToArray();
}
}
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Contracts;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -331,4 +332,81 @@ public class FeedbackModuleTests
Assert.Empty(store.List(Game, null, null, null, 100).Items);
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback"), "*.json", SearchOption.AllDirectories));
}
[Fact]
public async Task Screenshots_are_checked_cleaned_and_bound_to_their_request()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var png = FeedbackTestImages.PngWith("tEXt", "Author\0Nicolas");
var sent = await client.SendFeedbackAsync(Bug(), new[] { png, FeedbackTestImages.Jpeg() });
Assert.Equal((2, (string?)null), (sent.AttachmentsSent, sent.AttachmentError));
Assert.NotNull(sent.Submitted.UploadToken);
// Fichiers privés, sans métadonnées (ni texte PNG, ni EXIF/position).
var files = Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback", Game), "*-*.*", SearchOption.AllDirectories);
Assert.Equal(2, files.Length);
Assert.All(files, f =>
{
var text = System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(f));
Assert.DoesNotContain("Nicolas", text);
Assert.DoesNotContain("GPS", text);
});
var upload = Assert.Single(Journal(api, "feedback.attachment").Where(e => e.Result == AuditResults.Ok).Take(1));
Assert.Null(upload.Ip);
// Jeton : seulement pour cette demande, le nombre annoncé, et une vraie image.
async Task<HttpStatusCode> PutAsync(int index, string? token, byte[] body)
{
using var request = new HttpRequestMessage(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index)) { Content = new ByteArrayContent(body) };
if (token is not null) request.Headers.Add(FeedbackUpload.TokenHeader, token);
using var response = await api.Http.SendAsync(request);
return response.StatusCode;
}
var token = sent.Submitted.UploadToken!;
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(3, token, png)); // au-delà du nombre annoncé
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, null, png)); // sans jeton
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, token[..^4] + "AAAA", png)); // jeton modifié
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(1, token, System.Text.Encoding.UTF8.GetBytes("<script>")));
api.Time.Advance(TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes + 1));
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, token, png)); // délai dépassé
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var item = Assert.Single((await owner.GetFeedbackPageAsync(Game)).Items);
Assert.Equal(2, item.AttachmentCount);
var detail = await owner.GetFeedbackAsync(Game, item.Id);
Assert.Equal(new[] { "png", "jpg" }, detail.Attachments.Select(a => a.Format).ToArray());
var image = await owner.GetFeedbackAttachmentAsync(Game, item.Id, 1);
Assert.Null(FeedbackImages.Sanitize(image, out _, out var clean));
Assert.Equal(clean, image); // déjà propre
await owner.DeleteFeedbackAsync(Game, item.Id);
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback", Game), "*", SearchOption.AllDirectories));
}
[Fact]
public async Task Refused_screenshot_does_not_cancel_the_request()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var sent = await client.SendFeedbackAsync(Bug(), new[] { System.Text.Encoding.UTF8.GetBytes("pas une image") });
Assert.Equal(0, sent.AttachmentsSent);
Assert.Contains("PNG ou JPEG", sent.AttachmentError);
Assert.True(FeedbackRules.IsValidReference(sent.Submitted.Reference));
}
[Fact]
public async Task Requests_with_links_are_flagged()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug() with { Fields = new(Bug().Fields) { ["actual"] = "Voir hxxps://cadeau[.]ru" } });
await client.SendFeedbackAsync(Bug(title: "Sans lien dans le texte"));
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var items = (await owner.GetFeedbackPageAsync(Game)).Items;
Assert.True(items.Single(i => i.Title != "Sans lien dans le texte").HasLinks);
Assert.False(items.Single(i => i.Title == "Sans lien dans le texte").HasLinks);
}
}
+212 -3
View File
@@ -7,6 +7,7 @@ using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
@@ -18,7 +19,8 @@ namespace UiSnapshot.Fakes;
/// <summary>
/// Launcher API simulée, EN MÉMOIRE (gestionnaire HTTP du client de l'API : aucune connexion réseau).
/// Elle répond avec des données de démonstration cohérentes (administrateurs, appareils, journal,
/// catalogue, notes de version, signatures) et accepte les modifications sans rien enregistrer.
/// catalogue, notes de version, signatures, demandes des joueurs et leurs captures d'écran) et accepte les
/// modifications sans rien enregistrer.
/// Signatures et jetons ne sont pas vérifiés : seul l'affichage compte ici.
/// Une route inconnue (module ajouté depuis) répond 404 et est signalée dans le rapport.
/// </summary>
@@ -163,6 +165,26 @@ internal sealed class FakeApi : HttpMessageHandler
return Json(_world.PrunePreview(gameId));
case ["v1", "games", var gameId, "objects", "prune"]:
return Json(new ObjectsPruneResult { GameId = gameId, Deleted = 412, FreedBytes = 1_873_000_000 });
// ---- Demandes des joueurs (lecture, traitement accepté sans rien enregistrer, captures d'écran)
case ["v1", "feedback", "overview"] when get:
return Json(_world.FeedbackCounts());
case ["v1", "games", var gameId, "feedback"] when get:
return Json(_world.FeedbackList(gameId, QueryValue(query, "kind"), QueryValue(query, "status")));
case ["v1", "games", var gameId, "feedback", var feedbackId] when get:
return _world.FindFeedback(gameId, feedbackId) is { } detail
? Json(detail)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Demande introuvable.");
case ["v1", "games", var gameId, "feedback", var feedbackId] when method == HttpMethod.Put:
return _world.FindFeedback(gameId, feedbackId) is { } current
? Json(SampleWorld.FeedbackUpdated(current, Read<FeedbackUpdateRequest>(body)))
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Demande introuvable.");
case ["v1", "games", _, "feedback", _] when method == HttpMethod.Delete:
return NoContent();
case ["v1", "games", var gameId, "feedback", var feedbackId, "attachments", var index] when get:
return _world.FeedbackScreenshot(gameId, feedbackId, index) is { } image
? Bytes(image)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Capture introuvable.");
}
var description = $"{method.Method} {route}";
@@ -198,6 +220,13 @@ internal sealed class FakeApi : HttpMessageHandler
return response;
}
private static HttpResponseMessage Bytes(byte[] content)
{
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue(FeedbackUpload.ContentType);
return response;
}
private static HttpResponseMessage NoContent() => new(HttpStatusCode.NoContent) { Content = new ByteArrayContent(Array.Empty<byte>()) };
private static HttpResponseMessage Error(HttpStatusCode status, string code, string message)
@@ -211,7 +240,8 @@ internal sealed class FakeApi : HttpMessageHandler
/// <summary>
/// Monde de démonstration du menu d'administration : quatre administrateurs (propriétaire connecté,
/// administrateur, éditeur en attente d'approbation, compte révoqué), leurs appareils, des invitations,
/// un journal d'audit varié, le catalogue, les notes de version et l'état des signatures.
/// un journal d'audit varié, le catalogue, les notes de version, l'état des signatures et des demandes de
/// joueurs (tous les types, avec liens et captures d'écran).
/// </summary>
internal sealed class SampleWorld
{
@@ -227,6 +257,8 @@ internal sealed class SampleWorld
private readonly byte[] _newDeviceKey;
private readonly ECDsa _root;
private readonly DateTimeOffset _now = DateTimeOffset.Now;
private readonly byte[][] _screenshots;
private List<FeedbackDetail>? _feedback;
/// <param name="ownerPublicKey">Clé publique (SPKI) de la clé « fichier » utilisée pour se connecter.</param>
/// <param name="root">Clé racine de démonstration (signe le trousseau ; sa clé publique est donnée au menu comme clé intégrée).</param>
@@ -239,6 +271,12 @@ internal sealed class SampleWorld
_adminKey = NewPublicKey();
_revokedKey = NewPublicKey();
_newDeviceKey = NewPublicKey();
// Captures dessinées ici, sur le fil de l'interface (WPF), puis nettoyées comme le ferait le serveur.
_screenshots = new[]
{
SampleData.CreateScreenshot(1280, 720, "Inventaire figé"),
SampleData.CreateScreenshot(800, 600, "Erreur : NullReferenceException")
}.Select(png => FeedbackImages.Sanitize(png, out _, out var clean) is null ? clean! : png).ToArray();
}
public IReadOnlyList<LauncherConfig> Games { get; }
@@ -548,7 +586,7 @@ internal sealed class SampleWorld
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
var entries = new List<AuditEntry>();
void Add(double hoursAgo, string action, string result, string? actor, string? target = null, string? details = null,
string ip = "203.0.113.24")
string? ip = "203.0.113.24")
{
entries.Add(new AuditEntry
{
@@ -567,6 +605,11 @@ internal sealed class SampleWorld
Add(0.05, "audit.export", AuditResults.Ok, OwnerName, "fichier CSV", "245 entrée(s) ; du 01/11/2026 au 07/11/2026 ; filtres : catégorie « Connexions »");
Add(0.08, "audit.view", AuditResults.Ok, OwnerName, details: "200 entrée(s) lue(s)");
Add(0.1, "auth.login", AuditResults.Ok, OwnerName, details: "GameLauncher (PC-BUREAU)");
Add(0.5, "feedback.view", AuditResults.Ok, OwnerName, $"{game} FB-7K2M-Q9XD", "fiche consultée");
Add(2, "feedback.attachment", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "capture n° 2 (png, 800 × 600)", ip: null);
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
@@ -579,6 +622,8 @@ internal sealed class SampleWorld
Add(72, "keyring.publish", AuditResults.Ok, OwnerName, "trousseau n° 3", "+1 clé (Camille — PC-STUDIO)");
Add(75, "admins.role", AuditResults.Ok, OwnerName, "Camille", "editor → admin");
Add(98, "admins.revoke", AuditResults.Ok, OwnerName, "Sam");
Add(96, "feedback.purge", AuditResults.Ok, null, game, "4 demande(s) supprimée(s) (conservation)", ip: null);
Add(96.1, "audit.purge", AuditResults.Ok, null, "audit-2025-10.jsonl", "mois au-delà de 12 mois supprimé", ip: null);
Add(99, "auth.login", AuditResults.Denied, "Sam", details: "clé révoquée", ip: "192.0.2.55");
Add(120, "admins.approve_key", AuditResults.Ok, OwnerName, "Camille", "PC-STUDIO");
Add(121, "changelog.edit", AuditResults.Failed, "Camille", $"{game} 1.4.1", "le manifest a changé entre-temps (409)");
@@ -672,4 +717,168 @@ internal sealed class SampleWorld
};
private static string Random(int bytes) => Base64Url.Encode(RandomNumberGenerator.GetBytes(bytes));
// ------------------------------------------------------------------ Demandes des joueurs
public FeedbackOverview FeedbackCounts() => new()
{
Enabled = true,
Games = Games.Select(g =>
{
var requests = FeedbackRequests().Where(r => SameGame(r, g.GameId)).ToList();
return new FeedbackGameCounts
{
GameId = g.GameId,
New = requests.Count(r => r.Summary.Status == FeedbackStatuses.New),
Open = requests.Count(r => !FeedbackStatuses.IsClosed(r.Summary.Status)),
Total = requests.Count
};
}).ToList(),
RetentionClosedMonths = 12,
RetentionMaxMonths = 24
};
/// <summary>Liste filtrée comme le serveur (« open », « closed », un statut, ou tout).</summary>
public FeedbackPage FeedbackList(string gameId, string? kind, string? status)
{
var items = FeedbackRequests()
.Where(r => SameGame(r, gameId))
.Where(r => string.IsNullOrEmpty(kind) || r.Summary.Kind == kind)
.Where(r => status switch
{
null or "" => true,
"open" => !FeedbackStatuses.IsClosed(r.Summary.Status),
"closed" => FeedbackStatuses.IsClosed(r.Summary.Status),
_ => r.Summary.Status == status
})
.OrderByDescending(r => r.Summary.ReceivedAt)
.Select(r => r.Summary)
.ToList();
return new FeedbackPage { Items = items, NextCursor = null, Total = items.Count };
}
public FeedbackDetail? FindFeedback(string gameId, string id) =>
FeedbackRequests().FirstOrDefault(r => SameGame(r, gameId) && r.Summary.Id == id);
public byte[]? FeedbackScreenshot(string gameId, string id, string index) =>
FindFeedback(gameId, id) is { } detail && int.TryParse(index, out var n) && detail.Attachments.Any(a => a.Index == n)
? _screenshots[(n - 1) % _screenshots.Length]
: null;
/// <summary>Traitement accepté (statut, note), rien n'est enregistré.</summary>
public static FeedbackDetail FeedbackUpdated(FeedbackDetail current, FeedbackUpdateRequest request) => current with
{
Summary = current.Summary with
{
Status = request.Status,
StatusChangedAt = DateTimeOffset.Now,
HasNote = !string.IsNullOrEmpty(request.AdminNote),
Revision = current.Summary.Revision + 1
},
AdminNote = request.AdminNote,
StatusChangedBy = OwnerName
};
private static bool SameGame(FeedbackDetail request, string gameId) =>
string.Equals(request.Summary.GameId, gameId, StringComparison.OrdinalIgnoreCase);
private List<FeedbackDetail> FeedbackRequests() => _feedback ??= CreateFeedback();
/// <summary>
/// Demandes de démonstration : tous les types et statuts, un titre très long, des liens (déguisé, « www »), des
/// captures d'écran, des informations techniques et une note interne.
/// </summary>
private List<FeedbackDetail> CreateFeedback()
{
var list = new List<FeedbackDetail>();
var tech = new FeedbackTechInfo { LauncherVersion = "3.0.0", GameVersion = "1.4.2", Os = "Windows 11 64 bits (10.0.26100)", Language = "fr-FR" };
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
void Add(string gameId, string kind, string title, string status, double hoursAgo, Dictionary<string, string> fields,
bool withTech = false, int attachments = 0, string? note = null, string? reference = null)
{
var seed = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"feedback {gameId} {list.Count}"));
reference ??= "FB-" + new string(seed.Take(4).Select(b => FeedbackRules.ReferenceAlphabet[b % 32]).ToArray()) + "-" +
new string(seed.Skip(4).Take(4).Select(b => FeedbackRules.ReferenceAlphabet[b % 32]).ToArray());
var closed = FeedbackStatuses.IsClosed(status) || status == FeedbackStatuses.InProgress;
var badge = kind switch
{
FeedbackKinds.Review when fields.TryGetValue("rating", out var rating) => $"{rating}/5",
FeedbackKinds.Bug when fields.TryGetValue("severity", out var severity) =>
FeedbackForms.Find(FeedbackKinds.Bug)?.Field("severity")?.DisplayValue(severity),
_ => null
};
var infos = Enumerable.Range(1, attachments).Select(n => // numérotées à partir de 1, comme sur le serveur
{
var image = _screenshots[(n - 1) % _screenshots.Length];
FeedbackImages.Sanitize(image, out var info, out _);
return new FeedbackAttachmentInfo
{
Index = n, Format = info?.Format ?? "png", Width = info?.Width ?? 0, Height = info?.Height ?? 0, Bytes = image.Length
};
}).ToList();
list.Add(new FeedbackDetail
{
Summary = new FeedbackSummary
{
Id = SampleData.Hex(seed)[..32],
Reference = reference,
GameId = gameId,
Kind = kind,
Title = title,
Status = status,
ReceivedAt = _now.AddHours(-hoursAgo),
StatusChangedAt = closed ? _now.AddHours(-hoursAgo / 2) : null,
Badge = badge,
HasNote = note is not null,
HasLinks = FeedbackLinks.Contains(title + "\n" + string.Join("\n", fields.Values)),
AttachmentCount = attachments,
Revision = closed ? 2 : 1
},
FormVersion = FeedbackForms.CurrentVersion,
Fields = fields,
Tech = withTech ? tech : null,
Attachments = infos,
AdminNote = note,
StatusChangedBy = closed ? "Camille" : null
});
}
Add(game, FeedbackKinds.Bug, "Le jeu se fige à l'ouverture de l'inventaire", FeedbackStatuses.New, 2, new()
{
["category"] = "gameplay", ["severity"] = "blocking", ["frequency"] = "often",
["steps"] = "1. Je lance une partie dans la cour de l'école.\n2. Je ramasse la clé du gymnase.\n3. J'ouvre l'inventaire (touche I).",
["expected"] = "L'inventaire s'ouvre et la partie continue.",
["actual"] = "L'image se fige, le son continue, il faut fermer le jeu.\n" +
"Un joueur sur Discord conseille d'installer un correctif : hxxps://patch-exovival[.]ru/fix.exe"
}, withTech: true, attachments: 2, reference: "FB-7K2M-Q9XD");
Add(game, FeedbackKinds.Review, "Une ambiance vraiment réussie", FeedbackStatuses.New, 5, new()
{
["rating"] = "4", ["topic"] = "overall",
["liked"] = "L'ambiance sonore et la lumière dans les couloirs de l'école. On sent vraiment la tension.",
["improve"] = "Les munitions sont un peu trop rares au début.", ["recommend"] = "yes"
});
Add(game, FeedbackKinds.Feature, "Pouvoir réassigner les touches du clavier", FeedbackStatuses.InProgress, 30, new()
{
["area"] = "accessibility", ["idea"] = "Un écran pour changer les touches (je joue en AZERTY et avec une seule main).",
["problem"] = "Certaines actions sont impossibles à faire rapidement.", ["importance"] = "important"
}, note: "Prévu pour la 1.6 (menu Options).", reference: "FB-3PQR-8WXT");
Add(game, FeedbackKinds.Other, "Un serveur Discord officiel ?", FeedbackStatuses.New, 50, new()
{
["message"] = "Est-ce qu'il y aura un serveur Discord officiel ? J'ai vu www.exovival-fans.com mais je ne sais pas si c'est vous."
});
Add(game, FeedbackKinds.Review, "Trop difficile dès le premier niveau, impossible de traverser la cour de l'école sans se faire rattraper",
FeedbackStatuses.New, 70, new() { ["rating"] = "2", ["topic"] = "gameplay", ["improve"] = "Un mode facile serait le bienvenu." });
Add(game, FeedbackKinds.Bug, "Le son grésille dans le gymnase", FeedbackStatuses.Done, 200, new()
{
["category"] = "sound", ["severity"] = "annoying", ["frequency"] = "always",
["steps"] = "Entrer dans le gymnase.", ["actual"] = "Grésillement continu."
}, withTech: true, note: "Corrigé en 1.4.2.");
foreach (var other in Games.Skip(1))
Add(other.GameId, FeedbackKinds.Feature, "Un mode coopératif", FeedbackStatuses.New, 12, new()
{
["area"] = "content", ["idea"] = "Jouer à deux en écran partagé.", ["importance"] = "useful"
});
return list;
}
}
+44
View File
@@ -1,7 +1,11 @@
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
@@ -253,6 +257,46 @@ internal static class SampleData
public static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
// ------------------------------------------------------------------ Captures d'écran (demandes des joueurs)
/// <summary>
/// Fausse capture d'écran du jeu (PNG dessiné par WPF : ciel, bâtiments, encart de texte), pour la fenêtre « Aide »
/// et la page « Demandes des joueurs ». À créer sur le fil de l'interface (WPF).
/// </summary>
public static byte[] CreateScreenshot(int width, int height, string caption)
{
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
var sky = new LinearGradientBrush(Color.FromRgb(0x1B, 0x26, 0x3B), Color.FromRgb(0x5B, 0x6E, 0x8C), 90);
dc.DrawRectangle(sky, null, new Rect(0, 0, width, height));
var ground = height * 0.7;
var random = new Random(width * 31 + height);
var building = new SolidColorBrush(Color.FromRgb(0x14, 0x1C, 0x2B));
var window = new SolidColorBrush(Color.FromRgb(0xE8, 0xB0, 0x4A));
for (var x = -20.0; x < width; x += width / 14.0)
{
var h = height * (0.15 + random.NextDouble() * 0.3);
dc.DrawRectangle(building, null, new Rect(x, ground - h, width / 16.0, h));
if (random.Next(3) == 0) dc.DrawRectangle(window, null, new Rect(x + width / 64.0, ground - h * 0.7, width / 80.0, height / 40.0));
}
dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(0x2D, 0x3A, 0x2E)), null, new Rect(0, ground, width, height - ground));
var text = new FormattedText(caption, CultureInfo.GetCultureInfo("fr-FR"), FlowDirection.LeftToRight,
new Typeface("Segoe UI"), Math.Max(12, height / 22.0), Brushes.White, 1.0);
var box = new Rect(height / 20.0, height / 20.0, text.Width + height / 10.0, text.Height + height / 20.0);
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xB0, 0, 0, 0)), null, box, 8, 8);
dc.DrawText(text, new Point(box.X + height / 20.0, box.Y + height / 40.0));
}
var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var encoder = new PngBitmapEncoder { Interlace = PngInterlaceOption.Off };
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = new MemoryStream();
encoder.Save(stream);
return stream.ToArray();
}
/// <summary>Empreinte SHA-256 stable d'un texte (condensés et empreintes de démonstration).</summary>
public static string Digest(string seed) => Hex(SHA256.HashData(Encoding.UTF8.GetBytes(seed)));
}
+16 -2
View File
@@ -37,10 +37,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
bug rempli avec une capture d'écran (formulaire qui défile : pages `.p2.png`…), champs obligatoires
signalés, demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
démonstration (ni fichier choisi, ni presse-papiers).
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), et une
version prête à publier.
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), une
version prête à publier, et la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
lien signalé, captures d'écran).
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
@@ -57,6 +62,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
(`%USERPROFILE%\.launcher-keys`) n'est lue.
* Projet séparé, jamais distribué aux joueurs : le launcher ne le référence pas.
## Tenir l'outil à jour
L'outil sert au débogage : **chaque nouvelle fenêtre ou nouveau menu doit y être ajouté** dans la même
modification. Une page du menu d'administration est capturée automatiquement, mais ses routes de l'API sont
à simuler dans `Fakes\FakeApi.cs` (sinon le rapport les signale) et ses états intéressants (fiche ouverte,
élément choisi…) à ajouter dans `Scenes\AdminScenes.cs`. Une fenêtre du joueur s'ajoute dans
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`) déclaré dans
`SnapshotRunner.cs`.
## Limites
* Les états de survol, d'appui et les menus ou listes déroulantes ouverts (fenêtres surgissantes)
+30 -1
View File
@@ -11,6 +11,7 @@ using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
@@ -44,6 +45,7 @@ internal sealed class AdminScenes : ISceneSet
("admin-enroll-done", "Administration · clé créée, empreinte à communiquer"),
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
(PagePrefix + "feedback-requests-detail", "Administration · demande d'un joueur (lien signalé, captures d'écran)"),
("admin-login-locked", "Administration · session verrouillée")
};
@@ -210,6 +212,33 @@ internal sealed class AdminScenes : ISceneSet
await ShootAllThemesAsync(publishId, "Administration · version prête à publier");
}
// ---- Demandes des joueurs : fiche d'une demande avec un lien (avertissement) et des captures d'écran
var feedbackId = PagePrefix + "feedback-requests-detail";
if (capture.WantsAny(capture.Themes.Select(t => feedbackId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == FeedbackPanelViewModel.PanelId) is { } feedbackItem)
{
workspace.SelectedPanel = feedbackItem;
await WaitForPanelAsync(capture, feedbackItem, api);
if (feedbackItem.ViewModel is FeedbackPanelViewModel feedback)
{
if (feedback.SelectedGame is null && MainChoice(feedback.GameChoices) is { } choice)
{
feedback.SelectedGame = choice; // plusieurs jeux : choisi comme le ferait l'administrateur
await WaitForPanelAsync(capture, feedbackItem, api);
}
if ((feedback.Items.FirstOrDefault(i => i.HasLinks && i.HasAttachments) ?? feedback.Items.FirstOrDefault()) is { } request)
{
feedback.SelectedItem = request; // fiche, puis captures chargées et revérifiées une à une
await WaitForPanelAsync(capture, feedbackItem, api);
}
else
{
capture.GlobalNotes.Add("⚠ Demandes des joueurs : aucune demande à afficher (voir SampleWorld.CreateFeedback).");
}
}
await ShootAllThemesAsync(feedbackId, "Administration · demande d'un joueur (lien signalé, captures d'écran)");
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
@@ -254,7 +283,7 @@ internal sealed class AdminScenes : ISceneSet
return new ModuleCatalog(new ILauncherModule[]
{
new AdminModule(), new CatalogAdminModule(), new ChangelogAdminModule(), new PublishAdminModule(),
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule()
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule(), new FeedbackAdminModule()
});
}
+139
View File
@@ -0,0 +1,139 @@
using System.Windows;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Feedback;
using GameLauncher.Views;
using UiSnapshot.Fakes;
namespace UiSnapshot.Scenes;
/// <summary>
/// Fenêtre « Aide » du joueur (bouton de la barre de titre) : choix du type, formulaires (avis noté, bug rempli
/// avec une capture d'écran, champs obligatoires signalés), demande envoyée. Vrai ViewModel et vraie fenêtre ;
/// l'envoi est simulé (aucun réseau) et la capture d'écran est une image de démonstration (ni fichier choisi,
/// ni presse-papiers). Un formulaire plus haut que la fenêtre donne des pages supplémentaires (.p2.png…).
/// </summary>
internal sealed class FeedbackScenes : ISceneSet
{
/// <summary>Référence remise par l'envoi simulé (alphabet des références : ni I, L, O, U).</summary>
public const string SampleReference = "FB-7K2M-Q9XD";
private readonly Fixture _fixture;
public FeedbackScenes(Fixture fixture) => _fixture = fixture;
private static readonly (string Id, string Title, Func<FeedbackFormViewModel, Task> Apply)[] SceneList =
{
("feedback-kind", "Aide · choix du type de demande", _ => Task.CompletedTask),
("feedback-review", "Aide · avis noté", form =>
{
Choose(form, FeedbackKinds.Review);
form.Title = "Une ambiance vraiment réussie";
Field(form, "rating").Rating = 4;
Option(form, "topic", "overall");
Field(form, "liked").Text = "L'ambiance sonore et la lumière dans les couloirs de l'école.";
Option(form, "recommend", "yes");
return Task.CompletedTask;
}),
("feedback-bug", "Aide · bug rempli, avec capture d'écran (formulaire qui défile)", form =>
{
FillBug(form);
form.AddFileCommand.Execute(null);
return Task.CompletedTask;
}),
("feedback-bug-errors", "Aide · bug, champs obligatoires signalés", async form =>
{
Choose(form, FeedbackKinds.Bug);
form.Title = "Bug";
await form.SendCommand.ExecuteAsync(); // contrôle local : rien n'est envoyé
}),
("feedback-done", "Aide · demande envoyée (une capture refusée par le serveur)", async form =>
{
Choose(form, FeedbackKinds.Other);
form.Title = "Un serveur Discord officiel ?";
Field(form, "message").Text = "Est-ce qu'il y aura un serveur Discord officiel ?";
form.AddFileCommand.Execute(null);
form.PasteCommand.Execute(null);
await form.SendCommand.ExecuteAsync();
})
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes) =>
themes.SelectMany(theme => SceneList.Select(s => new SceneInfo(s.Id + Capture.Suffix(theme), s.Title)));
public async Task RunAsync(Capture capture)
{
foreach (var theme in capture.Themes)
{
foreach (var (id, title, apply) in SceneList)
{
await capture.ShootAsync(id + Capture.Suffix(theme), title, async () =>
{
_fixture.ApplyTheme(theme);
var form = NewForm();
await apply(form);
if (form.HasError && id != "feedback-bug-errors") capture.Problem($"Formulaire « Aide » : {form.ErrorMessage}");
if (form.HasAttachmentError) capture.Problem($"Capture d'écran refusée : {form.AttachmentError}");
return new FeedbackWindow(form); // la fenêtre libère le ViewModel à sa fermeture
}, Capture.CloseAsync);
}
}
}
private FeedbackFormViewModel NewForm()
{
var game = _fixture.MainGame;
var target = new FeedbackTarget(game.GameId, game.ShownName, "1.4.2", _fixture.LauncherVersion);
// Informations techniques fixes (aucune lecture de la machine : captures identiques d'un PC à l'autre).
var tech = new FeedbackTechInfo
{
LauncherVersion = FeedbackRules.SafeVersion(_fixture.LauncherVersion),
GameVersion = "1.4.2",
Os = "Windows 11 64 bits (10.0.26100)",
Language = "fr-FR"
};
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots());
}
private static void FillBug(FeedbackFormViewModel form)
{
Choose(form, FeedbackKinds.Bug);
form.Title = "Le jeu se fige à l'ouverture de l'inventaire";
Option(form, "category", "gameplay");
Option(form, "severity", "blocking");
Option(form, "frequency", "often");
Field(form, "steps").Text = "1. Je lance une partie dans la cour de l'école.\n2. Je ramasse la clé du gymnase.\n" +
"3. J'ouvre l'inventaire (touche I).";
Field(form, "expected").Text = "L'inventaire s'ouvre et la partie continue.";
Field(form, "actual").Text = "L'image se fige, le son continue, il faut fermer le jeu avec le gestionnaire des tâches.";
}
private static void Choose(FeedbackFormViewModel form, string kind) =>
form.Kinds.Single(k => k.Form.Kind == kind).SelectCommand.Execute(null);
private static FeedbackFieldViewModel Field(FeedbackFormViewModel form, string id) => form.Fields.Single(f => f.Id == id);
private static void Option(FeedbackFormViewModel form, string fieldId, string optionId)
{
var field = Field(form, fieldId);
field.SelectedOption = field.Options.Single(o => o.Id == optionId);
}
/// <summary>Envoi simulé : référence fixe ; sur deux captures, la seconde est « refusée » (avertissement affiché).</summary>
private sealed class SampleSender : IFeedbackSender
{
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct) =>
Task.FromResult(attachments.Count > 1
? new FeedbackSent(SampleReference, attachments.Count - 1, "Dimensions refusées : 16 à 4096 px de côté.")
: new FeedbackSent(SampleReference, attachments.Count, null));
}
/// <summary>« Choisir une image » ou « Coller » : image de démonstration (aucun fichier, presse-papiers intact).</summary>
private sealed class SampleScreenshots : IScreenshotSource
{
public ScreenshotResult? PickFile() =>
new(SampleData.CreateScreenshot(1280, 720, "Inventaire figé"), "capture-inventaire.png", null);
public ScreenshotResult? FromClipboard() =>
new(SampleData.CreateScreenshot(800, 600, "Erreur : NullReferenceException"), "Image collée", null);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ internal sealed class SnapshotRunner
var themes = ResolveThemes();
var repository = Fixture.FindRepositoryRoot();
using var fixture = new Fixture(repository);
var sets = new ISceneSet[] { new PlayerScenes(fixture), new AdminScenes(fixture) };
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AdminScenes(fixture) };
if (_options.List)
{
+2 -2
View File
@@ -2,8 +2,8 @@
<!--
Outil de développement « UiSnapshot » : rend chaque écran du launcher en PNG (fenêtre principale dans
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, menu d'administration et
chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtre « Aide », menu
d'administration et chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
les problèmes de mise en page (texte coupé, débordement, erreurs de liaison…).
Lancement : tools\ui-snapshot.bat (voir LISEZMOI.md).
Jamais distribué aux joueurs : projet séparé, que le launcher ne référence pas.
+86 -99
View File
@@ -1,112 +1,99 @@
# Launcher 3.0.0
# Launcher 3.1.0
LAUNCHER - NOTES DE MISE À JOUR
Bouton « Aide » et demandes des joueurs - Mise à jour du 9 novembre 2026
Captures d'écran et liens signalés - Mise à jour du 9 novembre 2026
==================================================================
Un nouveau bouton « Aide » permet d'envoyer à l'équipe, sans compte, un avis, une demande d'évolution, un bug ou autre chose, pour le jeu sélectionné. Chaque type de demande a ses propres champs, et une référence est remise à l'envoi.
Depuis le bouton « Aide », vous pouvez maintenant joindre jusqu'à 3 captures d'écran à votre demande. Chaque image est contrôlée et débarrassée de ses métadonnées (ni position GPS, ni appareil, ni date) avant l'envoi.
Dans le menu d'administration, la nouvelle page « Demandes des joueurs » range les demandes par jeu. Les demandes sont anonymes : aucun compte, aucune adresse IP enregistrée. Le serveur est à redéployer et à configurer avant de publier cette version.
Dans le menu d'administration, une demande contenant un lien est signalée par un avertissement, et le journal d'audit affiche des libellés lisibles. La fenêtre « Aide » reste centrée et défile avec un ascenseur. Le serveur est à redéployer, avec un bloc nginx à ajouter, avant de publier cette version.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : bouton « Aide » dans la barre de titre : avis, demande
d'évolution, bug ou autre, pour le jeu sélectionné.
- NOUVEAU : formulaires à champs prédéfinis selon le type, contrôlés
avant l'envoi ; référence FB-XXXX-XXXX remise au joueur.
- NOUVEAU : page d'administration « Demandes des joueurs » et carte
sur le tableau de bord.
- NOUVEAU : purge automatique des demandes et du journal d'audit
(12 mois).
- SÉCURITÉ : défi anti-spam, limites par adresse, plafonds, contrôle
complet du contenu ; demandes anonymes (aucune adresse IP
enregistrée).
- SERVEUR : à redéployer et à configurer (réception désactivée par
défaut).
- QUALITÉ : 30 nouveaux tests automatisés (312 au total).
- NOUVEAU : captures d'écran jointes à une demande (3 au plus,
fichier ou Ctrl+V) : vraie image exigée, PNG ou JPEG seulement,
sans métadonnées.
- NOUVEAU : captures affichées dans la fiche d'une demande
(administration), revérifiées avant l'affichage.
- NOUVEAU : avertissement quand une demande contient un lien ; liens
désamorcés, jamais cliquables.
- AMÉLIORÉ : journal d'audit avec des libellés lisibles (« Demande de
joueur reçue »…) et une catégorie « Demandes des joueurs ».
- AMÉLIORÉ : fenêtre « Aide » centrée, formulaire long avec un
ascenseur.
- SÉCURITÉ : image contrôlée par le launcher et par le serveur ;
envoi autorisé par un jeton propre à la demande.
- SERVEUR : à redéployer, avec un nouveau bloc nginx.
- QUALITÉ : 30 nouveaux tests automatisés (342 au total).
------------------------------------------------------------------
BOUTON « AIDE »
CAPTURES D'ÉCRAN
------------------------------------------------------------------
- Bulle avec un point d'interrogation, dans la barre de titre, à
gauche du bouton « Site officiel ».
- Le même bouton pour tous les jeux : la demande est rattachée au
jeu sélectionné dans la liste de gauche, affiché en en-tête et non
modifiable pendant la saisie.
- Trois étapes : choix du type, formulaire, référence à copier.
- Champs prédéfinis (* obligatoire) :
* Avis : note de 1 à 5 étoiles*, sujet*, ce que vous aimez, ce qui
pourrait être amélioré, recommanderiez-vous le jeu ? ;
* Demande d'évolution : domaine*, description de l'idée*, problème
résolu, importance ;
* Bug : catégorie*, gravité*, fréquence*, étapes pour reproduire*,
résultat attendu, résultat obtenu* ;
* Autre : message*.
- Informations techniques facultatives (versions du launcher et du
jeu, Windows, langue) : contenu exact affiché avant l'envoi, case
cochée par défaut pour un bug seulement.
- Saisie conservée en cas d'échec (réseau, serveur occupé) : il
suffit de cliquer de nouveau sur « Envoyer ».
- Échap ne ferme pas la fenêtre pendant la saisie.
- Le launcher ne contacte le serveur qu'au moment de l'envoi.
- Pas de réponse possible pour l'instant : un futur module « comptes
joueurs » permettra d'échanger avec l'équipe.
- Nouvelle section « Captures d'écran (facultatif) » dans le
formulaire du bouton « Aide », pour tous les types de demande.
- « Ajouter une image… » : PNG, JPEG, BMP, GIF ou WebP (30 Mo au
plus).
- « Coller (Ctrl+V) » : image du presse-papiers, après Impr. écran ou
Win+Maj+S ; Ctrl+V fonctionne aussi depuis un champ de texte.
- Miniature de chaque image, avec une croix pour la retirer avant
l'envoi.
- Image reconnue à son contenu (un fichier renommé est refusé),
réduite à 3 840 px au plus et réencodée en PNG ou JPEG sans
métadonnées : ni position GPS, ni appareil, ni date.
- Le nom du fichier n'est jamais envoyé.
- Si une capture ne peut pas être envoyée, un avertissement le
précise : la demande, elle, est bien enregistrée.
------------------------------------------------------------------
PAGE « DEMANDES DES JOUEURS » (ADMINISTRATION)
------------------------------------------------------------------
- Choix du jeu : une tuile par jeu avec le nombre de nouvelles
demandes ; les demandes d'un jeu ne se mélangent jamais à celles
d'un autre.
- Filtres : type, statut (« À traiter » par défaut), recherche dans
le titre ou la référence ; 100 demandes par page.
- Fiche : tous les champs en texte brut, informations techniques,
référence copiable.
- Traitement : statut (Nouvelle, En cours, Traitée, Rejetée,
Doublon), note interne visible des seuls administrateurs,
suppression après confirmation.
- Tableau de bord : carte « Demandes des joueurs » (nouvelles
demandes par jeu).
- Permissions : feedback.read (lecture) et feedback.manage
(traitement) ; Propriétaire et Administrateur, pas l'Éditeur.
- Serveur déjà installé : seul le groupe Propriétaire reçoit ces
permissions d'office ; les ajouter au groupe « Administrateur »
depuis la page « Groupes et permissions ».
- Liste : icône « image » pour une demande avec des captures, icône
« alerte » pour une demande contenant un lien.
- Fiche : captures d'écran en miniature, « Agrandir » / « Réduire » ;
chaque image est revérifiée avant l'affichage et rien n'est écrit
sur le PC.
- Liens : bandeau d'avertissement en haut de la fiche, liens
désamorcés (hxxps[://]site[.]com) et champs concernés surlignés ;
jamais cliquables.
- Formes reconnues : http, https, ftp, file, protocoles dangereux
(javascript:, data:…), www, domaines nus (site.ru, discord.gg/…),
adresses IP, chemins réseau, e-mails, formes déguisées (hxxp, [.],
(dot), caractères pleine chasse).
------------------------------------------------------------------
CONSERVATION ET PURGE AUTOMATIQUE
JOURNAL D'AUDIT ET FENÊTRE « AIDE »
------------------------------------------------------------------
- Au démarrage du serveur puis toutes les 24 heures.
- Demande close (traitée, rejetée, doublon) : supprimée 12 mois
après sa clôture ; toute demande : 24 mois au plus.
- Journal d'audit : fichiers de plus de 12 mois supprimés (réglage
Audit:RetentionMonths) ; corrige l'écart RGPD relevé.
- Libellés lisibles : Demande de joueur reçue, consultée, traitée,
supprimée ; Capture d'écran reçue ; Anciennes demandes supprimées ;
Anciens mois du journal supprimés ; Trousseau de signature publié ;
Version en ligne signée.
- Nouvelles catégories : « Demandes des joueurs » et « Signature
(trousseau) ».
- Fenêtre « Aide » centrée à chaque étape ; formulaire limité à une
hauteur de base, avec un ascenseur au-delà.
- La molette fait défiler le formulaire et ne change jamais une liste
déroulante fermée.
------------------------------------------------------------------
SÉCURITÉ ET VIE PRIVÉE
------------------------------------------------------------------
- Première route d'envoi ouverte à tous, désactivée par défaut ; le
serveur contrôle tout, avec les mêmes règles que le launcher.
- Défi anti-spam (calcul de quelques fractions de seconde), sans
service tiers ni captcha, à usage unique.
- Limites : 3 demandes par 10 minutes et 10 par jour par adresse IP,
comptées en mémoire seulement ; 300 demandes par jour et par jeu,
5 000 par jeu, 100 Mo au total.
- Caractères invisibles ou spéciaux refusés ; affichage en texte
brut dans le menu.
- Aucune adresse IP enregistrée avec une demande ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux ;
aucun identifiant du PC envoyé.
- Consultations, changements de statut et suppressions inscrits au
journal d'audit, sans le contenu des demandes ; statut, note et
suppression en requêtes signées.
- Stockage : un fichier par demande dans les données privées du
serveur (SQLite évalué et écarté pour ce module).
- Le serveur n'accepte que de vrais PNG ou JPEG : structure complète
vérifiée, rien après la fin de l'image, 16 à 4 096 px, 4 Mo au plus.
- Métadonnées retirées par le launcher puis par le serveur.
- Jeton d'envoi propre à la demande, valable 15 minutes, pour le
nombre de captures annoncé seulement.
- Plafond global porté à 1 Go (captures comprises).
- Capture affichée dans le menu seulement après un nouveau contrôle,
jamais écrite sur le disque.
- Aucune adresse IP enregistrée avec une capture ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
- Captures supprimées avec leur demande (suppression ou purge).
- Statut, note et suppression restent des requêtes signées.
------------------------------------------------------------------
@@ -114,28 +101,28 @@ SERVEUR
------------------------------------------------------------------
- À redéployer AVANT de publier le launcher : server/deploy/
LISEZMOI.md, étape 8, puis étape 3 octies.
- Activer la réception : "Feedback": { "Enabled": true,
"Games": [ "EZR" ] } dans appsettings.Production.json.
- nginx : zone feedback_par_ip et deux blocs
« location = /api/v1/feedback… » (nginx-api.conf).
- La purge du journal d'audit s'applique dès le premier démarrage.
- Sans redéploiement, le bouton « Aide » indique que l'envoi des
demandes n'est pas disponible.
- nginx : ajouter le troisième bloc
« location ^~ /api/v1/feedback/attachments/ » (nginx-api.conf).
- Si Feedback:MaxTotalMegabytes est fixé dans
appsettings.Production.json, le porter à 1024.
- Sans redéploiement, les demandes arrivent, mais sans leurs
captures (le joueur est prévenu).
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Nouveau projet GameLauncher.Modules.Feedback (formulaire du joueur,
sans WPF) ; fenêtre Views/FeedbackWindow.xaml.
- Formulaires décrits une seule fois (Contracts/Feedback) : le
launcher les génère, le serveur valide avec, le menu affiche les
libellés.
- Serveur : module Feedback, FeedbackGuard (défi, limites),
FeedbackStore (stockage), RetentionService (purges).
- Menu d'administration : FeedbackAdminModule (page « Demandes des
joueurs »).
- Tests : FeedbackRulesTests.cs, FeedbackModuleTests.cs,
- Contrats : FeedbackLinks (détection des liens), FeedbackImages
(contrôle des PNG/JPEG et retrait des métadonnées, sans
bibliothèque).
- Serveur : envoi et lecture des captures (FeedbackModule), jetons
d'envoi (FeedbackGuard), stockage à côté des demandes
(FeedbackStore).
- Launcher : ScreenshotSource (fichier ou presse-papiers, réencodage
sans métadonnées).
- Outil ui-snapshot : fenêtre « Aide » et fiche d'une demande
capturées ; section « Tenir l'outil à jour » dans son LISEZMOI.
- Tests : FeedbackContentTests.cs (nouveau), FeedbackModuleTests.cs,
FeedbackPanelTests.cs.
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
(§ 11 quinquies) et server/deploy/LISEZMOI.md mis à jour.