Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96edfce4f | ||
|
|
11721e9260 | ||
|
|
5d9f413f57 | ||
|
|
cde3b337e7 | ||
|
|
1c198f1204 | ||
|
|
2d26416a7a | ||
|
|
24de56cdcb | ||
|
|
8a7e51fa07 | ||
|
|
e18ebf818f | ||
|
|
821d31ce30 | ||
|
|
db4c874e13 | ||
|
|
64fb944038 | ||
|
|
bc3bf4fbba |
No files matched your search
@@ -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é,
|
||||
|
||||
@@ -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 : « <id>.<secret 256 bits> ». 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><id>-<n>.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)
|
||||
{
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"RequestsPerMinute": 10,
|
||||
"MaxPerGamePerDay": 300,
|
||||
"MaxPerGame": 5000,
|
||||
"MaxTotalMegabytes": 100,
|
||||
"MaxTotalMegabytes": 1024,
|
||||
"MinFreeDiskMegabytes": 500,
|
||||
"RetentionClosedMonths": 12,
|
||||
"RetentionMaxMonths": 24
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user