using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.CDN; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Relationships; using RadicadeServer.Radicade.Mail; namespace RadicadeServer.Web { [Route("web")] public class WebAPI : Controller { private readonly RecDBContext db; public WebAPI(RecDBContext context) { this.db = context; } private static string ImageUrl(string? name) { if (string.IsNullOrWhiteSpace(name)) return $"{DBConfig.NameServer.Images}/default"; if (name.Equals("default", StringComparison.OrdinalIgnoreCase) || name.Equals("default.png", StringComparison.OrdinalIgnoreCase) || name.Equals("DefaultRoomImage", StringComparison.OrdinalIgnoreCase) || name.Equals("DefaultProfileImage", StringComparison.OrdinalIgnoreCase)) return $"{DBConfig.NameServer.Images}/default"; return $"{DBConfig.NameServer.Images}/img/{name}"; } private async Task SubscriberCountAsync(long accountId) { return await db.Relationships.AsNoTracking() .CountAsync(x => x.PlayerId == accountId && x.RelationshipType == RelationshipType.Friend); } [HttpGet("me"), Authorize] public async Task Me() { var userId = User.GetUserId(); if (!userId.HasValue) return Unauthorized(); var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == userId.Value); if (profile == null) return NotFound(new { success = false, message = "Account not found." }); var plrData = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == userId.Value); return Ok(new { success = true, accountId = profile.accountId, username = profile.username, displayName = profile.displayName, bio = profile.bio ?? "", createdAt = profile.createdAt, profileImage = ImageUrl(profile.profileImage), email = plrData?.Email ?? "", emailVerified = plrData?.EmailVerified ?? false, hasPassword = !string.IsNullOrWhiteSpace(plrData?.Hashword), subscriberCount = await SubscriberCountAsync(userId.Value) }); } [HttpGet("profile/{accountId:long}")] public async Task Profile(long accountId) { var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == accountId); if (profile == null) return NotFound(new { success = false, message = "Player not found." }); var photos = new List(); var imageRows = await db.Images.AsNoTracking() .Where(x => x.OwnerId == accountId && x.Accessibility == SavedImageAccessibility.Public) .OrderByDescending(x => x.CreatedAt) .Take(24) .ToListAsync(); foreach (var img in imageRows) { string? roomName = null; if (img.RoomId.HasValue) { var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == img.RoomId.Value); if (room != null) roomName = room.Name; } var cheers = await db.ImageInteractions.AsNoTracking().CountAsync(x => x.SavedImageId == img.Id); photos.Add(new { id = img.Id, image = ImageUrl(img.Name), roomName = roomName ?? "Unknown room", cheers, createdAt = img.CreatedAt }); } var rooms = await db.Rooms.AsNoTracking() .Where(x => x.CreatorAccountId == accountId) .OrderByDescending(x => x.CreatedAt) .Take(24) .Select(x => new { roomId = x.RoomId, name = x.Name, description = x.Description ?? "", image = ImageUrl(x.ImageName), maxPlayers = x.MaxPlayers, createdAt = x.CreatedAt }) .ToListAsync(); var events = await db.PlayerEvents.AsNoTracking() .Where(x => x.CreatorPlayerId == accountId) .OrderBy(x => x.StartTime) .Take(24) .Select(x => new { eventId = x.PlayerEventId, name = x.Name, description = x.Description, image = ImageUrl(x.ImageName), startTime = x.StartTime, endTime = x.EndTime, attendeeCount = x.AttendeeCount }) .ToListAsync(); return Ok(new { success = true, profile = new { accountId = profile.accountId, username = profile.username, displayName = profile.displayName, bio = profile.bio ?? "", createdAt = profile.createdAt, profileImage = ImageUrl(profile.profileImage), subscriberCount = await SubscriberCountAsync(accountId) }, photos, rooms, events }); } public class ForgotPasswordRequest { public string? Email { get; set; } } [HttpPost("password/forgot")] public async Task ForgotPassword([FromBody] ForgotPasswordRequest? req) { var email = (req?.Email ?? "").Trim(); if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return Ok(new { success = false, message = "Enter a valid email address." }); var plrData = await db.Players.FirstOrDefaultAsync(x => x.Email.ToLower() == email.ToLower()); if (plrData != null) { plrData.PasswordResetToken = Guid.NewGuid().ToString("N"); plrData.PasswordResetExpires = DateTime.UtcNow.AddHours(1); await db.SaveChangesAsync(); SendResetEmail(plrData); } return Ok(new { success = true, message = "If an account exists for that email, a reset link has been sent." }); } public class ResetPasswordRequest { public string? Code { get; set; } public string? NewPassword { get; set; } } [HttpGet("password/reset/result")] public async Task ResetResult([FromQuery] string code) { var (ok, message) = await ValidateResetCode(code); return Ok(new { success = ok, message }); } [HttpPost("password/reset")] public async Task ResetPassword([FromBody] ResetPasswordRequest? req) { var code = req?.Code?.Trim() ?? ""; var newPassword = req?.NewPassword ?? ""; if (string.IsNullOrWhiteSpace(code)) return Ok(new { success = false, message = "This reset link is missing its code." }); if (newPassword.Length < Auth.AuthAPI.MinPasswordLength) return Ok(new { success = false, message = $"Password must be at least {Auth.AuthAPI.MinPasswordLength} characters." }); var plrData = await db.Players.FirstOrDefaultAsync(x => x.PasswordResetToken == code); if (plrData == null) return Ok(new { success = false, message = "This reset link is invalid or has already been used." }); if (!plrData.PasswordResetExpires.HasValue || plrData.PasswordResetExpires.Value < DateTime.UtcNow) return Ok(new { success = false, message = "This reset link has expired. Request a new one." }); var hasher = new Microsoft.AspNetCore.Identity.PasswordHasher(); plrData.Hashword = hasher.HashPassword(plrData, newPassword); plrData.PasswordResetToken = null; plrData.PasswordResetExpires = null; var staleLogins = await db.CachedLogins.Where(x => x.accountId == plrData.AccountId).ToListAsync(); if (staleLogins.Count > 0) db.CachedLogins.RemoveRange(staleLogins); plrData.TokenVersion++; Utils.TokenRevocation.Bump(plrData.AccountId, plrData.TokenVersion); await db.SaveChangesAsync(); Utils.AuthSecurityMonitor.NoteCredentialChange(plrData.AccountId, "password reset completed"); return Ok(new { success = true, message = "Password updated. You can now sign in with your new password." }); } private async Task<(bool Ok, string Message)> ValidateResetCode(string code) { if (string.IsNullOrWhiteSpace(code)) return (false, "This reset link is missing its code."); var plrData = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.PasswordResetToken == code); if (plrData == null) return (false, "This reset link is invalid or has already been used."); if (!plrData.PasswordResetExpires.HasValue || plrData.PasswordResetExpires.Value < DateTime.UtcNow) return (false, "This reset link has expired. Request a new one."); return (true, "This reset link is valid."); } private void SendResetEmail(PlayerData plrData) { if (string.IsNullOrWhiteSpace(plrData.PasswordResetToken)) return; var link = $"https://radicade.app/reset?code={plrData.PasswordResetToken}"; var html = "
" + "\"Radicade\"" + "

Reset your Radicade password

" + "

Hi there,

" + "

We received a request to reset the password on your Radicade account. If that was you, click the button below to choose a new password:

" + "

Reset my password

" + "

This link expires in one hour. If you didn't request this, you can safely ignore this email.

"; _ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html); } } }