using System.Security.Cryptography; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace DorkNet.Server.Services; /// /// Backs the admin-issued signup-code flow. While the server has signups /// disabled, the only path to a new account is redeeming one of these on /// the public /join page. Codes are single-use, carry an admin /// descriptor, and can have an optional expiry. Redemption mints an /// account bound to the device id the player's game client reported (see /// ), so the next launch logs straight /// in via the watch's device-id login. /// public class SignupCodeService(DorkNetDbContext db, PlayerService players) { // Unambiguous alphabet — no 0/O/1/I/L so codes are easy to read aloud // and retype. Format: XXXX-XXXX. private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; public async Task GenerateAsync(string descriptor, DateTime? expiresAt, long createdByPlayerId) { string code; do { code = NewCode(); } while (await db.SignupCodes.AnyAsync(c => c.Code == code)); var entity = new SignupCodeEntity { Code = code, Descriptor = (descriptor ?? string.Empty).Trim(), CreatedByPlayerId = createdByPlayerId, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt, }; db.SignupCodes.Add(entity); await db.SaveChangesAsync(); return entity; } public async Task> ListAsync(int take = 200) => await db.SignupCodes .OrderByDescending(c => c.CreatedAt) .Take(Math.Clamp(take, 1, 1000)) .ToListAsync(); /// Revoke an unused code. Already-redeemed codes can't be /// revoked (the account already exists); returns false in that /// case. public async Task RevokeAsync(long id) { var code = await db.SignupCodes.FirstOrDefaultAsync(c => c.Id == id); if (code is null || code.RedeemedByPlayerId is not null) return false; if (code.Revoked) return true; code.Revoked = true; await db.SaveChangesAsync(); return true; } public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username); /// Redeem a code: validate it, then mint an account bound to /// with the requested username. The code /// is consumed atomically with account creation. public async Task RedeemAsync(string? rawCode, string? rawUsername, string? deviceId) { var code = NormalizeCode(rawCode); if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null); var username = (rawUsername ?? string.Empty).Trim(); if (!IsValidUsername(username)) return new(false, "invalid_username", null, null); if (string.IsNullOrWhiteSpace(deviceId)) return new(false, "missing_device", null, null); var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code); if (entity is null) return new(false, "invalid_code", null, null); if (entity.Revoked) return new(false, "code_revoked", null, null); if (entity.RedeemedByPlayerId is not null) return new(false, "code_used", null, null); if (entity.ExpiresAt is { } exp && exp <= DateTime.UtcNow) return new(false, "code_expired", null, null); if (await db.Players.AnyAsync(p => p.Username == username)) return new(false, "username_taken", null, null); // The device must not already own an account (otherwise the // player would launch into the existing one, not this fresh // account). Guides them to a different device / to just log in. if (await players.GetByDeviceAsync(deviceId) is not null) return new(false, "device_in_use", null, null); var player = await players.GetOrCreateByDeviceAsync( deviceId: deviceId, platform: 0, platformId: null, displayName: username); entity.RedeemedByPlayerId = player.Id; entity.RedeemedAt = DateTime.UtcNow; await db.SaveChangesAsync(); // The device is now bound to a real account — drop any pending // refusal row so it stops showing on the /join picker. var pending = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId); if (pending is not null) { db.PendingDevices.Remove(pending); await db.SaveChangesAsync(); } return new(true, null, player.Id, player.Username); } /// Record a device that was refused account creation while /// signups are disabled, so the /join page can surface it (matched by /// IP) instead of making the player hunt for their device id. /// Upserts by device id. public async Task RecordPendingDeviceAsync(string? deviceId, int platform, string? platformId, string? ip) { if (string.IsNullOrWhiteSpace(deviceId)) return; var existing = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId); var now = DateTime.UtcNow; if (existing is null) { db.PendingDevices.Add(new PendingDeviceEntity { DeviceId = deviceId, Platform = platform, PlatformId = platformId ?? string.Empty, LastIp = ip, FirstSeenAt = now, LastSeenAt = now, }); } else { existing.Platform = platform; if (!string.IsNullOrEmpty(platformId)) existing.PlatformId = platformId; existing.LastIp = ip; existing.LastSeenAt = now; } await db.SaveChangesAsync(); } /// Devices recently refused from the given IP, newest first — /// the /join picker shows these so the player recognises their own. public async Task> RecentPendingByIpAsync(string? ip, int take = 10) { if (string.IsNullOrWhiteSpace(ip)) return new(); return await db.PendingDevices .Where(d => d.LastIp == ip) .OrderByDescending(d => d.LastSeenAt) .Take(Math.Clamp(take, 1, 50)) .ToListAsync(); } /// Best-effort real client IP. The game client and the /// player's browser both sit behind the same Cloudflare edge / tunnel, /// so RemoteIpAddress collapses every caller to one address; /// prefer the CF-Connecting-IP / X-Forwarded-For header so the /join /// picker can match a player to the device their own client /// reported. public static string? ClientIp(HttpContext ctx) { var cf = ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(cf)) return cf.Trim(); var fwd = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); return ctx.Connection.RemoteIpAddress?.ToString(); } private static string NewCode() { Span buf = stackalloc char[9]; for (var i = 0; i < 9; i++) buf[i] = i == 4 ? '-' : Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; return new string(buf); } private static string NormalizeCode(string? raw) => (raw ?? string.Empty).Trim().ToUpperInvariant(); private static bool IsValidUsername(string username) => username.Length is >= 2 and <= 24 && username.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-'); }