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 '-');
}