Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Players;
using RadicadeServer.RecNet.Notifications;
namespace RadicadeServer.API
{
[Route("api/announcement")]
public class AnnouncementAPI : Controller
{
RecDBContext db;
public AnnouncementAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("v1/get")]
public List<Announcement>? GetAnnouncement()
{
var config = db.ServerConfig.FirstOrDefault(x => x.Key == "Announcement");
if (config == null || string.IsNullOrWhiteSpace(config.Value) || config.Value == "[]")
return new List<Announcement>();
try
{
var announcements = config.Value.FromJson<List<Announcement>>() ?? new List<Announcement>();
var raw = System.Text.Json.JsonDocument.Parse(config.Value);
if (raw.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
var now = DateTime.UtcNow;
var keep = new List<Announcement>();
int i = 0;
foreach (var element in raw.RootElement.EnumerateArray())
{
var ann = i < announcements.Count ? announcements[i] : null;
if (ann != null)
{
if (element.TryGetProperty("type", out var typeProp) && typeProp.ValueKind == System.Text.Json.JsonValueKind.String &&
string.Equals(typeProp.GetString(), "Game Update", StringComparison.OrdinalIgnoreCase))
{
i++;
continue;
}
string? expiresAt = null;
if (element.TryGetProperty("expires_at", out var exp) && exp.ValueKind == System.Text.Json.JsonValueKind.String)
expiresAt = exp.GetString();
if (string.IsNullOrEmpty(expiresAt) || !DateTime.TryParse(expiresAt, out var expiry) || expiry > now)
keep.Add(ann);
}
i++;
}
announcements = keep;
}
announcements = announcements.OrderByDescending(x => x.CreatedAt).ToList();
foreach (var ann in announcements)
{
ann.IsMostRecent = false;
ann.Platform = Platforms.All;
}
if (announcements.Count > 0)
announcements[0].IsMostRecent = true;
return announcements;
}
catch
{
return new List<Announcement>();
}
}
}
}
+280
View File
@@ -0,0 +1,280 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Notifications;
namespace RadicadeServer.Clubs.Announcements
{
[Route("clubs/announcements/")]
[Route("api/clubs/v1/announcements/")]
[Route("api/clubs/v2/announcements/")]
public class ClubAnnouncementsAPI : Controller
{
private const string StorageKey = "ClubAnnouncements";
private static readonly ConcurrentDictionary<long, List<ClubAnnouncementRecord>> Store = new();
private sealed class ClubAnnouncementRecord
{
public long AnnouncementId { get; set; }
public long ClubId { get; set; }
public long AccountId { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public string Meta { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
private readonly RecDBContext? db;
public ClubAnnouncementsAPI(RecDBContext? db = null)
{
this.db = db;
}
[HttpGet("v2/subscription/mine/unread")]
[HttpGet("subscription/mine/unread")]
public List<Announcement> GetUnreadSubscribedAnnouncements()
{
return [];
}
[HttpGet("v2/mine/unread")]
[HttpGet("mine/unread")]
public List<Announcement> GetUnreadAnnouncements()
{
return [];
}
[HttpPost("club/{clubId}")]
[HttpPost("~/announcements/club/{clubId:long}")]
public async Task<IActionResult> PostClubAnnouncements(long clubId)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var (managedClubId, error) = await ResolveManagedClubAsync(clubId, userId.Value);
if (error != null) return error;
clubId = managedClubId;
var fields = await ReadBodyFieldsAsync();
var announcement = new ClubAnnouncementRecord
{
AnnouncementId = NewAnnouncementId(),
ClubId = clubId,
AccountId = userId.Value,
Title = GetField(fields, "title", "Title") ?? string.Empty,
Body = GetField(fields, "body", "Body") ?? string.Empty,
ImageName = GetField(fields, "imageName", "ImageName", "image_name") ?? string.Empty,
Meta = GetField(fields, "meta", "Meta") ?? string.Empty,
CreatedAt = DateTime.UtcNow
};
Add(clubId, announcement);
await PersistAsync(clubId);
return Ok(new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = announcement.AnnouncementId
});
}
[HttpGet("club/{clubId}")]
[HttpGet("~/announcements/club/{clubId:long}")]
public IActionResult GetClubAnnouncements(long clubId)
{
return Ok(BuildAnnouncementFeed(clubId));
}
[HttpDelete("club/{clubId:long}/{announcementId:long}")]
[HttpDelete("~/announcements/club/{clubId:long}/{announcementId:long}")]
public async Task<IActionResult> DeleteClubAnnouncement(long clubId, long announcementId)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var (managedClubId, error) = await ResolveManagedClubAsync(clubId, userId.Value);
if (error != null) return error;
clubId = managedClubId;
if (!Store.TryGetValue(clubId, out var announcements)) return NotFound();
bool removed;
lock (announcements)
removed = announcements.RemoveAll(item => item.AnnouncementId == announcementId) > 0;
if (!removed) return NotFound();
await PersistAsync(clubId);
return Ok(new { error = string.Empty, success = true, value = announcementId });
}
private async Task<(long ClubId, IActionResult? Error)> ResolveManagedClubAsync(long clubId, long userId)
{
if (clubId == 0 || PlayerSubscriptions.IsPersonalClubId(clubId))
{
var personalClubId = PlayerSubscriptions.PersonalClubId(userId);
if (clubId != 0 && clubId != personalClubId)
return (clubId, StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null }));
return (personalClubId, null);
}
if (db == null) return (clubId, StatusCode(StatusCodes.Status500InternalServerError));
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return (clubId, NotFound());
if (ClubStore.GetMembershipType(club, userId) < ClubMembershipTypes.Coowner)
return (clubId, StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null }));
return (clubId, null);
}
private void Add(long clubId, ClubAnnouncementRecord announcement)
{
var list = Store.GetOrAdd(clubId, _ => new List<ClubAnnouncementRecord>());
lock (list) list.Insert(0, announcement);
}
private static long NewAnnouncementId()
{
return Math.Abs((long)HashCode.Combine(Guid.NewGuid(), Environment.TickCount64));
}
[HttpPost("club/{clubId}/announcement/{announcementId}/read")]
[HttpPost("~/announcements/club/{clubId:long}/{announcementId:long}/read")]
[HttpPost("~/clubs/announcements/club/{clubId:long}/{announcementId:long}/read")]
[HttpPost("~/api/clubs/v1/announcements/club/{clubId:long}/{announcementId:long}/read")]
[HttpPost("~/api/clubs/v2/announcements/club/{clubId:long}/{announcementId:long}/read")]
public IActionResult MarkAnnouncementRead(long clubId, long announcementId)
{
if (User.GetUserId() == null) return Unauthorized();
return Ok(new { error = string.Empty, success = true, value = announcementId });
}
private async Task PersistAsync(long clubId)
{
if (db == null) return;
try
{
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == StorageKey);
if (config == null)
{
config = new ServerConfigVal(StorageKey, "{}");
await db.ServerConfig.AddAsync(config);
}
var snapshot = Store.ToDictionary(
kv => kv.Key.ToString(),
kv => SerializeAnnouncements(kv.Value));
config.Value = JsonSerializer.Serialize(snapshot);
await db.SaveChangesAsync();
}
catch (Exception)
{
}
}
private static List<object> SerializeAnnouncements(IReadOnlyList<ClubAnnouncementRecord> announcements)
{
return announcements.Select(announcement => (object)new Dictionary<string, object?>
{
["AnnouncementId"] = announcement.AnnouncementId,
["ClubId"] = announcement.ClubId,
["AccountId"] = announcement.AccountId,
["Title"] = announcement.Title,
["Body"] = announcement.Body,
["ImageName"] = announcement.ImageName,
["Meta"] = announcement.Meta,
["CreatedAt"] = announcement.CreatedAt
}).ToList();
}
private static object BuildAnnouncementFeed(long clubId)
{
var announcements = Store.TryGetValue(clubId, out var list)
? SerializeAnnouncements(list)
: new List<object>();
var lastAnnouncementId = announcements.Count > 0
? (long?)((Dictionary<string, object?>)announcements[0])["AnnouncementId"]
: null;
return new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = new Dictionary<string, object?>
{
["Announcements"] = announcements,
["ClubId"] = clubId,
["LastAnnouncementId"] = lastAnnouncementId,
["LastReadAnnouncementId"] = 0L
}
};
}
private static string? GetField(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
{
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var kv in form)
fields[kv.Key] = kv.Value.ToString();
return fields;
}
if (Request.ContentLength is not > 0) return fields;
try
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body)) return fields;
var trimmed = body.TrimStart();
if (trimmed.StartsWith("{"))
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? ""
: prop.Value.ToString();
}
}
}
else
{
foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = pair.Split('=', 2);
if (kv.Length < 2) continue;
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1].Replace('+', ' '));
}
}
}
catch
{
}
return fields;
}
}
}
+782
View File
@@ -0,0 +1,782 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.CDN;
using RadicadeServer.RecNet.Enums;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace RadicadeServer.Clubs;
public sealed class ClubRecord
{
public long clubId { get; set; }
public string name { get; set; } = "Radicade Club";
public string description { get; set; } = "";
public string primaryTag { get; set; } = "Social";
public string imageName { get; set; } = "default";
public string bannerImageName { get; set; } = "default";
public long ownerPlayerId { get; set; }
public DateTime createdAt { get; set; } = DateTime.UtcNow;
public long? clubhouseRoomId { get; set; }
public int visibility { get; set; } = 1;
public int joinability { get; set; } = 0;
public bool allowJuniors { get; set; } = true;
public int minLevel { get; set; }
public string[] customTags { get; set; } = Array.Empty<string>();
public string[] additionalImages { get; set; } = Array.Empty<string>();
public List<ClubMemberRecord> members { get; set; } = new();
}
public sealed class ClubMemberRecord
{
public long accountId { get; set; }
public int membershipType { get; set; }
public int pendingMembershipType { get; set; }
public long inviterAccountId { get; set; }
public DateTime createdAt { get; set; } = DateTime.UtcNow;
}
public static class ClubMembershipTypes
{
public const int Banned = -1;
public const int None = 0;
public const int PendingRequested = 1;
public const int PendingInvited = 2;
public const int PendingDenied = 3;
public const int Member = 10;
public const int Moderator = 20;
public const int Coowner = 30;
public const int Creator = 100;
}
public sealed record ClubMemberView(long AccountId, int MembershipType, long InviterAccountId, DateTime CreatedAt);
public static class ClubStore
{
private const string ConfigKey = "Clubs";
public const string DefaultImageName = "DefaultImgPurple";
private const int ActiveMemberThreshold = ClubMembershipTypes.Member;
private static readonly string[] CategoryQueryKeys =
{
"category", "Category", "categoryTag", "CategoryTag", "categoryName", "CategoryName",
"primaryTag", "PrimaryTag", "tag", "Tag", "tags", "Tags"
};
public static string? CategoryFilter(IQueryCollection query)
{
foreach (var key in CategoryQueryKeys)
{
var value = query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value.Trim().TrimStart('#');
}
return null;
}
public static bool MatchesCategory(ClubRecord? club, string? category)
{
if (club == null) return false;
var wanted = category?.Trim().TrimStart('#');
if (string.IsNullOrWhiteSpace(wanted)) return true;
return string.Equals(club.primaryTag?.Trim(), wanted, StringComparison.OrdinalIgnoreCase)
|| (club.customTags ?? Array.Empty<string>())
.Any(tag => string.Equals(tag?.Trim(), wanted, StringComparison.OrdinalIgnoreCase));
}
public static List<ClubRecord> FilterByCategory(List<ClubRecord> clubs, string? category)
=> string.IsNullOrWhiteSpace(category)
? clubs
: clubs.Where(club => MatchesCategory(club, category)).ToList();
public static async Task<List<ClubRecord>> LoadAsync(RecDBContext db)
{
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == ConfigKey);
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<ClubRecord>();
try
{
var clubs = config.Value.FromJson<List<ClubRecord>>() ?? new List<ClubRecord>();
foreach (var club in clubs)
{
club.members ??= new List<ClubMemberRecord>();
club.customTags ??= Array.Empty<string>();
club.additionalImages ??= Array.Empty<string>();
}
return clubs;
}
catch
{
return new List<ClubRecord>();
}
}
private static readonly string[] HomeClubConfigPrefixes = ["ClubHome:", "ClubHome.", "HomeClub:"];
private static string HomeClubConfigKey(long accountId) => $"ClubHome:{accountId}";
private static async Task<long?> GetHomeClubIdAsync(RecDBContext db, long accountId)
{
var keys = HomeClubConfigPrefixes.Select(prefix => prefix + accountId.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray();
var value = await db.ServerConfig.AsNoTracking()
.Where(config => keys.Contains(config.Key))
.Select(config => config.Value)
.FirstOrDefaultAsync();
return long.TryParse(value, out var clubId) && clubId > 0 ? clubId : null;
}
public static async Task<ClubRecord?> GetHomeClubAsync(RecDBContext db, long accountId)
{
var clubId = await GetHomeClubIdAsync(db, accountId);
if (!clubId.HasValue) return null;
var club = await GetAsync(db, clubId.Value);
return club?.clubhouseRoomId is > 0 ? club : null;
}
public static async Task<ClubRecord?> SetHomeClubAsync(RecDBContext db, long accountId, long? clubId)
{
var configKey = HomeClubConfigKey(accountId);
var config = await db.ServerConfig.FirstOrDefaultAsync(row => row.Key == configKey);
if (clubId is > 0)
{
if (config == null)
{
config = new ServerConfigVal(configKey, clubId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
await db.ServerConfig.AddAsync(config);
}
else
{
config.Value = clubId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
else if (config != null)
{
db.ServerConfig.Remove(config);
}
await db.SaveChangesAsync();
return clubId is > 0 ? await GetAsync(db, clubId.Value) : null;
}
public static async Task ClearHomeClubsAsync(RecDBContext db, long clubId)
{
var clubIdValue = clubId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var matching = await db.ServerConfig.Where(config => config.Value == clubIdValue).ToListAsync();
matching.RemoveAll(config => !HomeClubConfigPrefixes.Any(prefix => config.Key.StartsWith(prefix, StringComparison.Ordinal)));
if (matching.Count == 0) return;
db.ServerConfig.RemoveRange(matching);
await db.SaveChangesAsync();
}
public static async Task<bool> DeleteAsync(RecDBContext db, long clubId)
{
var clubs = await LoadAsync(db);
if (clubs.RemoveAll(club => club.clubId == clubId) == 0) return false;
await SaveAsync(db, clubs);
await ClearHomeClubsAsync(db, clubId);
return true;
}
public static async Task<ClubRecord?> UpdateAsync(RecDBContext db, long clubId, JsonObject body)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
if (club == null) return null;
if (GetNonEmptyString(body, "name") is { } name) club.name = name.Trim();
if (GetNonEmptyString(body, "description") is { } description) club.description = description;
if (GetNonEmptyString(body, "category", "primaryTag") is { } category) club.primaryTag = category.Trim();
if (GetNonEmptyString(body, "mainImageName") is { } imageName) club.imageName = imageName.Trim();
if (GetNonEmptyString(body, "bannerImageName") is { } bannerName) club.bannerImageName = bannerName.Trim();
if (GetInt(body, "visibility") is { } visibility) club.visibility = visibility;
if (GetInt(body, "joinability") is { } joinability) club.joinability = joinability;
if (GetInt(body, "minLevel") is { } minLevel && minLevel >= 0) club.minLevel = minLevel;
if (GetBool(body, "allowJuniors") is { } allowJuniors) club.allowJuniors = allowJuniors;
if (GetStringArray(body, "customTags") is { } customTags) club.customTags = NormalizeTags(customTags);
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> SetClubhouseAsync(RecDBContext db, long clubId, long? roomId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
if (club == null) return null;
club.clubhouseRoomId = roomId;
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> SetAdditionalImageAsync(RecDBContext db, long clubId, int index, string imageName)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
if (club == null) return null;
var images = (club.additionalImages ?? Array.Empty<string>()).ToList();
if (string.IsNullOrWhiteSpace(imageName))
{
if (index < images.Count) images.RemoveAt(index);
}
else if (index < images.Count)
{
images[index] = imageName.Trim();
}
else if (images.Count < 3)
{
images.Add(imageName.Trim());
}
club.additionalImages = images.ToArray();
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> InviteMemberAsync(RecDBContext db, long clubId, long accountId, int membershipType, long inviterAccountId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
if (club == null || club.ownerPlayerId == accountId) return null;
club.members ??= new List<ClubMemberRecord>();
var membership = club.members.FirstOrDefault(item => item.accountId == accountId);
if (membership == null)
{
membership = new ClubMemberRecord { accountId = accountId, createdAt = DateTime.UtcNow };
club.members.Add(membership);
}
membership.membershipType = ClubMembershipTypes.PendingInvited;
membership.pendingMembershipType = membershipType;
membership.inviterAccountId = inviterAccountId;
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> AcceptInvitationAsync(RecDBContext db, long clubId, long accountId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
var membership = club?.members?.FirstOrDefault(item => item.accountId == accountId);
if (club == null || membership?.membershipType != ClubMembershipTypes.PendingInvited) return null;
var grantedType = membership.pendingMembershipType is ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner
? membership.pendingMembershipType
: ClubMembershipTypes.Member;
membership.membershipType = grantedType;
membership.pendingMembershipType = 0;
membership.createdAt = DateTime.UtcNow;
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> RequestToJoinAsync(RecDBContext db, long clubId, long accountId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
if (club == null) return null;
var current = GetMembershipType(club, accountId);
if (current == ClubMembershipTypes.Banned || current >= ClubMembershipTypes.Member || current == ClubMembershipTypes.PendingRequested)
return club;
if (club.joinability == 1) return club;
return await SetMemberTypeAsync(db, clubId, accountId,
club.joinability == 0 ? ClubMembershipTypes.Member : ClubMembershipTypes.PendingRequested,
accountId);
}
private static string? GetString(JsonObject body, params string[] keys)
{
foreach (var pair in body)
{
if (!keys.Any(key => pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) || pair.Value == null) continue;
if (pair.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var value)) return value;
return pair.Value.ToString();
}
return null;
}
private static string? GetNonEmptyString(JsonObject body, params string[] keys)
{
var value = GetString(body, keys);
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static int? GetInt(JsonObject body, params string[] keys)
{
var raw = GetString(body, keys)?.Trim();
if (int.TryParse(raw, out var value)) return value;
return raw?.ToLowerInvariant() switch
{
"private" => 0,
"public" => 1,
"open" => 0,
"inviteonly" or "invite_only" => 1,
"asktojoin" or "requesttojoin" or "ask_to_join" => 2,
_ => null
};
}
private static bool? GetBool(JsonObject body, params string[] keys)
{
var raw = GetString(body, keys)?.Trim();
if (bool.TryParse(raw, out var value)) return value;
return raw?.ToLowerInvariant() switch
{
"1" or "yes" => true,
"0" or "no" => false,
_ => null
};
}
private static string[]? GetStringArray(JsonObject body, params string[] keys)
{
foreach (var pair in body)
{
if (!keys.Any(key => pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase))) continue;
if (pair.Value is JsonArray array)
return NormalizeTags(array.Select(item => item?.ToString() ?? string.Empty));
return NormalizeTags(new[] { pair.Value?.ToString() ?? string.Empty });
}
return null;
}
private static string[] NormalizeTags(IEnumerable<string> tags)
{
var output = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in tags)
{
var tag = raw.Trim();
if (tag.Length > 0 && seen.Add(tag)) output.Add(tag);
}
return output.ToArray();
}
public static async Task SaveAsync(RecDBContext db, List<ClubRecord> clubs)
{
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == ConfigKey);
if (config == null)
{
config = new ServerConfigVal(ConfigKey, "[]");
await db.ServerConfig.AddAsync(config);
}
config.Value = clubs.ToJson();
await db.SaveChangesAsync();
}
public static async Task<ClubRecord?> GetAsync(RecDBContext db, long clubId)
{
var clubs = await LoadAsync(db);
return clubs.FirstOrDefault(x => x.clubId == clubId);
}
public static async Task<ClubRecord> CreateAsync(
RecDBContext db,
long ownerPlayerId,
string? name,
string? description,
string? primaryTag = null,
int visibility = 1,
int joinability = 0,
bool allowJuniors = true,
int minLevel = 0,
string? mainImageName = null)
{
var clubs = await LoadAsync(db);
var clubName = string.IsNullOrWhiteSpace(name) ? "Radicade Club" : name.Trim();
var tag = string.IsNullOrWhiteSpace(primaryTag) ? "Social" : primaryTag.Trim();
var now = DateTime.UtcNow;
var clubId = Math.Abs((long)HashCode.Combine(ownerPlayerId, clubName, now.Ticks));
while (clubId == 0 || clubs.Any(x => x.clubId == clubId))
{
clubId = Math.Abs((long)HashCode.Combine(ownerPlayerId, clubName, Guid.NewGuid()));
}
var club = new ClubRecord
{
clubId = clubId,
name = clubName,
description = description?.Trim() ?? "",
primaryTag = tag,
imageName = string.IsNullOrWhiteSpace(mainImageName) ? DefaultImageName : mainImageName.Trim(),
visibility = visibility,
joinability = joinability,
allowJuniors = allowJuniors,
minLevel = minLevel,
ownerPlayerId = ownerPlayerId,
createdAt = now
};
club.members.Add(new ClubMemberRecord
{
accountId = ownerPlayerId,
membershipType = ClubMembershipTypes.Creator,
inviterAccountId = ownerPlayerId,
createdAt = now
});
clubs.Add(club);
await SaveAsync(db, clubs);
return club;
}
public static int GetMembershipType(ClubRecord? club, long accountId)
{
if (club == null || club.clubId <= 0 || accountId <= 0) return ClubMembershipTypes.None;
if (club.ownerPlayerId == accountId) return ClubMembershipTypes.Creator;
return club.members?.FirstOrDefault(x => x.accountId == accountId)?.membershipType ?? ClubMembershipTypes.None;
}
public static int GetMemberCount(ClubRecord? club)
{
if (club == null || club.clubId <= 0) return 0;
return 1 + (club.members ?? new List<ClubMemberRecord>())
.Where(x => x.accountId != club.ownerPlayerId && x.membershipType >= ActiveMemberThreshold)
.Select(x => x.accountId)
.Distinct()
.Count();
}
public static IReadOnlyList<ClubMemberView> GetMembers(ClubRecord? club)
{
if (club == null || club.clubId <= 0) return Array.Empty<ClubMemberView>();
var members = new List<ClubMemberView>
{
new(club.ownerPlayerId, ClubMembershipTypes.Creator, club.ownerPlayerId, club.createdAt)
};
members.AddRange((club.members ?? new List<ClubMemberRecord>())
.Where(x => x.accountId != club.ownerPlayerId)
.GroupBy(x => x.accountId)
.Select(group => group.Last())
.Select(x => new ClubMemberView(
x.accountId,
x.membershipType,
x.inviterAccountId,
NormalizeMembershipDate(x.createdAt, club.createdAt))));
return members;
}
private static DateTime NormalizeMembershipDate(DateTime memberCreatedAt, DateTime clubCreatedAt)
{
if (memberCreatedAt > DateTime.MinValue) return memberCreatedAt;
if (clubCreatedAt > DateTime.MinValue) return clubCreatedAt;
return DateTime.UnixEpoch;
}
public static async Task<ClubRecord?> SetMemberTypeAsync(
RecDBContext db,
long clubId,
long accountId,
int membershipType,
long inviterAccountId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
if (club == null || club.ownerPlayerId == accountId) return null;
club.members ??= new List<ClubMemberRecord>();
var membership = club.members.FirstOrDefault(x => x.accountId == accountId);
if (membership == null)
{
club.members.Add(new ClubMemberRecord
{
accountId = accountId,
membershipType = membershipType,
inviterAccountId = inviterAccountId,
createdAt = DateTime.UtcNow
});
}
else
{
if (membership.membershipType < ClubMembershipTypes.Member && membershipType >= ClubMembershipTypes.Member)
membership.createdAt = DateTime.UtcNow;
membership.membershipType = membershipType;
membership.inviterAccountId = inviterAccountId;
}
await SaveAsync(db, clubs);
return club;
}
public static async Task<ClubRecord?> RemoveMemberAsync(RecDBContext db, long clubId, long accountId)
{
var clubs = await LoadAsync(db);
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
if (club == null || club.ownerPlayerId == accountId) return null;
club.members ??= new List<ClubMemberRecord>();
club.members.RemoveAll(member => member.accountId == accountId && member.membershipType != ClubMembershipTypes.Banned);
await SaveAsync(db, clubs);
return club;
}
private static long ToUnix(DateTime value) => new DateTimeOffset(value).ToUnixTimeSeconds();
public static object BuildClub(ClubRecord? club, long viewerId = 0)
{
club ??= new ClubRecord { clubId = 0 };
var memberCount = GetMemberCount(club);
return new Dictionary<string, object?>
{
["ClubId"] = club.clubId,
["Name"] = club.name,
["Description"] = club.description,
["MainImageName"] = club.imageName,
["State"] = 0,
["CreatorAccountId"] = club.ownerPlayerId,
["Category"] = club.primaryTag,
["Visibility"] = club.visibility,
["Joinability"] = club.joinability,
["AllowJuniors"] = club.allowJuniors,
["MinLevel"] = club.minLevel,
["MemberCount"] = memberCount,
["IsRRO"] = false,
["ClubhouseRoomId"] = club.clubhouseRoomId,
["ClubType"] = 0
};
}
public static async Task<object> BuildClubDetailsAsync(RecDBContext db, ClubRecord? club, long viewerId = 0)
{
club ??= new ClubRecord { clubId = 0 };
var details = (Dictionary<string, object?>)BuildClubDetails(club, viewerId);
details["AdditionalImages"] = await GetAdditionalImagesAsync(db, club.additionalImages ?? Array.Empty<string>());
return details;
}
public static object BuildClubDetails(ClubRecord? club, long viewerId = 0)
{
club ??= new ClubRecord { clubId = 0 };
return new Dictionary<string, object?>
{
["Club"] = BuildClub(club, viewerId),
["ClubId"] = club.clubId,
["CustomTags"] = club.customTags ?? Array.Empty<string>(),
["AdditionalImages"] = Array.Empty<object>(),
["CoownerPermissions"] = BuildPermissions(club.clubId, 0),
["ModeratorPermissions"] = BuildPermissions(club.clubId, 1),
["MemberPermissions"] = BuildPermissions(club.clubId, 2),
["MyMembershipType"] = GetMembershipType(club, viewerId)
};
}
private static async Task<object[]> GetAdditionalImagesAsync(RecDBContext db, string[] imageNames)
{
if (imageNames.Length == 0) return Array.Empty<object>();
var records = await db.Images.AsNoTracking()
.Where(image => imageNames.Contains(image.Name))
.ToDictionaryAsync(image => image.Name);
var imageIds = records.Values.Select(image => image.Id).ToList();
var cheers = await db.ImageInteractions.AsNoTracking()
.Where(interaction => interaction.IsCheered && imageIds.Contains(interaction.SavedImageId))
.GroupBy(interaction => interaction.SavedImageId)
.Select(group => new { ImageId = group.Key, Count = group.Count() })
.ToDictionaryAsync(group => group.ImageId, group => group.Count);
return imageNames.Select(name =>
{
if (!records.TryGetValue(name, out var image))
return (object)new Dictionary<string, object?>
{
["Id"] = 0L, ["Type"] = 1,
["Accessibility"] = (int)SavedImageAccessibility.Public,
["AccessibilityLocked"] = false, ["ImageName"] = name,
["Description"] = null, ["PlayerId"] = 0L,
["TaggedPlayerIds"] = Array.Empty<long>(), ["RoomId"] = null,
["PlayerEventId"] = null, ["CreatedAt"] = DateTime.UnixEpoch,
["CheerCount"] = 0, ["CommentCount"] = 0
};
return (object)new Dictionary<string, object?>
{
["Id"] = image.Id, ["Type"] = (int)image.ImageType,
["Accessibility"] = (int)image.Accessibility, ["AccessibilityLocked"] = false,
["ImageName"] = image.Name, ["Description"] = null, ["PlayerId"] = image.OwnerId,
["TaggedPlayerIds"] = ParseImagePlayerIds(image.PlayerIds), ["RoomId"] = image.RoomId,
["PlayerEventId"] = image.EventId, ["CreatedAt"] = image.CreatedAt,
["CheerCount"] = cheers.GetValueOrDefault(image.Id), ["CommentCount"] = 0
};
}).ToArray();
}
private static long[] ParseImagePlayerIds(string? playerIds)
{
try { return JsonSerializer.Deserialize<long[]>(playerIds ?? "[]") ?? Array.Empty<long>(); }
catch { return Array.Empty<long>(); }
}
public static object BuildPermissions(long clubId, int type)
{
var isCoowner = type == 0;
var isModerator = type == 1;
return new Dictionary<string, object?>
{
["Type"] = type switch
{
0 => ClubMembershipTypes.Coowner,
1 => ClubMembershipTypes.Moderator,
_ => ClubMembershipTypes.Member
},
["ClubId"] = clubId,
["ApproveMember"] = isCoowner || isModerator,
["BanUnban"] = isCoowner || isModerator,
["CreateEvent"] = isCoowner,
["EditDetails"] = isCoowner,
["EditPermissionSettings"] = isCoowner,
["PostAnnouncement"] = isCoowner
};
}
public static object BuildMemberListItem(long playerId, int role, bool isOwner, long clubId = 0, long created = 0)
{
var type = isOwner ? ClubMembershipTypes.Creator : role;
var createdAt = created > 0 ? DateTimeOffset.FromUnixTimeSeconds(created).UtcDateTime.ToString("O") : null;
return new Dictionary<string, object?>
{
["ClubMemberId"] = playerId,
["ClubId"] = clubId,
["AccountId"] = playerId,
["MembershipType"] = type,
["CreatedAt"] = createdAt
};
}
public static object BuildMemberListItem(ClubMemberView member, long clubId) =>
BuildMemberListItem(
member.AccountId,
member.MembershipType,
member.MembershipType == ClubMembershipTypes.Creator,
clubId,
ToUnix(member.CreatedAt));
public static object BuildMember(long playerId, int role, bool isOwner, long clubId = 0, long created = 0) => new Dictionary<string, object?>
{
["AccountId"] = playerId,
["ClubId"] = clubId,
["CreatedAt"] = created > 0 ? DateTimeOffset.FromUnixTimeSeconds(created).UtcDateTime.ToString("O") : null,
["MembershipType"] = isOwner ? ClubMembershipTypes.Creator : role,
["membershipRequestId"] = 0L,
["inviterAccountId"] = 0L,
["subscriptionId"] = clubId,
["playerId"] = playerId,
["username"] = string.Empty,
["displayName"] = string.Empty,
["level"] = 0,
["role"] = isOwner ? ClubMembershipTypes.Creator : role,
["membershipStatus"] = role >= ActiveMemberThreshold || isOwner ? 1 : 0,
["isOwner"] = isOwner,
["created"] = created,
["createdAt"] = created
};
public static object BuildMember(ClubMemberView membership, ClubRecord? club, Profile? profile)
{
var role = membership.MembershipType;
var created = ToUnix(membership.CreatedAt);
var isOwner = role == ClubMembershipTypes.Creator;
return new Dictionary<string, object?>
{
["AccountId"] = membership.AccountId,
["ClubId"] = club?.clubId ?? 0L,
["CreatedAt"] = membership.CreatedAt.ToString("O"),
["MembershipType"] = role,
["membershipRequestId"] = 0L,
["inviterAccountId"] = membership.InviterAccountId,
["subscriptionId"] = club?.clubId ?? 0L,
["playerId"] = membership.AccountId,
["username"] = profile?.username ?? string.Empty,
["displayName"] = profile?.displayName ?? string.Empty,
["level"] = 0,
["role"] = role,
["membershipStatus"] = role >= ActiveMemberThreshold || isOwner ? 1 : 0,
["isOwner"] = isOwner,
["created"] = created,
["createdAt"] = created
};
}
public static object BuildMembership(ClubRecord? club, long viewerId = 0)
{
club ??= new ClubRecord { clubId = 0 };
var role = GetMembershipType(club, viewerId);
var isMember = role >= ActiveMemberThreshold;
return new Dictionary<string, object?>
{
["accountId"] = viewerId,
["clubId"] = club.clubId,
["creatorAccountId"] = club.ownerPlayerId,
["membershipType"] = role,
["LastAnnouncementId"] = 0L,
["LastReadAnnouncementId"] = 0L,
["announcements"] = Array.Empty<object>(),
["subscriptionId"] = club.clubId
};
}
public static object BuildSubscriptionEnvelope(ClubRecord? club, long viewerId = 0) => new Dictionary<string, object?>
{
["success"] = club != null,
["error"] = string.Empty,
["value"] = club == null ? null : BuildMembership(club, viewerId)
};
public static object BuildSearchResponse(object[] clubs, int skip, int take) => new Dictionary<string, object?>
{
["Clubs"] = clubs,
["TotalClubs"] = clubs.Length,
["ContinuationToken"] = null
};
public static object BuildHome(ClubRecord? homeClub, long viewerId, IReadOnlyList<ClubRecord> allClubs, IReadOnlyList<ClubRecord> mine)
{
return BuildClub(homeClub, viewerId);
}
public static object BuildEmptyHome(long viewerId)
{
return BuildClub(null, viewerId);
}
public static object BuildModerationBlockDetails() => new Dictionary<string, object?>
{
["Duration"] = 0L,
["GameSessionId"] = 0L,
["PlayerIdReporter"] = 0L,
["IsBan"] = false,
["IsHostKick"] = false,
["Message"] = string.Empty,
["ReportCategory"] = 0,
["VoteKickReason"] = string.Empty,
["bannedUntil"] = string.Empty,
["displayReason"] = string.Empty,
["banType"] = string.Empty,
["banAppealCode"] = string.Empty,
["banAppealText"] = string.Empty
};
public static object BuildEligibility() => new Dictionary<string, object?>
{
["canCreateClub"] = true,
["canCreate"] = true,
["creationAllowed"] = true,
["isAllowed"] = true,
["isCreationRestricted"] = false,
["creationRestricted"] = false,
["moderationRestricted"] = false,
["moderationIncidentCount"] = 0,
["myCreatedClubsCount"] = 0,
["maxCreatedClubs"] = 100,
["cooldownSeconds"] = 0,
["restrictionReason"] = string.Empty
};
}
+183
View File
@@ -0,0 +1,183 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using System.Text.Json;
namespace RadicadeServer.Clubs;
[Route("clubs/subscription")]
[Route("clubs/subscriptions")]
[Route("api/clubs/v1/subscription")]
[Route("api/clubs/v2/subscription")]
[Route("api/clubs/v1/subscriptions")]
[Route("api/clubs/v2/subscriptions")]
public class ClubSubscriptionAPI(RecDBContext db) : Controller
{
private static readonly string[] TargetIdKeys =
{
"playerId", "PlayerId", "accountId", "AccountId", "creatorAccountId", "CreatorAccountId",
"subscriptionId", "SubscriptionId", "id", "Id", "targetPlayerId", "TargetPlayerId"
};
[HttpGet("mine/member")]
[HttpGet("my/member")]
public IActionResult GetMyMemberships() => Ok(Array.Empty<object>());
[HttpGet("my/subscriptions")]
public async Task<IActionResult> GetMySubscriptions()
{
var userId = User.GetUserId();
if (userId == null) return Ok(Array.Empty<long>());
return Ok(await PlayerSubscriptions.SubscriptionIdsAsync(db, userId.Value));
}
[HttpGet("my/subscribers")]
public async Task<IActionResult> GetMySubscribers()
{
var userId = User.GetUserId();
if (userId == null) return Ok(Array.Empty<long>());
return Ok(await PlayerSubscriptions.SubscriberIdsAsync(db, userId.Value));
}
[HttpGet("details/{accountId:long}")]
public async Task<IActionResult> GetAccountSubscriptionDetails(long accountId) => Ok(new
{
clubId = PlayerSubscriptions.PersonalClubId(accountId),
accountId,
subscriberCount = await PlayerSubscriptions.SubscriberCountAsync(db, accountId)
});
[HttpGet("details/{subscription}")]
public IActionResult GetNamedSubscriptionDetails(string subscription) => Ok(new { });
[HttpGet("subscriberCount/{accountId:long}")]
public async Task<IActionResult> GetSubscriberCount(long accountId)
=> Ok(await PlayerSubscriptions.SubscriberCountAsync(db, accountId));
[HttpGet("top/creators")]
public async Task<IActionResult> GetTopCreators() => Ok(await PlayerSubscriptions.TopCreatorsAsync(db, 100));
[HttpGet("top/creators/count")]
public async Task<IActionResult> GetTopCreatorCount()
=> Ok((await PlayerSubscriptions.TopCreatorsAsync(db, int.MaxValue)).Count);
[HttpPost("{playerId:long}"), Authorize]
public async Task<IActionResult> Subscribe(long playerId)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
if (playerId > 0)
await PlayerSubscriptions.SubscribeAsync(db, userId.Value, playerId);
return Ok(new
{
value = PlayerSubscriptions.PersonalClubId(playerId),
success = true,
errorId = (object?)null,
error = (object?)null
});
}
[HttpPost(""), Authorize]
public async Task<IActionResult> SubscribeBare()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var playerId = await ReadTargetPlayerIdAsync();
if (playerId > 0)
await PlayerSubscriptions.SubscribeAsync(db, userId.Value, playerId);
return Ok(new
{
value = PlayerSubscriptions.PersonalClubId(playerId),
success = true,
errorId = (object?)null,
error = (object?)null
});
}
[HttpDelete("{playerId:long}"), Authorize]
[HttpPost("{playerId:long}/unsubscribe"), Authorize]
public async Task<IActionResult> Unsubscribe(long playerId)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
if (playerId > 0)
await PlayerSubscriptions.UnsubscribeAsync(db, userId.Value, playerId);
return Ok(new { success = true, errorId = (object?)null, error = (object?)null });
}
[HttpDelete(""), Authorize]
public async Task<IActionResult> UnsubscribeBare()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var playerId = await ReadTargetPlayerIdAsync();
if (playerId > 0)
await PlayerSubscriptions.UnsubscribeAsync(db, userId.Value, playerId);
return Ok(new { success = true, errorId = (object?)null, error = (object?)null });
}
private async Task<long> ReadTargetPlayerIdAsync()
{
foreach (var key in TargetIdKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (long.TryParse(value, out var fromQuery) && fromQuery > 0) return fromQuery;
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in TargetIdKeys)
{
var value = form[key].FirstOrDefault();
if (long.TryParse(value, out var fromForm) && fromForm > 0) return fromForm;
}
return 0;
}
if (Request.ContentLength is not > 0) return 0;
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
if (Request.Body.CanSeek) Request.Body.Position = 0;
if (string.IsNullOrWhiteSpace(body)) return 0;
var trimmed = body.Trim();
if (trimmed.StartsWith("{"))
{
using var document = JsonDocument.Parse(trimmed);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in document.RootElement.EnumerateObject())
{
if (!TargetIdKeys.Any(key => key.Equals(property.Name, StringComparison.OrdinalIgnoreCase))) continue;
if (long.TryParse(property.Value.ToString(), out var fromBody) && fromBody > 0) return fromBody;
}
}
}
else if (long.TryParse(trimmed, out var fromRaw) && fromRaw > 0)
{
return fromRaw;
}
}
catch
{
}
return 0;
}
}
+935
View File
@@ -0,0 +1,935 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Enums.Messages;
using RadicadeServer.RecNet.Messages;
using System.Text.Json.Nodes;
namespace RadicadeServer.Clubs;
[Route("clubs/club")]
[Route("api/clubs/v1/club")]
[Route("api/clubs/v2/club")]
public class ClubsAPI : Controller
{
private readonly RecDBContext db;
public ClubsAPI(RecDBContext db)
{
this.db = db;
}
private long ViewerId => User.GetUserId() ?? 0;
[HttpGet("home/me"), Authorize]
[HttpGet("~/club/home/me"), Authorize]
[HttpGet("~/api/clubs/v1/home/me"), Authorize]
[HttpGet("~/api/clubs/v2/home/me"), Authorize]
public async Task<IActionResult> GetMyClubHome()
{
var homeClub = await ClubStore.GetHomeClubAsync(db, ViewerId);
return homeClub == null
? NotFound()
: Ok(ClubStore.BuildClub(homeClub, ViewerId));
}
[HttpPut("~/clubs/club/home/me"), Authorize]
[HttpPut("~/club/home/me"), Authorize]
[HttpPut("~/api/clubs/v1/home/me"), Authorize]
[HttpPut("~/api/clubs/v2/home/me"), Authorize]
public async Task<IActionResult> SetMyClubHome()
{
var body = await ReadBodyObjectAsync();
var clubId = ReadLong(body, "clubId", "ClubId");
if (clubId is null or <= 0) return BadRequest(new { error = "Invalid clubId.", success = false, value = (object?)null });
var club = await ClubStore.GetAsync(db, clubId.Value);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Member)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You are not a member of that club.", success = false, value = (object?)null });
var updated = await ClubStore.SetHomeClubAsync(db, ViewerId, clubId.Value);
return Ok(new { error = "", success = true, value = ClubStore.BuildClub(updated, ViewerId) });
}
[HttpDelete("~/clubs/club/home/me"), Authorize]
[HttpDelete("~/club/home/me"), Authorize]
[HttpDelete("~/api/clubs/v1/home/me"), Authorize]
[HttpDelete("~/api/clubs/v2/home/me"), Authorize]
public async Task<IActionResult> ClearMyClubHome()
{
await ClubStore.SetHomeClubAsync(db, ViewerId, null);
return Ok(new { error = "", success = true, value = (object?)null });
}
[HttpGet("{clubId}"), Authorize]
[HttpGet("~/api/clubs/v1/{clubId:long}"), Authorize]
[HttpGet("~/api/clubs/v2/{clubId:long}"), Authorize]
public async Task<IActionResult> GetClub(long clubId)
{
return Ok(ClubStore.BuildClub(await ClubStore.GetAsync(db, clubId), ViewerId));
}
[HttpGet("{clubId}/details"), Authorize]
[HttpGet("~/club/{clubId:long}/details"), Authorize]
[HttpGet("~/api/clubs/v1/{clubId:long}/details"), Authorize]
[HttpGet("~/api/clubs/v2/{clubId:long}/details"), Authorize]
public async Task<IActionResult> GetClubDetails(long clubId)
{
return Ok(await ClubStore.BuildClubDetailsAsync(db, await ClubStore.GetAsync(db, clubId), ViewerId));
}
[HttpGet("{clubId:long}/mainimage")]
[HttpGet("~/club/{clubId:long}/mainimage")]
public async Task<IActionResult> GetClubMainImage(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
return Ok(new
{
error = string.Empty,
success = true,
value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId)
});
}
[HttpPut("{clubId:long}/mainimage"), Authorize]
[HttpPut("~/club/{clubId:long}/mainimage"), Authorize]
public async Task<IActionResult> SetClubMainImage(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
var imageName = ReadString(body, "imageName", "ImageName")?.Trim();
if (string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "imageName is required.", success = false, value = (object?)null });
var updated = await ClubStore.UpdateAsync(db, clubId, new JsonObject { ["mainImageName"] = imageName });
if (updated == null) return NotFound();
return Ok(new
{
error = string.Empty,
success = true,
value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId)
});
}
[HttpGet("{clubId}/members")]
[HttpGet("~/club/{clubId:long}/members")]
[HttpGet("~/api/clubs/v1/{clubId:long}/members")]
[HttpGet("~/api/clubs/v2/{clubId:long}/members")]
public async Task<IActionResult> GetClubMembers(long clubId, [FromQuery] int? membershipType = null, [FromQuery] int? sortBy = null)
{
var club = await ClubStore.GetAsync(db, clubId);
var members = ClubStore.GetMembers(club);
if (membershipType.HasValue)
members = members.Where(member => member.MembershipType == membershipType.Value).ToArray();
members = sortBy switch
{
1 => members.OrderBy(member => member.AccountId).ToArray(),
2 => members.OrderBy(member => member.CreatedAt).ToArray(),
_ => members.OrderByDescending(member => member.MembershipType).ThenBy(member => member.CreatedAt).ToArray()
};
var values = members
.Select(member => ClubStore.BuildMemberListItem(member, clubId))
.ToArray();
return Ok(new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = values
});
}
[HttpGet("{clubId}/hasDisabledClubChat"), Authorize]
[HttpGet("~/api/clubs/v1/{clubId:long}/hasDisabledClubChat"), Authorize]
[HttpGet("~/api/clubs/v2/{clubId:long}/hasDisabledClubChat"), Authorize]
public IActionResult GetHasDisabledClubChat(long clubId)
{
return Content("false", "application/json");
}
[HttpGet("{clubId:long}/members/{playerId:long}"), Authorize]
[HttpGet("~/club/{clubId:long}/members/{playerId:long}"), Authorize]
[HttpGet("~/api/clubs/v1/{clubId:long}/members/{playerId:long}"), Authorize]
[HttpGet("~/api/clubs/v2/{clubId:long}/members/{playerId:long}"), Authorize]
public async Task<IActionResult> GetClubMembership(long clubId, long playerId)
{
var club = await ClubStore.GetAsync(db, clubId);
var role = ClubStore.GetMembershipType(club, playerId);
var membership = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == playerId);
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId);
return Ok(new
{
clubId = clubId,
creatorAccountId = club?.ownerPlayerId ?? 0L,
membershipType = role,
LastAnnouncementId = 0L,
LastReadAnnouncementId = 0L,
announcements = Array.Empty<object>(),
subscriptionId = clubId,
playerId = playerId,
membershipStatus = role >= ClubMembershipTypes.Member ? 1 : 0,
member = membership == null ? null : ClubStore.BuildMember(membership, club, profile),
role = role,
subscribed = role >= ClubMembershipTypes.Member,
created = membership == null ? 0L : new DateTimeOffset(membership.CreatedAt).ToUnixTimeSeconds(),
createdAt = membership == null ? 0L : new DateTimeOffset(membership.CreatedAt).ToUnixTimeSeconds()
});
}
[HttpGet("mine/created"), Authorize]
[HttpGet("~/api/clubs/v1/mine/created"), Authorize]
[HttpGet("~/api/clubs/v2/mine/created"), Authorize]
public async Task<IActionResult> GetMyCreatedClubs()
{
var clubs = await ClubStore.LoadAsync(db);
return Ok(clubs.Where(x => x.ownerPlayerId == ViewerId).Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
}
[HttpGet("mine/member"), Authorize]
[HttpGet("~/api/clubs/v1/mine/member"), Authorize]
[HttpGet("~/api/clubs/v2/mine/member"), Authorize]
public async Task<IActionResult> GetMyMemberClubs()
{
var clubs = await ClubStore.LoadAsync(db);
return Ok(clubs
.Where(club => ClubStore.GetMembershipType(club, ViewerId) >= ClubMembershipTypes.Member)
.Select(club => ClubStore.BuildClub(club, ViewerId))
.ToArray());
}
[HttpGet("account/{accountId}/created"), Authorize]
[HttpGet("~/clubs/account/{accountId:long}/created"), Authorize]
[HttpGet("~/api/clubs/v1/account/{accountId:long}/created"), Authorize]
[HttpGet("~/api/clubs/v2/account/{accountId:long}/created"), Authorize]
public async Task<IActionResult> GetClubsCreatedByAccount(long accountId)
{
var clubs = await ClubStore.LoadAsync(db);
return Ok(clubs.Where(x => x.ownerPlayerId == accountId).Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
}
[HttpGet("~/clubs/account/{accountId:long}/clubs")]
[HttpGet("~/api/clubs/v1/account/{accountId:long}/clubs")]
[HttpGet("~/api/clubs/v2/account/{accountId:long}/clubs")]
public Task<IActionResult> GetClubsForAccount(long accountId) => GetClubsCreatedByAccount(accountId);
[HttpGet("mostactivetoday"), Authorize]
[HttpGet("~/api/clubs/v1/mostactivetoday"), Authorize]
[HttpGet("~/api/clubs/v2/mostactivetoday"), Authorize]
public async Task<IActionResult> GetMostActiveToday()
{
var clubs = await ClubStore.LoadAsync(db);
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
return Ok(clubs.Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
}
[HttpGet("mostactivenow"), Authorize]
public async Task<IActionResult> GetMostActiveNow()
{
var clubs = await ClubStore.LoadAsync(db);
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
return Ok(clubs.Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
}
[HttpGet("categoryTags"), Authorize]
[HttpGet("~/api/clubs/v1/categoryTags"), Authorize]
[HttpGet("~/api/clubs/v2/categoryTags"), Authorize]
public IActionResult GetCategoryTags()
{
return Ok(new[] { "Social", "Casual", "Competitive", "Creative", "Entertainment", "Educational", "Lifestyle" });
}
[HttpGet("search"), Authorize]
[HttpGet("~/api/clubs/v1/search"), Authorize]
[HttpGet("~/api/clubs/v2/search"), Authorize]
public async Task<IActionResult> SearchClubs(
[FromQuery] string? query = null,
[FromQuery] int sort = 0,
[FromQuery] string? category = null,
[FromQuery] int skip = 0,
[FromQuery] int count = 30,
[FromQuery] int take = 0)
{
var limit = take > 0 ? take : count;
var clubs = await ClubStore.LoadAsync(db);
if (!string.IsNullOrWhiteSpace(query))
{
clubs = clubs
.Where(x => x.name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
x.description.Contains(query, StringComparison.OrdinalIgnoreCase))
.ToList();
}
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query) ?? category);
var items = clubs
.Skip(Math.Max(0, skip))
.Take(Math.Max(1, limit))
.Select(x => ClubStore.BuildClub(x, ViewerId))
.ToArray();
return Ok(ClubStore.BuildSearchResponse(items, Math.Max(0, skip), Math.Max(1, limit)));
}
[HttpGet("cancreate"), Authorize]
[HttpGet("can-create"), Authorize]
[HttpGet("create/eligibility"), Authorize]
[HttpGet("creation/eligibility"), Authorize]
[HttpGet("~/api/clubs/v1/cancreate"), Authorize]
[HttpGet("~/api/clubs/v1/can-create"), Authorize]
[HttpGet("~/api/clubs/v1/create/eligibility"), Authorize]
[HttpGet("~/api/clubs/v1/creation/eligibility"), Authorize]
[HttpGet("~/api/clubs/v2/cancreate"), Authorize]
[HttpGet("~/api/clubs/v2/can-create"), Authorize]
[HttpGet("~/api/clubs/v2/create/eligibility"), Authorize]
[HttpGet("~/api/clubs/v2/creation/eligibility"), Authorize]
public IActionResult CanCreateClub()
{
return Ok(ClubStore.BuildEligibility());
}
[HttpPut("{clubId:long}/members/invite"), Authorize]
[HttpPut("{clubId:long}/members/invitemembers"), Authorize]
[HttpPost("{clubId:long}/members/invitemembers"), Authorize]
[HttpPut("~/club/{clubId:long}/members/invite"), Authorize]
[HttpPut("~/club/{clubId:long}/members/invitemembers"), Authorize]
[HttpPost("~/club/{clubId:long}/members/invitemembers"), Authorize]
public async Task<IActionResult> InviteMember(long clubId)
{
var inviterId = ViewerId;
if (inviterId <= 0) return Unauthorized();
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
var inviterRole = ClubStore.GetMembershipType(club, inviterId);
if (inviterRole < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var (rawAccountIds, rawMembershipType) = await ReadInviteMemberFieldsAsync();
if (rawAccountIds.Count == 0)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
var membershipType = string.IsNullOrWhiteSpace(rawMembershipType)
? ClubMembershipTypes.Member
: ParseInviteMembershipType(rawMembershipType);
if (membershipType is not (ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner))
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
if (membershipType > inviterRole)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var updated = club;
foreach (var rawAccountId in rawAccountIds)
{
if (!long.TryParse(rawAccountId, out var accountId) || accountId <= 0)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
if (accountId == club.ownerPlayerId)
return BadRequest(new { error = "You can’t change the club’s creator.", success = false, value = (object?)null });
if (ClubStore.GetMembershipType(updated, accountId) >= ClubMembershipTypes.Member)
{
updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId, membershipType, inviterId) ?? updated;
continue;
}
updated = await ClubStore.InviteMemberAsync(db, clubId, accountId, membershipType, inviterId) ?? updated;
await Notify.SendMessage(accountId, new Message
{
FromPlayerId = inviterId,
Type = membershipType switch
{
ClubMembershipTypes.Coowner => MessageType.ClubCoownerInvited,
ClubMembershipTypes.Moderator => MessageType.ClubModeratorInvited,
_ => MessageType.ClubMemberInvited
},
Data = clubId.ToString(System.Globalization.CultureInfo.InvariantCulture),
Details = membershipType.ToString(System.Globalization.CultureInfo.InvariantCulture)
});
}
return Ok(new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviterId)
});
}
[HttpPut("{clubId:long}/members/changetype"), Authorize]
[HttpPost("{clubId:long}/members/changetype"), Authorize]
[HttpPut("~/club/{clubId:long}/members/changetype"), Authorize]
[HttpPost("~/club/{clubId:long}/members/changetype"), Authorize]
public async Task<IActionResult> ChangeMemberType(long clubId)
{
var actorId = ViewerId;
if (actorId <= 0) return Unauthorized();
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
var actorRole = ClubStore.GetMembershipType(club, actorId);
if (actorRole < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId")
?? ReadLongFromRequest(Request, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
if (accountId is null or <= 0 || accountId == club.ownerPlayerId)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
if (ClubStore.GetMembershipType(club, accountId.Value) < ClubMembershipTypes.Member)
return BadRequest(new { error = "That player is not a member of this club.", success = false, value = (object?)null });
var requested = ReadLong(body, "membershipType", "MembershipType", "type", "Type", "role", "Role")
?? ReadLongFromRequest(Request, "membershipType", "MembershipType", "type", "Type", "role", "Role");
if (requested is null or < 0)
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
var membershipType = (int)requested.Value;
if (membershipType is not (ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner))
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
if (membershipType > actorRole)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, membershipType, actorId);
if (updated == null) return NotFound();
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, actorId) });
}
[HttpPost("{clubId:long}/members/acceptinvite"), Authorize]
[HttpPut("{clubId:long}/members/acceptinvite"), Authorize]
[HttpPost("{clubId:long}/members/accept"), Authorize]
[HttpPut("{clubId:long}/members/accept"), Authorize]
[HttpPost("~/club/{clubId:long}/members/acceptinvite"), Authorize]
[HttpPut("~/club/{clubId:long}/members/acceptinvite"), Authorize]
[HttpPost("~/club/{clubId:long}/members/accept"), Authorize]
[HttpPut("~/club/{clubId:long}/members/accept"), Authorize]
public async Task<IActionResult> AcceptClubInvitation(long clubId)
{
var inviteeId = ViewerId;
if (inviteeId <= 0) return Unauthorized();
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
var pending = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == inviteeId);
if (pending?.MembershipType != ClubMembershipTypes.PendingInvited)
return BadRequest(new { error = "No pending invitation exists.", success = false, value = (object?)null });
var updated = await ClubStore.AcceptInvitationAsync(db, clubId, inviteeId);
if (updated == null) return NotFound();
return Ok(new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviteeId)
});
}
[HttpPost("{clubId:long}/members/declineinvite"), Authorize]
[HttpPut("{clubId:long}/members/declineinvite"), Authorize]
[HttpPost("{clubId:long}/members/decline"), Authorize]
[HttpDelete("{clubId:long}/members/invite"), Authorize]
[HttpPost("~/club/{clubId:long}/members/declineinvite"), Authorize]
[HttpPut("~/club/{clubId:long}/members/declineinvite"), Authorize]
[HttpPost("~/club/{clubId:long}/members/decline"), Authorize]
[HttpDelete("~/club/{clubId:long}/members/invite"), Authorize]
public async Task<IActionResult> DeclineClubInvitation(long clubId)
{
var inviteeId = ViewerId;
if (inviteeId <= 0) return Unauthorized();
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
var pending = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == inviteeId);
if (pending?.MembershipType != ClubMembershipTypes.PendingInvited)
return BadRequest(new { error = "No pending invitation exists.", success = false, value = (object?)null });
var updated = await ClubStore.RemoveMemberAsync(db, clubId, inviteeId);
return Ok(new Dictionary<string, object?>
{
["error"] = string.Empty,
["success"] = true,
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviteeId)
});
}
private static readonly string[] InviteAccountIdKeys =
{
"accountId", "AccountId", "accountIds", "AccountIds", "accountId[]", "AccountId[]",
"playerId", "PlayerId", "playerIds", "PlayerIds", "invitedPlayerId", "InvitedPlayerId",
"invitedPlayerIds", "InvitedPlayerIds", "invitedPlayerIds[]", "InvitedPlayerIds[]",
"targetPlayerIds", "TargetPlayerIds"
};
private static int ParseInviteMembershipType(string raw)
{
if (int.TryParse(raw, out var numeric)) return numeric;
return raw.Trim().ToLowerInvariant() switch
{
"member" or "regular" or "invited" => ClubMembershipTypes.Member,
"moderator" or "mod" => ClubMembershipTypes.Moderator,
"coowner" or "co-owner" or "co_owner" or "co owner" => ClubMembershipTypes.Coowner,
_ => -1
};
}
private async Task<(List<string> AccountIds, string? MembershipType)> ReadInviteMemberFieldsAsync()
{
var accountIds = new List<string>();
string? membershipType = null;
void Collect(string key, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
if (key.Equals("membershipType", StringComparison.OrdinalIgnoreCase))
{
membershipType ??= value;
return;
}
if (!InviteAccountIdKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) return;
foreach (var part in value.Split(new[] { ',', '|', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out _) && !accountIds.Contains(part)) accountIds.Add(part);
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var field in form)
foreach (var value in field.Value)
Collect(field.Key, value);
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
if (await JsonNode.ParseAsync(Request.Body) is JsonObject body)
foreach (var pair in body)
{
if (pair.Value is JsonArray array)
foreach (var item in array) Collect(pair.Key, item?.ToString());
else
Collect(pair.Key, pair.Value?.ToString());
}
Request.Body.Position = 0;
}
catch
{
try { Request.Body.Position = 0; } catch { }
}
}
foreach (var key in InviteAccountIdKeys)
foreach (var value in Request.Query[key]) Collect(key, value);
membershipType ??= Request.Query["membershipType"].FirstOrDefault()
?? Request.Query["MembershipType"].FirstOrDefault();
return (accountIds, membershipType);
}
[HttpPost("create"), Authorize]
[HttpPost("~/club/create"), Authorize]
[HttpPost("createclub"), Authorize]
[HttpPost("~/api/clubs/v1/create"), Authorize]
[HttpPost("~/api/clubs/v1/createclub"), Authorize]
[HttpPost("~/api/clubs/v2/create"), Authorize]
[HttpPost("~/api/clubs/v2/createclub"), Authorize]
public async Task<IActionResult> CreateClub([FromForm] string? name = null, [FromForm] string? description = null)
{
var userId = ViewerId;
if (userId <= 0) return Unauthorized("Missing user ID.");
var fields = await ReadCreateClubFields(name, description);
var club = await ClubStore.CreateAsync(
db,
userId,
fields.Name,
fields.Description,
fields.Category,
fields.Visibility ?? 1,
fields.Joinability ?? 0,
fields.AllowJuniors ?? true,
fields.MinLevel ?? 0,
fields.MainImageName);
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, userId) });
}
private async Task<(string? Name, string? Description, string? Category, int? Visibility, int? Joinability, bool? AllowJuniors, int? MinLevel, string? MainImageName)> ReadCreateClubFields(string? name, string? description)
{
string? category = null;
string? visibility = null;
string? joinability = null;
string? allowJuniors = null;
string? minLevel = null;
string? mainImageName = null;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
name ??= FirstFormValue(form, "name", "Name", "clubName", "ClubName");
description ??= FirstFormValue(form, "description", "Description", "clubDescription", "ClubDescription");
category = FirstFormValue(form, "category", "Category", "primaryTag", "PrimaryTag");
visibility = FirstFormValue(form, "visibility", "Visibility");
joinability = FirstFormValue(form, "joinability", "Joinability");
allowJuniors = FirstFormValue(form, "allowJuniors", "AllowJuniors");
minLevel = FirstFormValue(form, "minLevel", "MinLevel");
mainImageName = FirstFormValue(form, "mainImageName", "MainImageName");
}
else if (Request.ContentLength is > 0)
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(rawBody) && JsonNode.Parse(rawBody) is JsonObject body)
{
name ??= FirstJsonValue(body, "name", "Name", "clubName", "ClubName");
description ??= FirstJsonValue(body, "description", "Description", "clubDescription", "ClubDescription");
category = FirstJsonValue(body, "category", "Category", "primaryTag", "PrimaryTag");
visibility = FirstJsonValue(body, "visibility", "Visibility");
joinability = FirstJsonValue(body, "joinability", "Joinability");
allowJuniors = FirstJsonValue(body, "allowJuniors", "AllowJuniors");
minLevel = FirstJsonValue(body, "minLevel", "MinLevel");
mainImageName = FirstJsonValue(body, "mainImageName", "MainImageName");
}
}
name ??= Request.Query["name"].FirstOrDefault() ?? Request.Query["Name"].FirstOrDefault() ??
Request.Query["clubName"].FirstOrDefault() ?? Request.Query["ClubName"].FirstOrDefault();
description ??= Request.Query["description"].FirstOrDefault() ?? Request.Query["Description"].FirstOrDefault() ??
Request.Query["clubDescription"].FirstOrDefault() ?? Request.Query["ClubDescription"].FirstOrDefault();
category ??= Request.Query["category"].FirstOrDefault() ?? Request.Query["Category"].FirstOrDefault() ??
Request.Query["primaryTag"].FirstOrDefault() ?? Request.Query["PrimaryTag"].FirstOrDefault();
return (
name,
description,
category,
ParseVisibility(visibility),
ParseJoinability(joinability),
ParseFormBool(allowJuniors),
int.TryParse(minLevel, out var parsedLevel) && parsedLevel >= 0 ? parsedLevel : null,
mainImageName);
}
private static int? ParseVisibility(string? raw) => raw?.Trim().ToLowerInvariant() switch
{
"private" or "0" => 0,
"public" or "1" => 1,
_ => null
};
private static int? ParseJoinability(string? raw) => raw?.Trim().ToLowerInvariant().Replace("_", "") switch
{
"open" or "0" => 0,
"inviteonly" or "1" => 1,
"asktojoin" or "requesttojoin" or "2" => 2,
_ => null
};
private static bool? ParseFormBool(string? raw) => raw?.Trim().ToLowerInvariant() switch
{
"true" or "1" or "yes" => true,
"false" or "0" or "no" => false,
_ => null
};
[HttpPut("{clubId:long}/minlevel"), Authorize]
[HttpPut("~/club/{clubId:long}/minlevel"), Authorize]
public async Task<IActionResult> SetMinimumLevel(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var rawMinLevel = await FirstFormValueAsync(Request, "minLevel", "MinLevel");
if (!int.TryParse(rawMinLevel, out var minLevel) || minLevel < 0)
{
var body = await ReadBodyObjectAsync();
var parsed = ReadLong(body, "minLevel", "MinLevel");
if (parsed is null or < 0 or > int.MaxValue)
return BadRequest(new { error = "Invalid minLevel.", success = false, value = (object?)null });
minLevel = (int)parsed.Value;
}
var updated = await ClubStore.UpdateAsync(db, clubId, new JsonObject { ["minLevel"] = minLevel });
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpPut("{clubId:long}/modifydetails"), Authorize]
[HttpPut("{clubId:long}/modify"), Authorize]
[HttpPut("~/club/{clubId:long}/modifydetails"), Authorize]
[HttpPut("~/club/{clubId:long}/modify"), Authorize]
public async Task<IActionResult> ModifyClub(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
if (body == null) return BadRequest(new { error = "Invalid request.", success = false, value = (object?)null });
var updated = await ClubStore.UpdateAsync(db, clubId, body);
if (updated == null) return NotFound();
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpPut("{clubId:long}/clubhouse"), Authorize]
[HttpDelete("{clubId:long}/clubhouse"), Authorize]
[HttpPut("~/club/{clubId:long}/clubhouse"), Authorize]
[HttpDelete("~/club/{clubId:long}/clubhouse"), Authorize]
public async Task<IActionResult> SetClubhouse(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
long? roomId = null;
if (!HttpMethods.IsDelete(Request.Method))
{
var body = await ReadBodyObjectAsync();
var raw = ReadLong(body, "roomId", "RoomId");
if (body?.Count > 0 && raw == null)
return BadRequest(new { error = "Invalid roomId.", success = false, value = (object?)null });
roomId = raw;
}
var updated = await ClubStore.SetClubhouseAsync(db, clubId, roomId);
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpPut("{clubId:long}/additionalimage/{index:int}"), Authorize]
[HttpDelete("{clubId:long}/additionalimage/{index:int}"), Authorize]
[HttpPut("~/club/{clubId:long}/additionalimage/{index:int}"), Authorize]
[HttpDelete("~/club/{clubId:long}/additionalimage/{index:int}"), Authorize]
public async Task<IActionResult> SetAdditionalImage(long clubId, int index)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
if (index < 0 || index >= 3)
return BadRequest(new { error = "Invalid additional image slot.", success = false, value = (object?)null });
var body = HttpMethods.IsDelete(Request.Method) ? null : await ReadBodyObjectAsync();
var imageName = body == null ? "" : ReadString(body, "imageName", "ImageName") ?? "";
var updated = await ClubStore.SetAdditionalImageAsync(db, clubId, index, imageName);
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpDelete("{clubId:long}"), Authorize]
[HttpDelete("~/club/{clubId:long}"), Authorize]
public async Task<IActionResult> DeleteClub(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (club.ownerPlayerId != ViewerId)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
await ClubStore.DeleteAsync(db, clubId);
return Ok(new { error = "", success = true, value = (object?)null });
}
[HttpPut("{clubId:long}/members/requesttojoin"), Authorize]
[HttpPut("~/club/{clubId:long}/members/requesttojoin"), Authorize]
public async Task<IActionResult> RequestToJoinClub(long clubId)
{
var existingClub = await ClubStore.GetAsync(db, clubId);
if (existingClub == null) return NotFound();
ClubRecord? club;
if (ClubStore.GetMembershipType(existingClub, ViewerId) == ClubMembershipTypes.PendingInvited)
club = await ClubStore.AcceptInvitationAsync(db, clubId, ViewerId);
else
club = await ClubStore.RequestToJoinAsync(db, clubId, ViewerId);
if (club == null) return NotFound();
var role = ClubStore.GetMembershipType(club, ViewerId);
if (role == ClubMembershipTypes.Banned)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You are banned from this club.", success = false, value = (object?)null });
if (club.joinability == 1 && role < ClubMembershipTypes.Member)
return BadRequest(new { error = "This club is invite only.", success = false, value = (object?)null });
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId) });
}
[HttpPost("{clubId:long}/members/leave"), Authorize]
[HttpPost("~/club/{clubId:long}/members/leave"), Authorize]
public async Task<IActionResult> LeaveClub(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (club.ownerPlayerId == ViewerId)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You created this club — delete it instead of leaving.", success = false, value = (object?)null });
club = await ClubStore.RemoveMemberAsync(db, clubId, ViewerId) ?? club;
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId) });
}
[HttpPost("{clubId:long}/members/remove"), Authorize]
[HttpDelete("{clubId:long}/members/remove"), Authorize]
[HttpPost("~/club/{clubId:long}/members/remove"), Authorize]
[HttpDelete("~/club/{clubId:long}/members/remove"), Authorize]
public async Task<IActionResult> RemoveClubMember(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
var actorRole = ClubStore.GetMembershipType(club, ViewerId);
if (actorRole < ClubMembershipTypes.Moderator)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId")
?? ReadLongFromRequest(Request, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
if (accountId is null or <= 0 || accountId == club.ownerPlayerId || accountId == ViewerId)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
var targetRole = ClubStore.GetMembershipType(club, accountId.Value);
if (targetRole >= ClubMembershipTypes.Moderator && actorRole < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var updated = await ClubStore.RemoveMemberAsync(db, clubId, accountId.Value);
return updated == null
? NotFound()
: Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpPost("{clubId:long}/members/ban"), Authorize]
[HttpPost("~/club/{clubId:long}/members/ban"), Authorize]
public async Task<IActionResult> BanMember(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Moderator)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
if (accountId is null or <= 0 || accountId == club.ownerPlayerId || accountId == ViewerId)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
var targetRole = ClubStore.GetMembershipType(club, accountId.Value);
if (targetRole >= ClubMembershipTypes.Moderator && ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, ClubMembershipTypes.Banned, ViewerId);
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
[HttpPost("{clubId:long}/members/unban"), Authorize]
[HttpPost("~/club/{clubId:long}/members/unban"), Authorize]
public async Task<IActionResult> UnbanMember(long clubId)
{
var club = await ClubStore.GetAsync(db, clubId);
if (club == null) return NotFound();
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Moderator)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
var body = await ReadBodyObjectAsync();
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
if (accountId is null or <= 0)
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, ClubMembershipTypes.None, ViewerId);
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
}
private async Task<JsonObject?> ReadBodyObjectAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var jsonForm = new JsonObject();
foreach (var pair in form)
{
if (pair.Value.Count > 1)
{
var values = new JsonArray();
foreach (var value in pair.Value) values.Add(value);
jsonForm[pair.Key] = values;
}
else
{
jsonForm[pair.Key] = pair.Value.FirstOrDefault();
}
}
return jsonForm;
}
if (Request.ContentLength is not > 0) return new JsonObject();
try
{
Request.EnableBuffering();
var body = await JsonNode.ParseAsync(Request.Body);
Request.Body.Position = 0;
return body as JsonObject;
}
catch
{
try { Request.Body.Position = 0; } catch { }
return null;
}
}
private static string? ReadString(JsonObject? body, params string[] keys)
{
if (body == null) return null;
foreach (var key in keys)
foreach (var pair in body)
if (pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase))
return pair.Value?.ToString();
return null;
}
private static long? ReadLong(JsonObject? body, params string[] keys)
{
var value = ReadString(body, keys);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private static long? ReadLongFromRequest(HttpRequest request, params string[] keys)
{
foreach (var key in keys)
if (long.TryParse(request.Query[key].FirstOrDefault(), out var value)) return value;
return null;
}
private static async Task<string?> FirstFormValueAsync(HttpRequest request, params string[] keys)
{
if (!request.HasFormContentType) return null;
var form = await request.ReadFormAsync();
foreach (var key in keys)
foreach (var pair in form)
if (pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) return pair.Value.FirstOrDefault();
return null;
}
private static string? FirstFormValue(IFormCollection form, params string[] keys)
{
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private static string? FirstJsonValue(JsonObject? json, params string[] keys)
{
if (json == null) return null;
foreach (var key in keys)
{
if (json.TryGetPropertyValue(key, out var value))
{
var text = value?.ToString();
if (!string.IsNullOrWhiteSpace(text)) return text;
}
}
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
namespace RadicadeServer.Clubs;
[Route("clubs")]
[Route("api/clubs/v1")]
[Route("api/clubs/v2")]
public class ClubsSearchAPI : Controller
{
private readonly RecDBContext db;
public ClubsSearchAPI(RecDBContext db)
{
this.db = db;
}
private long ViewerId => User.GetUserId() ?? 0;
[HttpGet("search/mostactivenow"), Authorize]
[HttpGet("mostactivenow"), Authorize]
public async Task<IActionResult> GetMostActiveClubs([FromQuery] int skip = 0, [FromQuery] int take = 30)
{
var clubs = await ClubStore.LoadAsync(db);
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
var items = clubs
.Skip(Math.Max(0, skip))
.Take(Math.Max(1, take))
.Select(x => ClubStore.BuildClub(x, ViewerId))
.ToArray();
return Ok(items);
}
}
+117
View File
@@ -0,0 +1,117 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Config;
namespace RadicadeServer.Clubs;
public sealed class PlayerSubscriptionRecord
{
public long subscriberAccountId { get; set; }
public long creatorAccountId { get; set; }
public DateTime createdAt { get; set; } = DateTime.UtcNow;
}
public static class PlayerSubscriptions
{
private const string ConfigKey = "PlayerSubscriptions";
private const long PersonalClubIdBase = 1_000_000_000_000L;
public static long PersonalClubId(long accountId) => accountId > 0 ? PersonalClubIdBase + accountId : 0;
public static bool IsPersonalClubId(long clubId) => clubId >= PersonalClubIdBase;
public static long? AccountIdForPersonalClub(long clubId) =>
IsPersonalClubId(clubId) ? clubId - PersonalClubIdBase : null;
public static async Task<List<PlayerSubscriptionRecord>> LoadAsync(RecDBContext db)
{
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == ConfigKey);
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<PlayerSubscriptionRecord>();
try
{
return config.Value.FromJson<List<PlayerSubscriptionRecord>>() ?? new List<PlayerSubscriptionRecord>();
}
catch
{
return new List<PlayerSubscriptionRecord>();
}
}
private static async Task SaveAsync(RecDBContext db, List<PlayerSubscriptionRecord> subscriptions)
{
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == ConfigKey);
if (config == null)
{
config = new ServerConfigVal(ConfigKey, "[]");
await db.ServerConfig.AddAsync(config);
}
config.Value = subscriptions.ToJson();
await db.SaveChangesAsync();
}
public static async Task<bool> SubscribeAsync(RecDBContext db, long subscriberId, long creatorId)
{
if (subscriberId <= 0 || creatorId <= 0 || subscriberId == creatorId) return false;
var subscriptions = await LoadAsync(db);
if (subscriptions.Any(x => x.subscriberAccountId == subscriberId && x.creatorAccountId == creatorId))
return true;
subscriptions.Add(new PlayerSubscriptionRecord
{
subscriberAccountId = subscriberId,
creatorAccountId = creatorId
});
await SaveAsync(db, subscriptions);
return true;
}
public static async Task<bool> UnsubscribeAsync(RecDBContext db, long subscriberId, long creatorId)
{
var subscriptions = await LoadAsync(db);
if (subscriptions.RemoveAll(x => x.subscriberAccountId == subscriberId && x.creatorAccountId == creatorId) == 0)
return false;
await SaveAsync(db, subscriptions);
return true;
}
public static async Task<List<long>> SubscriptionIdsAsync(RecDBContext db, long accountId) =>
(await LoadAsync(db))
.Where(x => x.subscriberAccountId == accountId)
.Select(x => x.creatorAccountId)
.Distinct()
.ToList();
public static async Task<List<long>> SubscriberIdsAsync(RecDBContext db, long accountId) =>
(await LoadAsync(db))
.Where(x => x.creatorAccountId == accountId)
.Select(x => x.subscriberAccountId)
.Distinct()
.ToList();
public static async Task<int> SubscriberCountAsync(RecDBContext db, long accountId) =>
(await SubscriberIdsAsync(db, accountId)).Count;
public static async Task<List<object>> TopCreatorsAsync(RecDBContext db, int limit)
{
var creators = (await LoadAsync(db))
.GroupBy(x => x.creatorAccountId)
.Select(group => new
{
clubId = PersonalClubId(group.Key),
accountId = group.Key,
subscriberCount = group.Select(x => x.subscriberAccountId).Distinct().Count()
})
.OrderByDescending(creator => creator.subscriberCount)
.ThenBy(creator => creator.accountId)
.Take(limit)
.ToList();
return creators.Cast<object>().ToList();
}
}