Files
Radicade/Server/Web/WebAPI.cs
T
2026-10-16 23:33:13 -07:00

279 lines
9.2 KiB
C#

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<long> SubscriberCountAsync(long accountId)
{
return await db.Relationships.AsNoTracking()
.CountAsync(x => x.PlayerId == accountId && x.RelationshipType == RelationshipType.Friend);
}
[HttpGet("me"), Authorize]
public async Task<IActionResult> 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<IActionResult> 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<object>();
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<IActionResult> 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<IActionResult> ResetResult([FromQuery] string code)
{
var (ok, message) = await ValidateResetCode(code);
return Ok(new { success = ok, message });
}
[HttpPost("password/reset")]
public async Task<IActionResult> 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<PlayerData>();
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 =
"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
"<h2 style=\"color:#1a1a2e;margin-top:0;\">Reset your Radicade password</h2>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi there,</p>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">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:</p>" +
"<p style=\"text-align:center;margin:28px 0;\"><a href=\"" + link + "\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Reset my password</a></p>" +
"<p style=\"color:#999;font-size:12px;margin-top:24px;\">This link expires in one hour. If you didn't request this, you can safely ignore this email.</p></div>";
_ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html);
}
}
}