/
kucheruk
/
comparator
Обзор
Документация
Войти
/
kucheruk
/
comparator
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
Program.cs
169 строк
7 KB
kucheruk
Публичные голосования: открытые результаты, авто-открытие после голоса, общий список
01 июл 2026, 20:47
01 июл 2026, 20:47
ecee184
Код
Авторство
О чём код?
using System.Security.Cryptography; using System.Text.Json; using Comparator; using Microsoft.AspNetCore.HttpOverrides; using QRCoder; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<Store>(); builder.Services.AddSingleton<Presence>(); builder.Services.AddSingleton<Broadcaster>(); builder.Services.AddSignalR() .AddJsonProtocol(o => o.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); var app = builder.Build(); // Behind Traefik + an external TLS proxy — trust forwarded scheme/host. var fwd = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }; fwd.KnownIPNetworks.Clear(); fwd.KnownProxies.Clear(); app.UseForwardedHeaders(fwd); // Sub-path deployment (e.g. /comparator). Everything downstream sees the stripped path. var appBase = (Environment.GetEnvironmentVariable("APP_BASE") ?? "").TrimEnd('/'); if (appBase.Length > 0) app.UsePathBase(appBase); // Explicit UseRouting AFTER UsePathBase: WebApplication otherwise auto-inserts // routing at the very start of the pipeline (before UsePathBase), so endpoints // would match the un-stripped "/comparator/..." path and always hit the fallback. app.UseStaticFiles(); // serves wwwroot (vendored signalr client at /lib/signalr.min.js) app.UseRouting(); // The single-page app, with the deploy base path injected so the client can // build API/hub URLs and share links correctly under any prefix. var htmlPath = Path.Combine(app.Environment.ContentRootPath, "web", "index.html"); var htmlTemplate = File.ReadAllText(htmlPath).Replace("%APP_BASE%", appBase); IResult Page() => Results.Content(htmlTemplate, "text/html; charset=utf-8"); // ---------------- API ---------------- app.MapGet("/api/health", () => Results.Ok(new { ok = true })); app.MapPost("/api/polls", async (CreatePollReq req, Store store) => { var items = (req.Items ?? new List<string>()) .Select(s => (s ?? "").Replace("\r", " ").Replace("\n", " ").Trim()) .Where(s => s.Length > 0) .Select(s => s.Length > 500 ? s[..500] : s) .ToList(); if (items.Count < 2) return Results.BadRequest(new { error = "нужно минимум 2 элемента" }); if (items.Count > 200) return Results.BadRequest(new { error = "максимум 200 элементов" }); var title = string.IsNullOrWhiteSpace(req.Title) ? null : req.Title.Trim(); if (title is { Length: > 200 }) title = title[..200]; string id = await NewUniqueIdAsync(store); string ownerToken = Rand(24); await store.CreatePollAsync(id, title, ownerToken, items, req.IsPublic, NowIso()); return Results.Ok(new CreatePollResp(id, ownerToken, items.Count)); }); app.MapGet("/api/polls/{id}", async (string id, Store store, Presence presence) => { var poll = await store.GetPollAsync(id); if (poll is null) return Results.NotFound(new { error = "голосование не найдено" }); var items = await store.GetItemsAsync(id); var (participants, finished, totalVotes) = await store.GetCountsAsync(id); var stats = new StatsDto(presence.Online(id), participants, finished, totalVotes); return Results.Ok(new PollResp(poll.Id, poll.Title, items, poll.ItemCount, poll.IsPublic, stats)); }); app.MapGet("/api/public", async (Store store) => { var rows = await store.ListPublicAsync(); return Results.Ok(rows.Select(r => new PublicPollDto(r.Id, r.Title, r.ItemCount, r.Participants, r.Finished, r.CreatedAt))); }); app.MapPost("/api/polls/{id}/votes", async (string id, VoteReq req, Store store, Broadcaster bc) => { if (string.IsNullOrWhiteSpace(req.VoterId)) return Results.BadRequest(new { error = "нет voterId" }); var poll = await store.GetPollAsync(id); if (poll is null) return Results.NotFound(new { error = "голосование не найдено" }); int n = poll.ItemCount; if (req.A < 0 || req.A >= n || req.B < 0 || req.B >= n || req.A == req.B) return Results.BadRequest(new { error = "некорректная пара" }); int a = Math.Min(req.A, req.B), b = Math.Max(req.A, req.B); int? winner = req.Winner; if (winner is not null && winner != req.A && winner != req.B) return Results.BadRequest(new { error = "winner не из пары" }); await store.RecordVoteAsync(id, req.VoterId!, a, b, winner, NowIso()); bc.Touch(id); return Results.Ok(new { ok = true }); }); app.MapPost("/api/polls/{id}/finish", async (string id, FinishReq req, Store store, Broadcaster bc) => { if (string.IsNullOrWhiteSpace(req.VoterId)) return Results.BadRequest(new { error = "нет voterId" }); var poll = await store.GetPollAsync(id); if (poll is null) return Results.NotFound(new { error = "голосование не найдено" }); await store.SetFinishedAsync(id, req.VoterId!, NowIso()); bc.Touch(id); return Results.Ok(new { ok = true }); }); app.MapGet("/api/polls/{id}/results", async (string id, string? owner, Store store, Presence presence) => { var poll = await store.GetPollAsync(id); if (poll is null) return Results.NotFound(new { error = "голосование не найдено" }); bool ownerOk = !string.IsNullOrEmpty(owner) && FixedEquals(poll.OwnerToken, owner); if (!poll.IsPublic && !ownerOk) return Results.Json(new { error = "результаты закрыты — нужен owner-токен" }, statusCode: 403); var items = await store.GetItemsAsync(id); var comparisons = await store.GetComparisonsAsync(id); var ranking = Ranking.Compute(items, comparisons); var (participants, finished, totalVotes) = await store.GetCountsAsync(id); var stats = new StatsDto(presence.Online(id), participants, finished, totalVotes); return Results.Ok(new ResultsResp(stats, ranking)); }); app.MapGet("/api/polls/{id}/qr", async (string id, string? u, Store store, HttpRequest http) => { var poll = await store.GetPollAsync(id); if (poll is null) return Results.NotFound(); string target = !string.IsNullOrWhiteSpace(u) ? u! : $"{http.Scheme}://{http.Host}{http.PathBase}/p/{id}"; using var gen = new QRCodeGenerator(); using var data = gen.CreateQrCode(target, QRCodeGenerator.ECCLevel.M); var svg = new SvgQRCode(data).GetGraphic(4, "#0b0f1c", "#ffffff", drawQuietZones: true); return Results.Content(svg, "image/svg+xml"); }); app.MapHub<PollHub>("/hub"); // SPA client routes (create / vote / dashboard) all render the same page. app.MapFallback(() => Page()); app.Run(); // ---------------- helpers ---------------- static string NowIso() => DateTime.UtcNow.ToString("o"); static string Rand(int bytes) { var buf = RandomNumberGenerator.GetBytes(bytes); return Convert.ToBase64String(buf).Replace('+', '-').Replace('/', '_').TrimEnd('='); } static async Task<string> NewUniqueIdAsync(Store store) { for (int attempt = 0; attempt < 8; attempt++) { var id = Rand(6); // ~8 url-safe chars if (await store.GetPollAsync(id) is null) return id; } return Rand(12); } static bool FixedEquals(string a, string b) { if (a.Length != b.Length) return false; int diff = 0; for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; return diff == 0; }