Add remaining project files
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
using DeluxeBackend.Controllers.Auth; // Added to access ConnectController explicitly
|
||||
using DeluxeBackend.Models;
|
||||
using DeluxeBackend.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
[Route("Auth/account")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AccountController(IJwtService jwt, ILiteDbService db, DiscordBotService discord) : ControllerBase
|
||||
{
|
||||
[HttpPost("me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth([FromForm(Name = "code"), Required] string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_request", description = "Authorization code cannot be empty." });
|
||||
}
|
||||
|
||||
Account? account = await jwt.GetLogin(User);
|
||||
if (account == null)
|
||||
{
|
||||
return Unauthorized(new { error = "unauthorized", description = "Session expired or invalid user profile." });
|
||||
}
|
||||
|
||||
string? matchingServerCode = null;
|
||||
string upperUserCode = code.Trim().ToUpperInvariant();
|
||||
DateTime cachedCreatedAt = DateTime.UtcNow;
|
||||
|
||||
foreach (var kvp in ConnectController.DeviceAuthorizationKeys)
|
||||
{
|
||||
if (kvp.Value.UserCode == upperUserCode)
|
||||
{
|
||||
matchingServerCode = kvp.Key;
|
||||
cachedCreatedAt = kvp.Value.CreatedAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingServerCode == null)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_code", description = "The authorization code is invalid or has expired." });
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - cachedCreatedAt > TimeSpan.FromMinutes(15))
|
||||
{
|
||||
ConnectController.DeviceAuthorizationKeys.TryRemove(matchingServerCode, out _);
|
||||
return BadRequest(new { error = "code_expired", description = "The authorization code has expired." });
|
||||
}
|
||||
|
||||
var updatedTuple = (UserCode: upperUserCode, AccId: account.Id, CreatedAt: cachedCreatedAt);
|
||||
|
||||
if (!ConnectController.DeviceAuthorizationKeys.TryUpdate(matchingServerCode, updatedTuple, (upperUserCode, -1, cachedCreatedAt)))
|
||||
{
|
||||
return BadRequest(new { error = "transaction_conflict", description = "Authorization signature was modified or consumed." });
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, message = "Device authorized successfully. Your studio instance will resume shortly." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using DeluxeBackend.Models;
|
||||
using DeluxeBackend.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Numerics;
|
||||
using static DeluxeBackend.Enums;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
[Route("Auth/cachedlogin")]
|
||||
[ApiController]
|
||||
public class CachedLoginController : ControllerBase
|
||||
{
|
||||
private readonly ILiteDbService db;
|
||||
public CachedLoginController(ILiteDbService _db)
|
||||
{
|
||||
db = _db;
|
||||
}
|
||||
[HttpGet("forplatformid/{platform}/{platformId}")]
|
||||
public async Task<IActionResult> ForPlatformId([FromRoute] PlatformType platform, [FromRoute] string platformId)
|
||||
{
|
||||
List<Dictionary<string, object>> logins = new List<Dictionary<string, object>>();
|
||||
|
||||
foreach (Cachedlogin item in db.Cachedlogins.Include(x => x.Account).Find(x => x.Platform == platform && x.PlatformId == platformId).OrderByDescending(x => x.LastLoginAt))
|
||||
{
|
||||
logins.Add(new Dictionary<string, object>
|
||||
{
|
||||
["platform"] = (int)item.Platform,
|
||||
["platformId"] = item.PlatformId,
|
||||
["accountId"] = item.Account.Id,
|
||||
["lastLoginTime"] = item.LastLoginAt.ToString("O"),
|
||||
["requirePassword"] = item.RequirePassword
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return Ok(logins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using DeluxeBackend.Models;
|
||||
using DeluxeBackend.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using static DeluxeBackend.Enums;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
public class GameTokenRequestDto
|
||||
{
|
||||
[FromForm(Name = "grant_type"), Required]
|
||||
public required string GrantType { get; set; }
|
||||
|
||||
[FromForm(Name = "client_id"), Required]
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
[FromForm(Name = "client_secret"), Required]
|
||||
public required string ClientSecret { get; set; }
|
||||
|
||||
[FromForm(Name = "platform"), Required]
|
||||
public required PlatformType Platform { get; set; }
|
||||
|
||||
[FromForm(Name = "platform_id"), Required]
|
||||
public required string PlatformId { get; set; }
|
||||
|
||||
[FromForm(Name = "device_id"), Required]
|
||||
public required string DeviceId { get; set; }
|
||||
|
||||
[FromForm(Name = "device_class"), Required]
|
||||
public required DeviceClassType DeviceClass { get; set; }
|
||||
|
||||
[FromForm(Name = "time"), Required]
|
||||
public required DateTimeOffset Time { get; set; }
|
||||
|
||||
[FromForm(Name = "ver"), Required]
|
||||
public required string Version { get; set; }
|
||||
|
||||
[FromForm(Name = "cid"), Required]
|
||||
public required int Cid { get; set; }
|
||||
|
||||
[FromForm(Name = "build_key"), Required]
|
||||
public required string BuildKey { get; set; }
|
||||
|
||||
[FromForm(Name = "asid"), Required]
|
||||
public required long Asid { get; set; }
|
||||
|
||||
[FromForm(Name = "locale"), Required]
|
||||
public required string Locale { get; set; }
|
||||
|
||||
[FromForm(Name = "isInitialLogin"), Required]
|
||||
public required bool IsInitialLogin { get; set; }
|
||||
|
||||
[FromForm(Name = "dinfo")]
|
||||
[MaxLength(2000)]
|
||||
public string? DeviceInfo { get; set; }
|
||||
|
||||
[FromForm(Name = "eac_challenge")]
|
||||
public string? EacChallenge { get; set; }
|
||||
|
||||
[FromForm(Name = "eac_response")]
|
||||
public string? EacResponse { get; set; }
|
||||
|
||||
[FromForm(Name = "platform_auth"), Required]
|
||||
public required string PlatformAuthRaw { get; set; }
|
||||
|
||||
[FromForm(Name = "account_id")]
|
||||
public long? AccountId { get; set; } = null;
|
||||
|
||||
[FromForm(Name = "refresh_token")]
|
||||
public string? RefreshToken { get; set; } = null;
|
||||
|
||||
[FromForm(Name = "username")]
|
||||
[MaxLength(256)]
|
||||
public string? Username { get; set; } = null;
|
||||
|
||||
[FromForm(Name = "password")]
|
||||
[MaxLength(256)]
|
||||
public string? Password { get; set; } = null;
|
||||
}
|
||||
|
||||
public class OculusPlatformAuth
|
||||
{
|
||||
public required string Nonce { get; set; }
|
||||
public required string AppId { get; set; }
|
||||
public required string Source { get; set; }
|
||||
}
|
||||
|
||||
[Route("Auth/connect")]
|
||||
[ApiController]
|
||||
public class ConnectController : ControllerBase
|
||||
{
|
||||
private readonly ILiteDbService db;
|
||||
private readonly IJwtService jwt;
|
||||
private readonly IPasswordService password;
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public static readonly ConcurrentDictionary<string, (string UserCode, long AccId, DateTime CreatedAt)> DeviceAuthorizationKeys = new();
|
||||
|
||||
public ConnectController(ILiteDbService _db, IJwtService _jwtService, IPasswordService _password, HttpClient _httpClient)
|
||||
{
|
||||
db = _db;
|
||||
jwt = _jwtService;
|
||||
password = _password;
|
||||
httpClient = _httpClient;
|
||||
}
|
||||
|
||||
[HttpPost("gametoken")]
|
||||
[HttpPost("token")]
|
||||
public async Task<IActionResult> GameToken([FromForm, Required] GameTokenRequestDto request)
|
||||
{
|
||||
if (request.ClientId != "recroom")
|
||||
{
|
||||
return BadRequest(new { error = "invalid_client" });
|
||||
}
|
||||
|
||||
switch (request.Platform)
|
||||
{
|
||||
case PlatformType.Oculus:
|
||||
try
|
||||
{
|
||||
var auth = JsonConvert.DeserializeObject<OculusPlatformAuth>(request.PlatformAuthRaw);
|
||||
if (auth == null || string.IsNullOrWhiteSpace(auth.Nonce) || string.IsNullOrWhiteSpace(auth.AppId))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Meta authentication failed. Missing required nonces." });
|
||||
}
|
||||
|
||||
if (!ServerConfig.OvrAppSecret.TryGetValue(auth.AppId, out var ovrAppSecret))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Oculus integration context not configured." });
|
||||
}
|
||||
|
||||
var postData = new Dictionary<string, string>
|
||||
{
|
||||
{ "access_token", $"OC|{auth.AppId}|{ovrAppSecret}" },
|
||||
{ "user_id", request.PlatformId },
|
||||
{ "nonce", auth.Nonce }
|
||||
};
|
||||
|
||||
var formContent = new FormUrlEncodedContent(postData);
|
||||
var metaResponse = await httpClient.PostAsync("https://graph.oculus.com/user_nonce_validate", formContent);
|
||||
|
||||
if (!metaResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode(502, new { error = "invalid_grant", error_description = "Upstream identity tracking challenge rejected." });
|
||||
}
|
||||
|
||||
var metaContent = await metaResponse.Content.ReadAsStringAsync();
|
||||
var metaJson = JObject.Parse(metaContent);
|
||||
|
||||
if (metaJson["is_valid"]?.Value<bool>() != true)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Meta identity tracking validation signature verification failed." });
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "JSONDecodeError" });
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return StatusCode(500, new { error = "invalid_grant", error_description = "Internal Meta handling error." });
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return BadRequest(new { error = "unsupported_platform_type" });
|
||||
}
|
||||
|
||||
Account? account = null;
|
||||
switch (request.GrantType)
|
||||
{
|
||||
case "create_account":
|
||||
Account newacc = new() { Username = Guid.NewGuid().ToString("N") };
|
||||
db.Accounts.Insert(newacc);
|
||||
account = newacc;
|
||||
|
||||
Cachedlogin cachedlogin = new()
|
||||
{
|
||||
Platform = request.Platform,
|
||||
PlatformId = request.PlatformId,
|
||||
LastLoginAt = DateTime.UtcNow,
|
||||
Account = newacc
|
||||
};
|
||||
db.Cachedlogins.Insert(cachedlogin);
|
||||
break;
|
||||
|
||||
case "cached_login":
|
||||
if (request.AccountId == null || request.AccountId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
var cached = db.Cachedlogins.Include(x => x.Account)
|
||||
.FindOne(x => x.Platform == request.Platform && x.PlatformId == request.PlatformId && x.Account.Id == request.AccountId);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
cached.LastLoginAt = DateTime.UtcNow;
|
||||
db.Cachedlogins.Update(cached);
|
||||
account = cached.Account;
|
||||
}
|
||||
break;
|
||||
|
||||
case "refresh_token":
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
RefreshToken? refreshToken = db.RefreshTokens.Include(x => x.Account).FindOne(x => x.Token == request.RefreshToken && !x.IsRevoked);
|
||||
if (refreshToken == null || refreshToken.Expires < DateTime.UtcNow)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Token expired or blacklisted." });
|
||||
}
|
||||
|
||||
refreshToken.IsRevoked = true;
|
||||
db.RefreshTokens.Update(refreshToken);
|
||||
account = refreshToken.Account;
|
||||
break;
|
||||
|
||||
case "password":
|
||||
if (string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.Username))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
Account? account1 = db.Accounts.FindOne(x => x.Username == request.Username);
|
||||
if (account1 == null)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "invalid_username_or_password" });
|
||||
}
|
||||
|
||||
if (password.VerifyPassword(account1, request.Password) == Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "invalid_username_or_password" });
|
||||
}
|
||||
|
||||
account = account1;
|
||||
Cachedlogin cachedlogin1 = new()
|
||||
{
|
||||
Platform = request.Platform,
|
||||
PlatformId = request.PlatformId,
|
||||
LastLoginAt = DateTime.UtcNow,
|
||||
Account = account1
|
||||
};
|
||||
db.Cachedlogins.Insert(cachedlogin1);
|
||||
break;
|
||||
|
||||
default:
|
||||
return BadRequest(new { error = "unsupported_grant_type" });
|
||||
}
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Authentication reference lookups resolved to null." });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = await jwt.GenAccessToken(account, ["gameClient"], new Dictionary<string, object>
|
||||
{
|
||||
["db.platform"] = (int)request.Platform,
|
||||
["db.platform.id"] = request.PlatformId,
|
||||
["db.deviceclass"] = (int)request.DeviceClass,
|
||||
["db.locale"] = request.Locale,
|
||||
["db.appver"] = request.Version
|
||||
}),
|
||||
refresh_token = await jwt.GenRefreshToken(account),
|
||||
key = ServerConfig.SigKey
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("deviceauthorization")]
|
||||
public IActionResult DeviceAuthorization()
|
||||
{
|
||||
CleanExpiredDeviceCodes();
|
||||
|
||||
string userCode = GenerateSecureCode(6).ToUpperInvariant();
|
||||
string serverCode = GenerateSecureCode(30).ToUpperInvariant();
|
||||
|
||||
DeviceAuthorizationKeys[serverCode] = (userCode, -1, DateTime.UtcNow);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
device_code = serverCode,
|
||||
user_code = userCode,
|
||||
verification_uri = string.Empty,
|
||||
verification_uri_complete = "calc://60+7",
|
||||
expires_in = int.MaxValue,
|
||||
interval = 5
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("studiotoken")]
|
||||
public async Task<IActionResult> StudioToken([FromForm(Name = "device_code")] string deviceCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceCode) || !DeviceAuthorizationKeys.TryGetValue(deviceCode, out var authData))
|
||||
{
|
||||
return NotFound(new { error = "invalid_grant", error_description = "The provided authorization tracker code was not found." });
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - authData.CreatedAt > TimeSpan.FromMinutes(15))
|
||||
{
|
||||
DeviceAuthorizationKeys.TryRemove(deviceCode, out _);
|
||||
return BadRequest(new { error = "code_expired" });
|
||||
}
|
||||
|
||||
if (authData.AccId == -1)
|
||||
{
|
||||
return BadRequest(new { error = "authorization_pending" });
|
||||
}
|
||||
|
||||
Account account = db.Accounts.FindById(authData.AccId);
|
||||
if (account == null)
|
||||
{
|
||||
return BadRequest(new { error = "no_account" });
|
||||
}
|
||||
|
||||
DeviceAuthorizationKeys.TryRemove(deviceCode, out _);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = await jwt.GenAccessToken(account, ["studioClient"], new Dictionary<string, object>()),
|
||||
refresh_token = await jwt.GenRefreshToken(account),
|
||||
scope = string.Empty,
|
||||
expires_in = 3600
|
||||
});
|
||||
}
|
||||
|
||||
private static void CleanExpiredDeviceCodes()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddMinutes(-15);
|
||||
foreach (var kvp in DeviceAuthorizationKeys)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < cutoff)
|
||||
{
|
||||
DeviceAuthorizationKeys.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateSecureCode(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
return string.Create(length, chars, (buffer, symbols) =>
|
||||
{
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
buffer[buffer.Length - 1 - i] = symbols[RandomNumberGenerator.GetInt32(symbols.Length)];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
[Route("Auth/eac")]
|
||||
[ApiController]
|
||||
public class EacController : ControllerBase
|
||||
{
|
||||
[HttpGet("challenge")]
|
||||
public async Task<IActionResult> C_hallenge()
|
||||
{
|
||||
return Ok("\"AQAAAHsg7mW5FQEE9HVl9EKMWXrqDzQxUCdgV/IPuQfbRgTx+cGnQqhhAgv1RvpihEC77gQ29JdoGFn2806Q+QPEj7nYg9C8pynbaiSVO8rKLJPvROsHuSXVJpQMv3TD8KyK3Y+n5bb86vAb5kRdZGD//uC8HY+D9jJLlEfTUlU=\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
[Route("Auth/photon")]
|
||||
[ApiController]
|
||||
public class PhotonController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<PhotonController> _logger;
|
||||
|
||||
public PhotonController(ILogger<PhotonController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public class PhotonAuthResponse
|
||||
{
|
||||
[JsonPropertyName("ResultCode")]
|
||||
public int ResultCode { get; set; } // 1 = Success, 2 = Fail, 3 = Invalid Params
|
||||
|
||||
[JsonPropertyName("Message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("UserId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("Nickname")]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
[JsonPropertyName("Data")]
|
||||
public object? Data { get; set; }
|
||||
}
|
||||
|
||||
/*[HttpPost()]
|
||||
public async Task<IActionResult> Authenticate()
|
||||
{
|
||||
_logger.LogInformation("Photon Auth Request Query: {Query}", Request.QueryString.Value);
|
||||
|
||||
string requestBody = string.Empty;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Photon Auth Request Body: {Body}", requestBody);
|
||||
|
||||
return Ok(new PhotonAuthResponse
|
||||
{
|
||||
ResultCode = 1,
|
||||
UserId = "2",
|
||||
Nickname = "player",
|
||||
Message = "Authentication verified successfully.",
|
||||
Data = new { }
|
||||
});
|
||||
}*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using DeluxeBackend.Models;
|
||||
using DeluxeBackend.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeluxeBackend.Controllers.Auth
|
||||
{
|
||||
[Route("Auth/role")]
|
||||
[ApiController]
|
||||
public class RoleController : ControllerBase
|
||||
{
|
||||
|
||||
private readonly ILiteDbService db;
|
||||
private readonly IJwtService jwt;
|
||||
private readonly DiscordBotService discord;
|
||||
public RoleController(ILiteDbService _db, IJwtService _jwtService, DiscordBotService discordBotService)
|
||||
{
|
||||
db = _db;
|
||||
jwt = _jwtService;
|
||||
discord = discordBotService;
|
||||
}
|
||||
|
||||
[HttpGet("{roleName}/{accId}")]
|
||||
public async Task<IActionResult> HasRole(string roleName, long accId)
|
||||
{
|
||||
Account? account = db.Accounts.FindById(accId);
|
||||
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(await account.HasRoleAsync(discord, roleName));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user