feat: Add new API controllers for progression events, room consumables, currencies, earnings distributions, screen share reports, subscription seasons, and UGC purchasables

- Implemented ProgressionEventsController to handle progression event retrieval and active events.
- Created RoomConsumablesController for listing and purchasing room consumables.
- Developed RoomCurrenciesController for managing room currencies, including creation, updates, and balance retrieval.
- Added RoomEarningsDistributionsController to fetch earnings distribution data for rooms.
- Introduced ScreenShareReportsController for reporting screen share incidents.
- Established SubscriptionSeasonsController to provide current subscription season details and rewards.
- Launched UgcPurchasablesController for managing user-generated content purchasables, including bulk operations.
- Added new data entities for customization and room economy, including custom avatar items, room currencies, and UGC purchasables.
This commit is contained in:
Alexa
2026-06-22 16:08:23 +03:00
parent 157cc457a9
commit 70980bf3b6
53 changed files with 4470 additions and 91 deletions
@@ -106,9 +106,11 @@ public class AccountApiController(
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AgeVerification;
[ApiController]
[Route("api/ageverification")]
[Authorize]
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("generateCode")]
[HttpGet("generateCode")]
public async Task<IActionResult> GenerateCode()
{
var pid = this.RequireCurrentPlayerId();
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AppIntegrity;
[ApiController]
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/AppIntegrity/v1/iosproducts")]
[AllowAnonymous]
public async Task<IActionResult> IosProducts()
{
var rows = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.Id)
.Take(100)
.Select(i => new
{
ProductId = i.Slug,
i.Price,
i.CurrencyType,
Name = i.DisplayName,
})
.ToListAsync();
return Ok(rows);
}
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
[Authorize]
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
{
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = this.RequireCurrentPlayerId(),
Title = "iOS payment queue failed",
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
Category = "appintegrity",
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Apple;
[ApiController]
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/apple/musicpromotion/active")]
[AllowAnonymous]
public IActionResult Active()
{
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var end = start.AddYears(1);
return Ok(new { Active = true, StartAt = start, EndAt = end });
}
[HttpPost("api/apple/musicpromotion/code")]
[HttpGet("api/apple/musicpromotion/code")]
[Authorize]
public async Task<IActionResult> Code()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
if (row is null)
{
row = new PlayerSettingEntity
{
PlayerId = me,
Key = "applemusic:promotion_code",
Value = $"RR-{Guid.NewGuid():N}"[..16],
};
db.PlayerSettings.Add(row);
await db.SaveChangesAsync();
}
return Ok(new { Code = row.Value, Redeemed = false });
}
}
@@ -186,6 +186,22 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
return Ok(result);
}
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous]
public IActionResult DefaultBaseAvatarItems()
{
var rows = _catalog.Value.Items.Values
.OrderBy(i => i.AvatarItemType)
.ThenBy(i => i.FriendlyName)
.Take(100)
.ToList();
return Ok(rows);
}
[HttpGet("api/avatar/v1/defaultunlocked")]
[AllowAnonymous]
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
@@ -73,6 +73,11 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
});
}
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
[AllowAnonymous]
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json");
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/config")]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
@@ -0,0 +1,369 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController]
[Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")]
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
DomainConfig domain) : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
.Where(i => i.IsPublic);
if (needle.Length > 0)
{
rows = rows.Where(i =>
i.Name.ToLower().Contains(needle) ||
i.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpGet("{id:guid}")]
[AllowAnonymous]
public async Task<IActionResult> Get(Guid id)
{
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
return Ok(ToWire(item));
}
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
{
var ids = await ReadIdsAsync(body);
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.CustomAvatarItems
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("featured")]
[AllowAnonymous]
public async Task<IActionResult> Featured([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic && i.IsFeatured)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("hot")]
[AllowAnonymous]
public async Task<IActionResult> Hot([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CheerCount)
.ThenByDescending(i => i.PurchaseCount)
.ThenByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("new")]
[AllowAnonymous]
public async Task<IActionResult> New([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me()
{
var pid = this.RequireCurrentPlayerId();
var created = await db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
var ownedIds = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Select(o => o.CustomAvatarItemId)
.ToListAsync();
var owned = ownedIds.Count == 0
? new List<CustomAvatarItemEntity>()
: await db.CustomAvatarItems
.Where(i => ownedIds.Contains(i.Id))
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(new
{
Created = created.Select(ToWire),
Owned = owned.Select(ToWire),
});
}
[HttpGet("owned")]
[Authorize]
public async Task<IActionResult> Owned()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("isCreationAllowedForAccount")]
[Authorize]
public async Task<IActionResult> IsCreationAllowedForAccount()
{
var pid = this.RequireCurrentPlayerId();
var player = await db.Players
.Where(p => p.Id == pid)
.Select(p => new { p.IsJunior, p.BannedUntil })
.FirstOrDefaultAsync();
var allowed = player is not null
&& !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
}
[HttpGet("isCreationEnabled")]
[AllowAnonymous]
public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true });
[HttpGet("isRenderingEnabled")]
[AllowAnonymous]
public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true });
[HttpGet("minPriceForPublicItem")]
[AllowAnonymous]
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
[HttpPost("design")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Design()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
if (name.Length > 128) name = name[..128];
var description = (req.Description ?? string.Empty).Trim();
if (description.Length > 1024) description = description[..1024];
CustomAvatarItemEntity item;
if (req.Id is Guid id)
{
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
if (item.Id == 0) db.CustomAvatarItems.Add(item);
}
else
{
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
db.CustomAvatarItems.Add(item);
}
item.Name = name;
item.Description = description;
item.Price = Math.Max(100, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
item.ImageName = Trim(req.ImageName, 256);
item.AssetName = Trim(req.AssetName, 256);
item.IsPublic = req.IsPublic ?? item.IsPublic;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await EnsureOwnershipAsync(pid, item.Id);
return Ok(ToWire(item));
}
[HttpPost("{id:guid}/purchase")]
[Authorize]
public async Task<IActionResult> Purchase(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
if (item is null) return NotFound();
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
await EnsureOwnershipAsync(pid, item.Id);
item.PurchaseCount += 1;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
}
private async Task EnsureOwnershipAsync(long playerId, long itemId)
{
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
{
return;
}
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
{
PlayerId = playerId,
CustomAvatarItemId = itemId,
});
await db.SaveChangesAsync();
}
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
{
var result = new List<Guid>();
if (body?.Ids is { Count: > 0 })
result.AddRange(body.Ids);
if (body?.ItemIds is { Count: > 0 })
result.AddRange(body.ItemIds);
foreach (var pair in Request.Query)
foreach (var value in pair.Value)
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
}
return result.Distinct().Take(200).ToList();
}
private async Task<DesignRequest> ReadDesignRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new DesignRequest
{
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<DesignRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new DesignRequest();
}
catch (JsonException)
{
return new DesignRequest();
}
}
private object ToWire(CustomAvatarItemEntity item) => new
{
Id = item.PublicId,
CustomAvatarItemId = item.PublicId,
CreatorId = (int)item.CreatorPlayerId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.Price,
item.ItemType,
item.BaseAvatarItemId,
item.Color,
item.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
item.AssetName,
item.IsPublic,
item.IsFeatured,
item.CheerCount,
item.PurchaseCount,
item.CreatedAt,
item.UpdatedAt,
Status = item.IsPublic ? 1 : 0,
};
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class BulkRequest
{
public List<Guid>? Ids { get; set; }
public List<Guid>? ItemIds { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int? Price { get; set; }
public int? ItemType { get; set; }
public int? BaseAvatarItemId { get; set; }
public string? Color { get; set; }
public string? ImageName { get; set; }
public string? AssetName { get; set; }
public bool? IsPublic { get; set; }
}
}
@@ -1,11 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController : ControllerBase
public class EventsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("list")]
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.Select(e => new
{
EventId = e.Id,
PlayerEventId = e.Id,
Name = e.Title,
e.Description,
StartTime = e.StartsAt,
EndTime = e.EndsAt,
CreatorPlayerId = (int)e.CreatorPlayerId,
e.RoomId,
e.Capacity,
})
.ToListAsync();
return Ok(rows.Cast<object>().ToList());
}
}
@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
[ApiController]
[Authorize]
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
{
var code = Guid.NewGuid().ToString("N")[..10];
var key = $"externalinvite:platform:{code}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = key,
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty });
}
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
{
var code = Guid.NewGuid().ToString("N")[..10];
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:text:{code}",
Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
}
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
private static object ToInviteWire(PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new
{
InviteCode = row.Key.Split(':').Last(),
Kind = row.Key.Contains(":text:") ? "text" : "platform",
Value = parts.FirstOrDefault() ?? string.Empty,
CreatedAt = parts.LastOrDefault() ?? string.Empty,
};
}
}
@@ -0,0 +1,124 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.FreeGifts;
[ApiController]
[Authorize]
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
[HttpPost("api/freegifts/v1/sendmultiple")]
public async Task<IActionResult> SendMultiple()
{
var fields = await ReadFieldsAsync();
var sender = this.RequireCurrentPlayerId();
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
if (recipients.Count == 0) return BadRequest("missing_recipients");
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
var message = ReadString(fields, "message", "Message") ?? string.Empty;
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
foreach (var recipient in recipients.Distinct().Take(100))
{
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = (int)sender,
CurrencyType = currency > 0 ? currencyType : 0,
Currency = currency,
Xp = xp,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = message,
Platform = -1,
PackageVariant = "FreeGift",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
created.Add((recipient, gift));
}
await db.SaveChangesAsync();
foreach (var row in created)
{
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
}
return Ok(new { Success = true, Sent = created.Count });
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(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 static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(100)
.ToList();
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.GameSight;
[ApiController]
[Authorize]
public class GameSightController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/gamesight/event")]
[AllowAnonymous]
public async Task<IActionResult> Event([FromBody] object? body)
{
var playerId = this.CurrentPlayerId();
if (playerId is null)
return Ok(new { Success = true });
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId.Value,
Key = $"gamesight:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
}
@@ -274,6 +274,48 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
public sealed class ImageBulkRequest
{
public List<long>? ImageIds { get; set; }
public List<long>? SavedImageIds { get; set; }
}
[HttpGet("api/images/v5/bulk")]
[HttpPost("api/images/v5/bulk")]
[Authorize]
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
.Concat(Request.Query.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0))
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Photos
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
&& (p.IsPublic || p.UploaderPlayerId == pid))
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
[HttpGet("api/images/v6")]
[AllowAnonymous]
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
@@ -424,6 +466,7 @@ public class ImagesController(
}
[HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
[ApiController]
[Authorize]
[Route("api/incentivizedreferrals")]
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("")]
[HttpGet("progress")]
public async Task<IActionResult> Progress()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
var claimed = await db.PlayerSettings
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
return Ok(new
{
ReferralCount = referralCount,
RequiredReferralCount = 1,
CanClaim = referralCount >= 1 && !claimed,
Claimed = claimed,
RewardCurrencyType = 2,
RewardCurrency = 250,
});
}
[HttpPost("claim")]
public async Task<IActionResult> Claim()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
if (referralCount < 1)
return Ok(new { Success = false, Error = "not_enough_referrals" });
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
return Ok(new { Success = false, Error = "already_claimed" });
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = "referral:reward_claimed",
Value = DateTime.UtcNow.ToString("O"),
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
}
}
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
[ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct()
.Take(take)
.ToListAsync();
var players = await db.Players
.Where(p => creatorIds.Contains(p.Id))
.OrderBy(p => p.Username)
.ToListAsync();
return Ok(players.Select(ToInfluencerWire));
}
[HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
{
var player = accountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (player is null) return NotFound();
return Ok(ToInfluencerWire(player));
}
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
[Authorize]
public async Task<IActionResult> MyInfluencer()
{
var me = this.RequireCurrentPlayerId();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null || !long.TryParse(setting.Value, out var influencerId))
return Ok(new { Influencer = (object?)null });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId);
return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) });
}
[HttpPost("api/influencerpartnerprogram/support")]
[Authorize]
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
{
var me = this.RequireCurrentPlayerId();
var influencer = influencerAccountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (influencer is null) return NotFound();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "influencer:supported",
Value = influencer.Id.ToString(),
});
}
else
{
setting.Value = influencer.Id.ToString();
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) });
}
[HttpPost("api/influencerpartnerprogram/remove")]
[Authorize]
public async Task<IActionResult> Remove()
{
var me = this.RequireCurrentPlayerId();
await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
.ExecuteDeleteAsync();
return Ok(new { Success = true });
}
private static object ToInfluencerWire(PlayerEntity player) => new
{
AccountId = (int)player.Id,
player.Username,
DisplayName = player.DisplayName,
ProfileImage = player.ProfileImageName ?? string.Empty,
};
}
@@ -40,6 +40,9 @@ public class InventionsController(
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v1/featured")]
[HttpGet("api/inventions/v1/featureddormskins")]
[HttpGet("api/inventions/v1/toptoday")]
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
@@ -78,6 +81,7 @@ public class InventionsController(
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
[HttpGet("api/inventions/v2/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
@@ -127,6 +131,8 @@ public class InventionsController(
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
[HttpPost("api/inventions/v2/batch")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
if (req?.InventionIds is null || req.InventionIds.Count == 0)
@@ -138,6 +144,27 @@ public class InventionsController(
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v1/fromcreators")]
public async Task<ActionResult> FromCreators()
{
var creatorIds = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
@@ -356,6 +383,7 @@ public class InventionsController(
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[HttpPost("api/inventions/v6/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
{
@@ -426,6 +454,7 @@ public class InventionsController(
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
@@ -491,6 +520,22 @@ public class InventionsController(
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public async Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
@@ -685,7 +730,7 @@ public class InventionsController(
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
Price = 0,
i.Price,
HideFromPlayer = false,
};
@@ -182,6 +182,44 @@ public class InventoryController(
return Ok(new { row.Id, Count = row.Quantity });
}
[HttpPost("api/consumables/v1/transfer")]
public async Task<IActionResult> TransferConsumable(
[FromForm] long? id,
[FromForm] long? recipientPlayerId,
[FromForm] int quantity = 1)
{
var pid = Me;
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, quantity);
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
return BadRequest("invalid_transfer");
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
if (source is null || source.Quantity < amount) return NotFound();
var target = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
if (target is null)
{
target = new PlayerInventoryEntity
{
PlayerId = recipient,
ItemSlug = source.ItemSlug,
Quantity = 0,
};
db.PlayerInventory.Add(target);
}
source.Quantity -= amount;
target.Quantity += amount;
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists;
[ApiController]
[Route("api/itemWishlists")]
[Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("v1/wishlist/me")]
public async Task<IActionResult> Mine()
{
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == Me)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("v1/wishlist")]
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
{
var pid = playerId is > 0 ? playerId.Value : Me;
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == pid)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost]
[HttpPost("v1/wishlist")]
public async Task<IActionResult> Add()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
if (key.Length > 128) key = key[..128];
var itemType = req.ItemType ?? 0;
var exists = await db.ItemWishlists.AnyAsync(w =>
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
if (!exists)
{
db.ItemWishlists.Add(new ItemWishlistEntity
{
PlayerId = Me,
ItemKey = key,
ItemType = itemType,
});
await db.SaveChangesAsync();
}
return await Mine();
}
[HttpDelete("v1/wishlist")]
[HttpPost("v1/wishlist/remove")]
public async Task<IActionResult> Remove()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
var itemType = req.ItemType ?? 0;
await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
.ExecuteDeleteAsync();
return await Mine();
}
private async Task<WishlistRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new WishlistRequest
{
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
{
return new WishlistRequest
{
ItemKey = queryKey,
ItemId = queryId,
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new WishlistRequest();
}
catch (JsonException)
{
return new WishlistRequest();
}
}
private static object ToWire(ItemWishlistEntity row) => new
{
row.Id,
row.PlayerId,
row.ItemKey,
ItemId = row.ItemKey,
row.ItemType,
row.CreatedAt,
};
public sealed class WishlistRequest
{
public string? ItemKey { get; set; }
public string? ItemId { get; set; }
public int? ItemType { get; set; }
}
}
@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Keepsakes;
[ApiController]
[Route("api/keepsakes")]
[Authorize]
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
public async Task<IActionResult> Mine()
{
await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me)
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories() => Ok(new[]
{
new { Category = "account", DisplayName = "Account" },
new { Category = "event", DisplayName = "Events" },
new { Category = "room", DisplayName = "Rooms" },
});
[HttpGet("events")]
public async Task<IActionResult> Events()
{
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category == "event")
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
Enabled = true,
Categories = new[] { "account", "event", "room" },
CdnBaseUrl = $"https://{domain.Sub("cdn")}/",
});
[HttpPost]
public async Task<IActionResult> Create()
{
var req = await ReadRequestAsync();
var key = Trim(req.EventKey, 128);
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
var category = Trim(req.Category, 64);
if (category.Length == 0) category = "event";
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
k.PlayerId == Me && k.EventKey == key);
if (existing is null)
{
existing = new KeepsakeEntity
{
PlayerId = Me,
EventKey = key,
};
db.Keepsakes.Add(existing);
}
existing.Category = category;
existing.Title = Trim(req.Title, 128);
existing.Description = Trim(req.Description, 1024);
existing.ImageName = Trim(req.ImageName, 256);
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(existing));
}
private async Task EnsureLoginKeepsakeAsync(long playerId)
{
const string key = "first-login";
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
return;
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = playerId,
Category = "account",
EventKey = key,
Title = "First Login",
Description = "Joined this server.",
});
await db.SaveChangesAsync();
}
private async Task<KeepsakeRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new KeepsakeRequest
{
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new KeepsakeRequest();
}
catch (JsonException)
{
return new KeepsakeRequest();
}
}
private object ToWire(KeepsakeEntity row) => new
{
row.Id,
row.PlayerId,
row.Category,
row.EventKey,
row.Title,
row.Description,
row.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
row.EarnedAt,
};
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class KeepsakeRequest
{
public string? Category { get; set; }
public string? EventKey { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public DateTime? EarnedAt { get; set; }
}
}
@@ -114,7 +114,18 @@ public class PlatformLoginController(
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public IActionResult RemoveCachedLogin() => Ok(new { });
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
{
var targetId = accountId ?? this.CurrentPlayerId();
if (targetId is not long playerId) return Unauthorized();
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return NotFound();
player.LastPlatform = 0;
player.LastPlatformId = string.Empty;
await db.SaveChangesAsync();
return Ok(new { Success = true, AccountId = (int)player.Id });
}
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
[ApiController]
[Authorize]
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playstationplus/membership")]
public async Task<IActionResult> Membership()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
? parsed
: DateTime.MinValue;
var active = expires > DateTime.UtcNow;
return Ok(new
{
IsMember = active,
MembershipActive = active,
ExpiresAt = active ? expires : (DateTime?)null,
});
}
[HttpPost("api/playstationplus/expire")]
public async Task<IActionResult> Expire()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "playstationplus:expires",
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
});
}
else
{
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
}
await db.SaveChangesAsync();
return Ok(new { Success = true, IsMember = false });
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -45,6 +46,7 @@ public class PlayerEventsController(
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
@@ -52,6 +54,121 @@ public class PlayerEventsController(
return Ok(ToWire(ev));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow);
if (needle.Length > 0)
{
rows = rows.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
public async Task<ActionResult> ForRoom(long roomId)
{
var rows = await db.PlayerEvents
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(long clubId)
{
var memberIds = await db.ClubMemberships
.Where(m => m.ClubId == clubId)
.Select(m => m.PlayerId)
.ToListAsync();
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubPlayerIds = await db.ClubMemberships
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed class EventBulkRequest
{
public List<long>? PlayerEventIds { get; set; }
public List<long>? EventIds { get; set; }
public List<long>? Ids { get; set; }
}
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
{
var ids = await ReadEventIdsAsync(body);
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
@@ -93,6 +210,39 @@ public class PlayerEventsController(
return Ok(new { Created = created, Responses = responses });
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == accountId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == accountId)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
@@ -233,6 +383,123 @@ public class PlayerEventsController(
return Ok(new { success = true, error = "" });
}
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
public async Task<IActionResult> UpdateDescription(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
public async Task<IActionResult> UpdateTime(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
public async Task<IActionResult> UpdateImage(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "image", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
public async Task<IActionResult> UpdateAccessibility(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "tags", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
await SetEventSettingAsync(evt, "club", id.ToString());
return Ok(ToWire(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
@@ -297,6 +564,44 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
public sealed class BroadcastRequest
{
public long PlayerEventId { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == ev.Id && r.Response != 2)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
var body = string.IsNullOrWhiteSpace(req.Message)
? $"Update for '{ev.Title}'"
: req.Message.Trim();
foreach (var playerId in recipients.Where(id => id != Me))
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
Body = body,
});
await notifications.NotifyAsync(playerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, ev.Title, Message = body });
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Sent = recipients.Count });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
@@ -306,6 +611,130 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
{
var ids = new List<long>();
if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds);
if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds);
if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids);
foreach (var value in Request.Query.SelectMany(q => q.Value))
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
return ids.Distinct().Take(200).ToList();
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
foreach (var name in names)
if (fields.TryGetValue(name, out var value))
return value;
return null;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return DateTime.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
{
const string itemKey = "__playerevent_fields";
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
&& cached is Dictionary<string, string> existing)
{
return existing;
}
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.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() ?? string.Empty
: prop.Value.GetRawText();
}
}
catch (JsonException)
{
}
}
HttpContext.Items[itemKey] = fields;
return fields;
}
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
{
var settingKey = $"playerevent:{evt.Id}:{key}";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = evt.CreatorPlayerId,
Key = settingKey,
Value = value,
});
}
else
{
row.Value = value;
}
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
[ApiController]
[Authorize]
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playerwarnings")]
public async Task<IActionResult> Warnings()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows
.Select(ToWarningWire)
.Where(w => !w.Acknowledged)
.ToList());
}
[HttpPost("api/playerwarnings/acknowledge")]
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
{
var me = this.RequireCurrentPlayerId();
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return NotFound();
var parts = row.Value.Split('|');
row.Value = string.Join('|', parts.Take(3).Append("true"));
await db.SaveChangesAsync();
return Ok(new { Success = true, WarningId = id });
}
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new WarningWire
{
WarningId = row.Key["warning:".Length..],
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
};
}
private sealed class WarningWire
{
public string WarningId { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Category { get; set; }
public string CreatedAt { get; set; } = string.Empty;
public bool Acknowledged { get; set; }
}
}
@@ -21,6 +21,20 @@ public class PlayersController(PlayerService playerService, NotificationService
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("/api/players/v4/current/contact")]
public async Task<IActionResult> CurrentContact()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(new
{
AccountId = (int)player.Id,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
CanReceiveInvites = player.CanReceiveInvites,
});
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController]
public class ProgressionEventsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/progressionEvents")]
[Authorize]
public async Task<IActionResult> Progress()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
.OrderByDescending(o => o.ClearedAt)
.Take(100)
.Select(o => new
{
EventKey = o.Key,
o.IsCompleted,
o.ClearedAt,
})
.ToListAsync();
return Ok(rows);
}
[HttpGet("api/progressionEvents/active")]
[AllowAnonymous]
public IActionResult Active()
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
var end = start.AddDays(7);
return Ok(new
{
EventKey = $"weekly:{start:yyyyMMdd}",
Name = "Weekly Progression",
StartAt = start,
EndAt = end,
Active = true,
});
}
}
@@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
return Ok(new { Imported = added });
}
[HttpGet("api/relationships/mutualfriends")]
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
var mine = await FriendIdsAsync(me);
var theirs = await FriendIdsAsync(playerId);
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
var players = await db.Players
.Where(p => mutualIds.Contains(p.Id))
.Select(p => new
{
AccountId = (int)p.Id,
p.Username,
p.DisplayName,
ProfileImage = p.ProfileImageName ?? string.Empty,
})
.ToListAsync();
return Ok(players);
}
[HttpPost("api/relationships/sendfriendintroductions")]
public async Task<IActionResult> SendFriendIntroductions(
[FromForm] long playerId,
[FromForm] long introducedPlayerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || introducedPlayerId <= 0)
return BadRequest("missing_player");
db.Messages.Add(new MessageEntity
{
SenderPlayerId = me,
RecipientPlayerId = playerId,
Type = 30,
Body = $"Friend introduction:{introducedPlayerId}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
[HttpPost("api/relationships/v1/addfriendwithcode")]
[HttpGet("api/relationships/v1/addfriendwithcode")]
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
{
var raw = (code ?? formCode ?? string.Empty).Trim();
if (raw.Length == 0) return Ok(EmptyRelationship(0));
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
if (id is null)
{
id = await db.Players
.Where(p => p.Username == raw)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync();
}
if (id is not long playerId) return Ok(EmptyRelationship(0));
return await AddFriend(playerId);
}
// ── helpers ──────────────────────────────────────────────────────
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
.Where(r => r.Status == EntityStatus.Friend
&& (r.RequesterId == playerId || r.TargetId == playerId))
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
.ToListAsync();
return rows.ToHashSet();
}
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomConsumables;
[ApiController]
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
[HttpGet("api/roomconsumables")]
[HttpGet("api/roomconsumables/v1/roomConsumable")]
[AllowAnonymous]
public async Task<IActionResult> List([FromQuery] long? id = null)
{
var query = db.StoreItems
.Where(i => i.IsActive && i.Category.ToLower() == "consumable");
if (id is long itemId && itemId > 0)
query = query.Where(i => i.Id == itemId);
var rows = await query
.OrderBy(i => i.DisplayName)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
[Authorize]
public Task<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
Purchase(itemId, currencyType);
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
[Authorize]
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
private async Task<IActionResult> Purchase(long itemId, int? currencyType)
{
var me = this.RequireCurrentPlayerId();
var item = await db.StoreItems
.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable");
if (item is null) return NotFound();
var spendCurrency = currencyType ?? item.CurrencyType;
var balance = await level.GetBalanceAsync(me, spendCurrency);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency });
var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}");
var inventory = await db.PlayerInventory
.FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug);
if (inventory is null)
{
inventory = new PlayerInventoryEntity
{
PlayerId = me,
ItemSlug = item.Slug,
Quantity = 0,
};
db.PlayerInventory.Add(inventory);
}
inventory.Quantity += 1;
await db.SaveChangesAsync();
return Ok(new
{
Success = true,
Balance = newBalance,
CurrencyType = spendCurrency,
Item = ToWire(item),
Quantity = inventory.Quantity,
});
}
private static object ToWire(StoreItemEntity item) => new
{
RoomConsumableId = item.Id,
ItemId = item.Id,
item.Slug,
Name = item.DisplayName,
item.Description,
item.ImageName,
item.Price,
item.CurrencyType,
item.Category,
};
}
@@ -0,0 +1,405 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomCurrencies;
[ApiController]
[Authorize]
public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/roomcurrencies/v1/currencies")]
[AllowAnonymous]
public async Task<IActionResult> Currencies([FromQuery] long roomId)
{
var rows = await db.RoomCurrencies
.Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId))
.OrderBy(c => c.Name)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomcurrencies/v1/createCurrency")]
public async Task<IActionResult> CreateCurrency()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
if (!await CanManageRoomAsync(room, Me)) return Forbid();
var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64);
var currency = new RoomCurrencyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = name,
Description = Trim(ReadString(fields, "description", "Description"), 512),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
};
db.RoomCurrencies.Add(currency);
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpPost("api/roomcurrencies/v1/updateCurrency")]
[HttpPut("api/roomcurrencies/v1/updateCurrency")]
public async Task<IActionResult> UpdateCurrency()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
currency.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpGet("api/roomcurrencies/v1/getBalance")]
public async Task<IActionResult> GetBalance()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
var balance = await GetBalanceAsync(playerId, currency.Id);
return Ok(new
{
PlayerId = (int)playerId,
RoomCurrencyId = currency.PublicId,
Balance = balance,
});
}
[HttpGet("api/roomcurrencies/v1/getAllBalances")]
public async Task<IActionResult> GetAllBalances([FromQuery] long? roomId = null)
{
var playerId = Me;
var query = db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId)
.Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c })
.Where(x => !x.c.IsDeleted);
if (roomId is long rid && rid > 0)
query = query.Where(x => x.c.RoomId == rid);
var rows = await query
.OrderBy(x => x.c.RoomId)
.ThenBy(x => x.c.Name)
.ToListAsync();
return Ok(rows.Select(x => new
{
PlayerId = (int)playerId,
RoomCurrencyId = x.c.PublicId,
x.b.Balance,
}));
}
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
public async Task<IActionResult> AwardCurrencyBulk()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds");
var amount = ReadLong(fields, "amount", "Amount") ?? 0;
if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards");
var results = new List<object>();
foreach (var playerId in playerIds)
{
var balance = await AddBalanceAsync(playerId, currency.Id, amount);
results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance });
}
await db.SaveChangesAsync();
return Ok(results);
}
[HttpPost("api/roomcurrencies/v1/createPurchaseOffer")]
public async Task<IActionResult> CreatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
var offer = new RoomCurrencyPurchaseOfferEntity
{
RoomCurrencyId = currency.Id,
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1),
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
};
db.RoomCurrencyPurchaseOffers.Add(offer);
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")]
[HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")]
public async Task<IActionResult> UpdatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount);
if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")]
[HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")]
public async Task<IActionResult> DeletePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
offer.IsDeleted = true;
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[AllowAnonymous]
public async Task<IActionResult> GetPurchaseOffersBatch()
{
var fields = await ReadFieldsAsync();
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds");
var query = db.RoomCurrencyPurchaseOffers
.Where(o => !o.IsDeleted)
.Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c })
.Where(x => !x.c.IsDeleted);
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
var rows = await query.Take(200).ToListAsync();
return Ok(rows.Select(x => ToOfferWire(x.o, x.c)));
}
[HttpPost("api/roomCurrencies/v2/purchase")]
[HttpGet("api/roomCurrencies/v2/purchase")]
public async Task<IActionResult> Purchase()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var pid = Me;
var balance = await level.GetBalanceAsync(pid, offer.CurrencyType);
if (balance < offer.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType });
var newBalance = offer.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount);
await db.SaveChangesAsync();
return Ok(new
{
Success = true,
Balance = newBalance,
offer.CurrencyType,
RoomCurrencyId = currency.PublicId,
RoomCurrencyBalance = customBalance,
});
}
private async Task<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted);
return null;
}
private async Task<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted);
var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted);
return null;
}
private async Task<long> GetBalanceAsync(long playerId, long currencyId) =>
await db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
.Select(b => b.Balance)
.FirstOrDefaultAsync();
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
{
var row = await db.RoomCurrencyBalances
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
if (row is null)
{
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance + amount);
row.UpdatedAt = DateTime.UtcNow;
return row.Balance;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(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 static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
private static object ToWire(RoomCurrencyEntity currency) => new
{
RoomCurrencyId = currency.PublicId,
Id = currency.PublicId,
InternalId = currency.Id,
currency.RoomId,
CreatorPlayerId = (int)currency.CreatorPlayerId,
currency.Name,
currency.Description,
currency.ImageName,
currency.DailyLimit,
currency.CreatedAt,
currency.UpdatedAt,
};
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
{
PurchaseOfferId = offer.PublicId,
RoomCurrencyPackageId = offer.PublicId,
Id = offer.PublicId,
InternalId = offer.Id,
RoomCurrencyId = currency.PublicId,
offer.Name,
offer.Amount,
offer.Price,
offer.CurrencyType,
offer.CreatedAt,
offer.UpdatedAt,
};
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
[ApiController]
[Authorize]
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/roomEarningsDistributions")]
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null)
{
var me = this.RequireCurrentPlayerId();
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me);
if (roomId is long rid && rid > 0)
rooms = rooms.Where(r => r.Id == rid);
var roomRows = await rooms
.Select(r => new { r.Id, r.Name })
.ToListAsync();
var roomIds = roomRows.Select(r => r.Id).ToList();
var earnings = await db.RoomKeyPurchases
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
.Where(x => roomIds.Contains(x.k.RoomId))
.GroupBy(x => x.k.RoomId)
.Select(g => new
{
RoomId = g.Key,
Gross = g.Sum(x => x.p.PaidPrice),
PurchaseCount = g.Count(),
})
.ToListAsync();
var byRoom = earnings.ToDictionary(e => e.RoomId);
return Ok(roomRows.Select(room =>
{
byRoom.TryGetValue(room.Id, out var value);
var gross = value?.Gross ?? 0;
return new
{
RoomId = room.Id,
room.Name,
GrossEarnings = gross,
NetEarnings = gross,
PurchaseCount = value?.PurchaseCount ?? 0,
CurrencyType = 2,
UpdatedAt = DateTime.UtcNow,
};
}).ToList());
}
}
@@ -42,6 +42,22 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys")]
[HttpGet("api/roomkeys/v1")]
public async Task<IActionResult> List([FromQuery] long? roomId = null)
{
var rows = db.RoomKeys.Where(k => !k.IsDeleted);
if (roomId is long id && id > 0)
rows = rows.Where(k => k.RoomId == id);
var result = await rows
.OrderBy(k => k.RoomId)
.ThenBy(k => k.Price)
.Take(200)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
@@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Content(owns ? "true" : "false", "application/json");
}
[HttpGet("api/roomkeys/v1/owns")]
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
return Content(owns ? "true" : "false", "application/json");
}
[HttpGet("api/roomkeys/v1/owns/bulk")]
[HttpPost("api/roomkeys/v1/owns/bulk")]
public async Task<IActionResult> OwnsBulk()
{
var ids = await ReadRoomKeyIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var pid = Me;
var owned = await db.RoomKeyPurchases
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
.Select(p => p.RoomKeyId)
.ToListAsync();
var ownedSet = owned.ToHashSet();
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
@@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
.FirstOrDefaultAsync();
}
private async Task<List<long>> ReadRoomKeyIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
return ids.Distinct().Take(200).ToList();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
{
if (Request.HasFormContentType)
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ScreenShareReports;
[ApiController]
[Authorize]
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/screensharereports/v1/report")]
public async Task<IActionResult> Report(
[FromForm] long? targetPlayerId,
[FromForm] long? roomId,
[FromForm] int? reportCategory,
[FromForm] string? details)
{
var reporter = this.RequireCurrentPlayerId();
var target = targetPlayerId
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
var room = roomId
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
var category = reportCategory
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
RoomId = room,
Category = category,
Message = message[..Math.Min(1000, message.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
}
@@ -205,6 +205,7 @@ public class StorefrontsBuyController(
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
@@ -242,6 +243,95 @@ public class StorefrontsBuyController(
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
[HttpPost("api/storefronts/v2/buyInvention")]
[HttpGet("api/storefronts/v2/buyInvention")]
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
{
var pid = Me;
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
if (inv is null) return NotFound();
var price = Math.Max(0, inv.Price);
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
var key = $"invention_purchase:{inv.Id}";
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
}
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
db.GiftPackages.Add(new GiftPackageEntity
{
RecipientPlayerId = pid,
CurrencyType = 2,
Currency = 25,
Level = 1,
PackageVariant = "FreeGiftButton",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
{
var pid = Me;
var balance = await level.GetBalanceAsync(pid, currencyType);
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
var newBalance = price == 0
? balance
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(BalanceUpdateResponse(newBalance, currencyType));
}
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
public async Task<IActionResult> BuyPurchaseReminder()
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = Me,
Key = $"purchaseReminder:{Guid.NewGuid():N}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
@@ -32,6 +32,8 @@ public class StorefrontsController(
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[HttpGet("api/storefronts/v1/toptoday")]
[HttpGet("api/storefronts/v1/adcarouselitems")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.SubscriptionSeasons;
[ApiController]
public class SubscriptionSeasonsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/subscriptionseasons/v1/seasons/current")]
public async Task<IActionResult> Current()
{
var now = DateTime.UtcNow;
var start = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var end = start.AddMonths(1);
var activeSubscribers = await db.Subscriptions.CountAsync();
var rewards = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.Id)
.Take(10)
.Select(i => new
{
RewardId = i.Id,
i.Slug,
Name = i.DisplayName,
i.Category,
i.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = $"{start:yyyyMM}",
Name = $"Season {start:yyyy.MM}",
StartAt = start,
EndAt = end,
ActiveSubscriberCount = activeSubscribers,
Rewards = rewards,
});
}
}
@@ -0,0 +1,225 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.UgcPurchasables;
[ApiController]
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/ugcPurchasables")]
[AllowAnonymous]
public async Task<IActionResult> List([FromQuery] long roomId = 0)
{
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
.OrderBy(i => i.SortOrder)
.ThenBy(i => i.Name)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/ugcPurchasables")]
[Authorize]
public async Task<IActionResult> Create()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
var me = this.RequireCurrentPlayerId();
if (!await CanManageRoomAsync(room, me)) return Forbid();
var item = new UgcPurchasableEntity
{
RoomId = room.Id,
CreatorPlayerId = me,
Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128),
Description = Trim(ReadString(fields, "description", "Description"), 1024),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0),
SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0,
IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false,
};
db.UgcPurchasables.Add(item);
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpPut("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/update")]
[Authorize]
public async Task<IActionResult> Update()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128);
if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType);
if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType);
if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder;
if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpDelete("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/delete")]
[Authorize]
public async Task<IActionResult> Delete()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
item.IsDeleted = true;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpGet("api/ugcPurchasables/v1/items/bulk")]
[HttpPost("api/ugcPurchasables/v1/items/bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk()
{
var fields = await ReadFieldsAsync();
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
{
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
if (publicId is Guid guid)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted);
var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId");
if (localId is long id)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
return null;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(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 static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
private static object ToWire(UgcPurchasableEntity item) => new
{
UgcPurchasableId = item.PublicId,
PurchasableItemId = item.PublicId,
Id = item.PublicId,
InternalId = item.Id,
item.RoomId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.ImageName,
item.Price,
item.CurrencyType,
item.ItemType,
item.IsFeatured,
item.SortOrder,
item.CreatedAt,
item.UpdatedAt,
};
}
@@ -73,9 +73,11 @@ public class AccountsController(
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
@@ -170,6 +172,7 @@ public class AccountsController(
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
// localhost public site's account search. Anonymous-safe (no JWT
// required) so the front-facing site can call it directly.
[HttpGet("/account/search")]
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
@@ -405,6 +408,26 @@ public class AccountsController(
return age;
}
private async Task SetPlayerSettingAsync(long playerId, string key, string value)
{
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = value,
});
}
else
{
row.Value = value;
}
await db.SaveChangesAsync();
}
// Everyone is a dev for now — exposes the in-game debug console + dev
// tools. Per user request: easier debugging while iterating on the
// private server. Flip back to false later if you want shipping behavior.
@@ -509,6 +532,120 @@ public class AccountsController(
[HttpGet("/account/v1/me/disallowiap")]
public IActionResult DisallowIap() => Ok(new { Disallow = false });
[HttpGet("/account/isactivecreator/me")]
public async Task<IActionResult> IsActiveCreator()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Content("false", "application/json");
var active = await db.Rooms.AnyAsync(r => r.CreatorPlayerId == me)
|| await db.Inventions.AnyAsync(i => i.CreatorPlayerId == me && !i.IsDeleted)
|| await db.CustomAvatarItems.AnyAsync(i => i.CreatorPlayerId == me);
return Content(active ? "true" : "false", "application/json");
}
[HttpPost("/account/me/createlogintoken")]
[HttpGet("/account/me/createlogintoken")]
public async Task<IActionResult> CreateLoginToken()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var token = Guid.NewGuid().ToString("N");
var expiresAt = DateTime.UtcNow.AddMinutes(10);
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
return Ok(new
{
Success = true,
Error = string.Empty,
Token = token,
LoginToken = token,
ExpiresAt = expiresAt,
});
}
[HttpPost("/account/me/remoteauth")]
[HttpGet("/account/me/remoteauth")]
public async Task<IActionResult> RemoteAuth()
{
var token = Request.Query["token"].FirstOrDefault()
?? Request.Query["loginToken"].FirstOrDefault()
?? Request.Query["code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
token = form["token"].FirstOrDefault()
?? form["loginToken"].FirstOrDefault()
?? form["code"].FirstOrDefault();
}
if (string.IsNullOrWhiteSpace(token))
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var pair = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = pair.AccessToken,
RefreshToken = pair.RefreshToken,
});
}
var prefix = token.Trim() + "|";
var row = await db.PlayerSettings
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.FirstOrDefaultAsync();
if (row is null) return Unauthorized();
var rawExpiry = row.Value[prefix.Length..];
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
return Unauthorized();
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
[HttpPost("/account/recoverpassword")]
[HttpGet("/account/recoverpassword")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> RecoverPassword(
[FromForm] string? username,
[FromForm] string? email)
{
var nameOrEmail = username
?? email
?? Request.Query["username"].FirstOrDefault()
?? Request.Query["email"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(nameOrEmail))
return Ok(new RecNetResult { Success = false, Error = "missing_account" });
var needle = nameOrEmail.Trim().ToLowerInvariant();
var player = await db.Players
.FirstOrDefaultAsync(p => p.Username.ToLower() == needle
|| (p.Email != null && p.Email.ToLower() == needle));
if (player is null)
return Ok(new RecNetResult { Success = false, Error = "unknown_account" });
var code = Random.Shared.Next(100000, 999999).ToString();
var expiresAt = DateTime.UtcNow.AddMinutes(30);
await SetPlayerSettingAsync(player.Id, "password_recovery_code", $"{code}|{expiresAt:O}");
return Ok(new
{
Success = true,
Error = string.Empty,
Code = code,
ExpiresAt = expiresAt,
});
}
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
@@ -31,6 +31,42 @@ public class AuthController(
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken) => new()
{
["access_token"] = accessToken,
["accessToken"] = accessToken,
["AccessToken"] = accessToken,
["token"] = accessToken,
["Token"] = accessToken,
["token_type"] = "Bearer",
["tokenType"] = "Bearer",
["TokenType"] = "Bearer",
["expires_in"] = 43200,
["expiresIn"] = 43200,
["ExpiresIn"] = 43200,
["refresh_token"] = refreshToken,
["refreshToken"] = refreshToken,
["RefreshToken"] = refreshToken,
["scope"] = "openid profile",
};
private IActionResult TokenOk(string accessToken, string refreshToken)
{
var cookie = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = DateTimeOffset.UtcNow.AddHours(12),
IsEssential = true,
};
if (domain.Apex.Contains('.'))
cookie.Domain = "." + domain.Apex;
Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
return Ok(BuildTokenResponse(accessToken, refreshToken));
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
@@ -90,14 +126,7 @@ public class AuthController(
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
return TokenOk(acc1, refr1);
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
@@ -111,14 +140,7 @@ public class AuthController(
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
return TokenOk(acc, refr);
}
// password grant: two paths.
@@ -150,14 +172,7 @@ public class AuthController(
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
return TokenOk(pair.AccessToken, pair.RefreshToken);
}
// Device-id fallback path. Prefer the real deviceId; fall back
@@ -196,14 +211,7 @@ public class AuthController(
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
return TokenOk(access, refresh);
}
[HttpGet("/connect/userinfo")]
@@ -307,6 +315,62 @@ public class AuthController(
return Ok(results);
}
[HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
}
[HttpPost("/cachedlogin/migrate")]
public async Task<IActionResult> MigrateCachedLogin(
[FromForm] int platform = 0,
[FromForm] string? platformId = null)
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
await playerService.TagPlatformAsync(playerId, platform, platformId);
var player = await playerService.GetByIdAsync(playerId);
return player is null
? Unauthorized()
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
}
[HttpPost("/connect/deviceauthorization")]
[HttpGet("/connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization()
{
var deviceCode = Guid.NewGuid().ToString("N");
var userCode = deviceCode[..8].ToUpperInvariant();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = RelationshipQueries.SystemAccountId,
Key = $"deviceauthorization:{deviceCode}",
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
});
await db.SaveChangesAsync();
return Ok(new
{
device_code = deviceCode,
user_code = userCode,
verification_uri = $"https://{domain.Sub("auth")}/device",
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
expires_in = 900,
interval = 5,
});
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{
Platform = platform,
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>The boot-time client-version gate the 2020.12 watch hits
/// <summary>The boot-time client-version gate the 2023.03.21 client hits
/// at startup as <c>GET api/versioncheck/v4?v={build}&amp;p={platform}</c>.
///
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
@@ -20,10 +20,9 @@ namespace DorkNet.Server.Controllers.Auth;
/// front door — the watch has dedicated UX for it, no custom client
/// mod work needed.</para>
///
/// <para>The <c>v</c> query param is the raw build identifier the watch
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
/// to our internal <see cref="ClientVersion.Key"/>s via the
/// <para>The <c>v</c> query param is the raw build identifier supplied
/// by the client. We map raw build IDs to our internal
/// <see cref="ClientVersion.Key"/>s via the
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
/// don't recognise OR that maps to a key not in
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
@@ -49,7 +48,8 @@ public class VersionCheckController(
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["20201210"] = "december_2020_12_18",
["20230317"] = "march_2023_03_21",
["20230321"] = "march_2023_03_21",
};
[HttpGet("/api/versioncheck/v4")]
@@ -66,8 +66,7 @@ public class VersionCheckController(
// Map raw build ID → ClientVersion key via config. Format:
// "DorkNet": {
// "BuildIdToVersionKey": {
// "20201210": "december_2020_12_18",
// "20200310": "march_2020_03_10"
// "20230321": "march_2023_03_21"
// }
// }
// Mapping ABSENT → unknown build → UpdateRequired.
@@ -71,6 +71,38 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
.ToListAsync();
return Ok(rows);
}
[HttpGet("/econ/customAvatarItems")]
[HttpGet("/econ/customAvatarItems/v1/owned")]
public async Task<IActionResult> GetOwnedCustomAvatarItems()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(i => new
{
CustomAvatarItemId = i.PublicId,
Id = i.PublicId,
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.Price,
i.ItemType,
i.ImageName,
i.AssetName,
i.Color,
}));
}
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new
{
Limit = 1000,
ItemOwnershipLimit = 1000,
});
}
public class TokenBalance
+67
View File
@@ -74,6 +74,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
public DbSet<CustomAvatarItemEntity> CustomAvatarItems => Set<CustomAvatarItemEntity>();
public DbSet<CustomAvatarItemOwnershipEntity> CustomAvatarItemOwnership => Set<CustomAvatarItemOwnershipEntity>();
public DbSet<ItemWishlistEntity> ItemWishlists => Set<ItemWishlistEntity>();
public DbSet<KeepsakeEntity> Keepsakes => Set<KeepsakeEntity>();
public DbSet<RoomCurrencyEntity> RoomCurrencies => Set<RoomCurrencyEntity>();
public DbSet<RoomCurrencyBalanceEntity> RoomCurrencyBalances => Set<RoomCurrencyBalanceEntity>();
public DbSet<RoomCurrencyPurchaseOfferEntity> RoomCurrencyPurchaseOffers => Set<RoomCurrencyPurchaseOfferEntity>();
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -679,5 +687,64 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasKey(c => new { c.RoomId, c.Channel });
e.HasIndex(c => c.RoomId);
});
modelBuilder.Entity<CustomAvatarItemEntity>(e =>
{
e.HasKey(i => i.Id);
e.HasIndex(i => i.PublicId).IsUnique();
e.HasIndex(i => i.CreatorPlayerId);
e.HasIndex(i => i.IsPublic);
e.HasIndex(i => i.IsFeatured);
});
modelBuilder.Entity<CustomAvatarItemOwnershipEntity>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => new { o.PlayerId, o.CustomAvatarItemId }).IsUnique();
e.HasIndex(o => o.CustomAvatarItemId);
});
modelBuilder.Entity<ItemWishlistEntity>(e =>
{
e.HasKey(w => w.Id);
e.HasIndex(w => new { w.PlayerId, w.ItemKey, w.ItemType }).IsUnique();
});
modelBuilder.Entity<KeepsakeEntity>(e =>
{
e.HasKey(k => k.Id);
e.HasIndex(k => k.PlayerId);
e.HasIndex(k => new { k.PlayerId, k.EventKey });
});
modelBuilder.Entity<RoomCurrencyEntity>(e =>
{
e.HasKey(c => c.Id);
e.HasIndex(c => c.PublicId).IsUnique();
e.HasIndex(c => c.RoomId);
e.HasIndex(c => c.CreatorPlayerId);
});
modelBuilder.Entity<RoomCurrencyBalanceEntity>(e =>
{
e.HasKey(b => b.Id);
e.HasIndex(b => new { b.PlayerId, b.RoomCurrencyId }).IsUnique();
e.HasIndex(b => b.RoomCurrencyId);
});
modelBuilder.Entity<RoomCurrencyPurchaseOfferEntity>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => o.PublicId).IsUnique();
e.HasIndex(o => o.RoomCurrencyId);
});
modelBuilder.Entity<UgcPurchasableEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => p.PublicId).IsUnique();
e.HasIndex(p => p.RoomId);
e.HasIndex(p => p.CreatorPlayerId);
});
}
}
@@ -0,0 +1,94 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
public class CustomAvatarItemEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long CreatorPlayerId { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(1024)]
public string Description { get; set; } = string.Empty;
public int Price { get; set; } = 100;
public int ItemType { get; set; } = 0;
public int BaseAvatarItemId { get; set; }
[MaxLength(64)]
public string Color { get; set; } = string.Empty;
[MaxLength(256)]
public string ImageName { get; set; } = string.Empty;
[MaxLength(256)]
public string AssetName { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public bool IsFeatured { get; set; }
public long CheerCount { get; set; }
public long PurchaseCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class CustomAvatarItemOwnershipEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
public long CustomAvatarItemId { get; set; }
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
}
public class ItemWishlistEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
[MaxLength(128)]
public string ItemKey { get; set; } = string.Empty;
public int ItemType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class KeepsakeEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
[MaxLength(64)]
public string Category { get; set; } = "general";
[MaxLength(128)]
public string EventKey { get; set; } = string.Empty;
[MaxLength(128)]
public string Title { get; set; } = string.Empty;
[MaxLength(1024)]
public string Description { get; set; } = string.Empty;
[MaxLength(256)]
public string ImageName { get; set; } = string.Empty;
public DateTime EarnedAt { get; set; } = DateTime.UtcNow;
}
@@ -85,6 +85,7 @@ public class InventionEntity
public int CheerCount { get; set; } = 0;
public int SpawnCount { get; set; } = 0;
public int Price { get; set; } = 0;
/// <summary>Soft-delete flag. When set, the row is hidden from
/// browse / search / detail endpoints but stays in the table so
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
public class RoomCurrencyEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public long CreatorPlayerId { get; set; }
[MaxLength(64)] public string Name { get; set; } = string.Empty;
[MaxLength(512)] public string Description { get; set; } = string.Empty;
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
public int DailyLimit { get; set; } = 0;
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrencyBalanceEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
public long RoomCurrencyId { get; set; }
public long Balance { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class RoomCurrencyPurchaseOfferEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomCurrencyId { get; set; }
[MaxLength(64)] public string Name { get; set; } = string.Empty;
public int Amount { get; set; }
public int Price { get; set; }
public int CurrencyType { get; set; } = 2;
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class UgcPurchasableEntity
{
public long Id { get; set; }
public Guid PublicId { get; set; } = Guid.NewGuid();
public long RoomId { get; set; }
public long CreatorPlayerId { get; set; }
[MaxLength(128)] public string Name { get; set; } = string.Empty;
[MaxLength(1024)] public string Description { get; set; } = string.Empty;
[MaxLength(256)] public string ImageName { get; set; } = string.Empty;
public int Price { get; set; }
public int CurrencyType { get; set; } = 2;
public int ItemType { get; set; }
public bool IsFeatured { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -47,6 +47,12 @@ public class RoomEntity
public bool IsAGRoom { get; set; } = true;
public bool IsDormRoom { get; set; } = false;
public bool IsStudioRoom { get; set; } = false;
public bool IsRoomLinkedToRecRoomStudio { get; set; } = false;
[MaxLength(128)]
public string StudioSessionId { get; set; } = string.Empty;
public bool CloningAllowed { get; set; } = false;
public bool SupportsVRLow { get; set; } = true;
public bool SupportsMobile { get; set; } = false;
+12 -1
View File
@@ -9,6 +9,8 @@ namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db)
{
public const string AccessCookieName = "dorknet_access_token";
// Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated
@@ -56,13 +58,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("roles", "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("roles", "developer"));
}
var descriptor = new SecurityTokenDescriptor
{
+6
View File
@@ -1016,6 +1016,9 @@ public class RoomService(DorkNetDbContext db)
// showed up that the admin couldn't purge.)
IsAGRoom = false,
IsDormRoom = false,
IsStudioRoom = source.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
StudioSessionId = source.StudioSessionId,
CloningAllowed = source.CloningAllowed,
SupportsVRLow = source.SupportsVRLow,
SupportsMobile = source.SupportsMobile,
@@ -1070,6 +1073,9 @@ public class RoomService(DorkNetDbContext db)
// ignored by both clients.
IsDorm = r.IsDormRoom,
IsDormRoom = r.IsDormRoom,
IsStudioRoom = r.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = r.IsRoomLinkedToRecRoomStudio,
StudioSessionId = r.StudioSessionId,
CloningAllowed = r.CloningAllowed,
SupportsVRLow = r.SupportsVRLow,
SupportsMobile = r.SupportsMobile,
+321
View File
@@ -104,6 +104,8 @@ public static class DatabaseBootstrap
await EnsureInventorySchemaAsync(db);
await EnsureLeaderboardSchemaAsync(db);
await EnsureRoomColumnsAsync(db);
await EnsureInventionColumnsAsync(db);
await EnsureMarch2023TablesAsync(db);
// Coach system account at Player.Id=1. The RR-Original room seeder
// below assigns CreatorPlayerId=1 to every canonical room
@@ -251,6 +253,12 @@ public static class DatabaseBootstrap
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsStudioRoom"" boolean NOT NULL DEFAULT false;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""IsRoomLinkedToRecRoomStudio"" boolean NOT NULL DEFAULT false;");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""StudioSessionId"" character varying(128) NOT NULL DEFAULT '';");
return;
}
@@ -262,6 +270,45 @@ public static class DatabaseBootstrap
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""IsStudioRoom"" INTEGER NOT NULL DEFAULT 0;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""IsRoomLinkedToRecRoomStudio"" INTEGER NOT NULL DEFAULT 0;");
}
catch { }
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""StudioSessionId"" TEXT NOT NULL DEFAULT '';");
}
catch { }
}
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Inventions"" ADD COLUMN IF NOT EXISTS ""Price"" integer NOT NULL DEFAULT 0;");
return;
}
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Inventions"" ADD COLUMN ""Price"" INTEGER NOT NULL DEFAULT 0;");
}
catch { }
}
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
@@ -464,6 +511,280 @@ public static class DatabaseBootstrap
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardStats_RoomId_StatChannel_Value"" ON ""LeaderboardStats"" (""RoomId"", ""StatChannel"", ""Value"");");
}
private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
{
var statements = new[]
{
"""
CREATE TABLE IF NOT EXISTS "CustomAvatarItems" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PublicId" uuid NOT NULL,
"CreatorPlayerId" bigint NOT NULL,
"Name" character varying(128) NOT NULL,
"Description" character varying(1024) NOT NULL,
"Price" integer NOT NULL DEFAULT 100,
"ItemType" integer NOT NULL DEFAULT 0,
"BaseAvatarItemId" integer NOT NULL DEFAULT 0,
"Color" character varying(64) NOT NULL DEFAULT '',
"ImageName" character varying(256) NOT NULL DEFAULT '',
"AssetName" character varying(256) NOT NULL DEFAULT '',
"IsPublic" boolean NOT NULL DEFAULT false,
"IsFeatured" boolean NOT NULL DEFAULT false,
"CheerCount" bigint NOT NULL DEFAULT 0,
"PurchaseCount" bigint NOT NULL DEFAULT 0,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");",
"""
CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"CustomAvatarItemId" bigint NOT NULL,
"AcquiredAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");",
"""
CREATE TABLE IF NOT EXISTS "ItemWishlists" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"ItemKey" character varying(128) NOT NULL,
"ItemType" integer NOT NULL DEFAULT 0,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");",
"""
CREATE TABLE IF NOT EXISTS "Keepsakes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"Category" character varying(64) NOT NULL DEFAULT 'general',
"EventKey" character varying(128) NOT NULL DEFAULT '',
"Title" character varying(128) NOT NULL DEFAULT '',
"Description" character varying(1024) NOT NULL DEFAULT '',
"ImageName" character varying(256) NOT NULL DEFAULT '',
"EarnedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencies" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PublicId" uuid NOT NULL,
"RoomId" bigint NOT NULL,
"CreatorPlayerId" bigint NOT NULL,
"Name" character varying(64) NOT NULL DEFAULT '',
"Description" character varying(512) NOT NULL DEFAULT '',
"ImageName" character varying(256) NOT NULL DEFAULT '',
"DailyLimit" integer NOT NULL DEFAULT 0,
"IsDeleted" boolean NOT NULL DEFAULT false,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PlayerId" bigint NOT NULL,
"RoomCurrencyId" bigint NOT NULL,
"Balance" bigint NOT NULL DEFAULT 0,
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PublicId" uuid NOT NULL,
"RoomCurrencyId" bigint NOT NULL,
"Name" character varying(64) NOT NULL DEFAULT '',
"Amount" integer NOT NULL DEFAULT 0,
"Price" integer NOT NULL DEFAULT 0,
"CurrencyType" integer NOT NULL DEFAULT 2,
"IsDeleted" boolean NOT NULL DEFAULT false,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");",
"""
CREATE TABLE IF NOT EXISTS "UgcPurchasables" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"PublicId" uuid NOT NULL,
"RoomId" bigint NOT NULL,
"CreatorPlayerId" bigint NOT NULL,
"Name" character varying(128) NOT NULL DEFAULT '',
"Description" character varying(1024) NOT NULL DEFAULT '',
"ImageName" character varying(256) NOT NULL DEFAULT '',
"Price" integer NOT NULL DEFAULT 0,
"CurrencyType" integer NOT NULL DEFAULT 2,
"ItemType" integer NOT NULL DEFAULT 0,
"IsFeatured" boolean NOT NULL DEFAULT false,
"SortOrder" integer NOT NULL DEFAULT 0,
"IsDeleted" boolean NOT NULL DEFAULT false,
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");",
};
foreach (var sql in statements)
await db.Database.ExecuteSqlRawAsync(sql);
return;
}
var sqliteStatements = new[]
{
"""
CREATE TABLE IF NOT EXISTS "CustomAvatarItems" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItems" PRIMARY KEY AUTOINCREMENT,
"PublicId" TEXT NOT NULL,
"CreatorPlayerId" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"Description" TEXT NOT NULL,
"Price" INTEGER NOT NULL DEFAULT 100,
"ItemType" INTEGER NOT NULL DEFAULT 0,
"BaseAvatarItemId" INTEGER NOT NULL DEFAULT 0,
"Color" TEXT NOT NULL DEFAULT '',
"ImageName" TEXT NOT NULL DEFAULT '',
"AssetName" TEXT NOT NULL DEFAULT '',
"IsPublic" INTEGER NOT NULL DEFAULT 0,
"IsFeatured" INTEGER NOT NULL DEFAULT 0,
"CheerCount" INTEGER NOT NULL DEFAULT 0,
"PurchaseCount" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_PublicId"" ON ""CustomAvatarItems"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_CreatorPlayerId"" ON ""CustomAvatarItems"" (""CreatorPlayerId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsPublic"" ON ""CustomAvatarItems"" (""IsPublic"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItems_IsFeatured"" ON ""CustomAvatarItems"" (""IsFeatured"");",
"""
CREATE TABLE IF NOT EXISTS "CustomAvatarItemOwnership" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CustomAvatarItemOwnership" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"CustomAvatarItemId" INTEGER NOT NULL,
"AcquiredAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_PlayerId_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""PlayerId"", ""CustomAvatarItemId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_CustomAvatarItemOwnership_CustomAvatarItemId"" ON ""CustomAvatarItemOwnership"" (""CustomAvatarItemId"");",
"""
CREATE TABLE IF NOT EXISTS "ItemWishlists" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ItemWishlists" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"ItemKey" TEXT NOT NULL,
"ItemType" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ItemWishlists_PlayerId_ItemKey_ItemType"" ON ""ItemWishlists"" (""PlayerId"", ""ItemKey"", ""ItemType"");",
"""
CREATE TABLE IF NOT EXISTS "Keepsakes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Keepsakes" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"Category" TEXT NOT NULL DEFAULT 'general',
"EventKey" TEXT NOT NULL DEFAULT '',
"Title" TEXT NOT NULL DEFAULT '',
"Description" TEXT NOT NULL DEFAULT '',
"ImageName" TEXT NOT NULL DEFAULT '',
"EarnedAt" TEXT NOT NULL
);
""",
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId"" ON ""Keepsakes"" (""PlayerId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_Keepsakes_PlayerId_EventKey"" ON ""Keepsakes"" (""PlayerId"", ""EventKey"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencies" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencies" PRIMARY KEY AUTOINCREMENT,
"PublicId" TEXT NOT NULL,
"RoomId" INTEGER NOT NULL,
"CreatorPlayerId" INTEGER NOT NULL,
"Name" TEXT NOT NULL DEFAULT '',
"Description" TEXT NOT NULL DEFAULT '',
"ImageName" TEXT NOT NULL DEFAULT '',
"DailyLimit" INTEGER NOT NULL DEFAULT 0,
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencies_PublicId"" ON ""RoomCurrencies"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_RoomId"" ON ""RoomCurrencies"" (""RoomId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencies_CreatorPlayerId"" ON ""RoomCurrencies"" (""CreatorPlayerId"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencyBalances" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyBalances" PRIMARY KEY AUTOINCREMENT,
"PlayerId" INTEGER NOT NULL,
"RoomCurrencyId" INTEGER NOT NULL,
"Balance" INTEGER NOT NULL DEFAULT 0,
"UpdatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_PlayerId_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""PlayerId"", ""RoomCurrencyId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyBalances_RoomCurrencyId"" ON ""RoomCurrencyBalances"" (""RoomCurrencyId"");",
"""
CREATE TABLE IF NOT EXISTS "RoomCurrencyPurchaseOffers" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomCurrencyPurchaseOffers" PRIMARY KEY AUTOINCREMENT,
"PublicId" TEXT NOT NULL,
"RoomCurrencyId" INTEGER NOT NULL,
"Name" TEXT NOT NULL DEFAULT '',
"Amount" INTEGER NOT NULL DEFAULT 0,
"Price" INTEGER NOT NULL DEFAULT 0,
"CurrencyType" INTEGER NOT NULL DEFAULT 2,
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_PublicId"" ON ""RoomCurrencyPurchaseOffers"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_RoomCurrencyPurchaseOffers_RoomCurrencyId"" ON ""RoomCurrencyPurchaseOffers"" (""RoomCurrencyId"");",
"""
CREATE TABLE IF NOT EXISTS "UgcPurchasables" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UgcPurchasables" PRIMARY KEY AUTOINCREMENT,
"PublicId" TEXT NOT NULL,
"RoomId" INTEGER NOT NULL,
"CreatorPlayerId" INTEGER NOT NULL,
"Name" TEXT NOT NULL DEFAULT '',
"Description" TEXT NOT NULL DEFAULT '',
"ImageName" TEXT NOT NULL DEFAULT '',
"Price" INTEGER NOT NULL DEFAULT 0,
"CurrencyType" INTEGER NOT NULL DEFAULT 2,
"ItemType" INTEGER NOT NULL DEFAULT 0,
"IsFeatured" INTEGER NOT NULL DEFAULT 0,
"SortOrder" INTEGER NOT NULL DEFAULT 0,
"IsDeleted" INTEGER NOT NULL DEFAULT 0,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_UgcPurchasables_PublicId"" ON ""UgcPurchasables"" (""PublicId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_RoomId"" ON ""UgcPurchasables"" (""RoomId"");",
@"CREATE INDEX IF NOT EXISTS ""IX_UgcPurchasables_CreatorPlayerId"" ON ""UgcPurchasables"" (""CreatorPlayerId"");",
};
foreach (var sql in sqliteStatements)
await db.Database.ExecuteSqlRawAsync(sql);
}
/// <summary>SQLite migration-history reconciliation. Handles two
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
/// <list type="number">
@@ -128,14 +128,15 @@ public static class RequestTracingExtensions
var trimmedRequest = safeRequest is null ? null
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
var authPresence = DescribeAuthPresence(ctx.Request);
var level = thrown is not null || status >= 500 ? LogLevel.Error
: status >= 400 ? LogLevel.Warning
: LogLevel.Information;
reqLogger.Log(level, thrown,
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms auth={AuthPresence} contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds, authPresence,
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
trimmedRequest ?? "", trimmedResponse);
@@ -170,6 +171,15 @@ public static class RequestTracingExtensions
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
private static string DescribeAuthPresence(HttpRequest request)
{
var hasAuthHeader = request.Headers.ContainsKey("Authorization");
var hasBearer = request.Headers.Authorization.ToString()
.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase);
var hasAccessCookie = request.Cookies.ContainsKey(AuthService.AccessCookieName);
return $"header={hasAuthHeader};bearer={hasBearer};cookie={hasAccessCookie}";
}
private static readonly Regex JsonSecretRegex = new(
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
@@ -107,14 +107,22 @@ public static class ServiceCollectionExtensions
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
var allowedHosts = new List<string> { apex, $"*.{apex}", "localhost", "127.0.0.1" };
var bootstrapApex = builder.Configuration["Domain:BootstrapApex"];
if (!string.IsNullOrWhiteSpace(bootstrapApex))
{
allowedHosts.Add(bootstrapApex);
allowedHosts.Add($"*.{bootstrapApex}");
}
builder.Services.AddSingleton(new DomainConfig(apex, scheme));
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
opt.AllowedHosts = allowedHosts.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
opt.AllowEmptyHosts = true;
opt.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
}
// ── Services ──────────────────────────────────────────────────────────────
@@ -174,17 +182,16 @@ public static class ServiceCollectionExtensions
var pluginInstances = new IVersionPlugin[]
{
new Late2020VersionPlugin(),
// Add March2020 / future generations here as their plugins land.
};
foreach (var p in pluginInstances) p.RegisterStrategies(builder.Services);
foreach (var p in pluginInstances)
builder.Services.AddSingleton<IVersionPlugin>(p);
var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get<string[]>()
?? new[] { "december_2020_12_18" })
?? new[] { "march_2023_03_21" })
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"]
?? "december_2020_12_18";
?? "march_2023_03_21";
builder.Services.AddSingleton(sp => new VersionRegistry(
sp.GetServices<IVersionPlugin>(),
supported,
@@ -285,6 +292,12 @@ public static class ServiceCollectionExtensions
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken;
if (string.IsNullOrEmpty(ctx.Token) &&
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
ctx.Token = cookieToken;
}
return Task.CompletedTask;
},
};
@@ -2,31 +2,17 @@ using DorkNet.Server.Compat;
namespace DorkNet.Server.Versions.Late2020;
/// <summary>The "Late 2020" wire-format generation: covers all Rec Room
/// builds from the autumn 2020 schema migration through the end of the
/// year. The 2020.12.18 build is the canonical member; if/when we add
/// 2020.10 or 2020.11 builds and find their wire shapes are identical,
/// we just list them in <see cref="VersionKeys"/> here — no new code.
///
/// <para>Currently this plugin is a marker: it declares which version
/// keys belong to the generation, but no strategy services are
/// registered yet. As controllers start branching on
/// <see cref="HttpContextExtensions.ClientVersion"/>'s
/// <c>Generation</c>, the strategies that get extracted will register
/// themselves through <see cref="RegisterStrategies"/> with a
/// keyed-DI binding so the right implementation resolves per
/// request.</para>
/// <summary>Version marker for the 2023.03.21 client branch. This
/// branch is intentionally single-client: it does not advertise or
/// serve older 2020 build keys.
/// </summary>
public sealed class Late2020VersionPlugin : IVersionPlugin
{
public const string GenerationKey = "Late2020";
public const string GenerationKey = "March2023";
public IReadOnlyCollection<string> VersionKeys { get; } = new[]
{
// Verified working against this server. The canonical
// private-deployment build the codebase has been developed
// against since the move to localhost.
"december_2020_12_18",
"march_2023_03_21",
};
public string Generation => GenerationKey;
+7 -6
View File
@@ -21,13 +21,14 @@
"PhotonRoomPrefix": "DorkNet"
},
"DorkNet": {
"_comment_SupportedVersions": "Per-deployment allow-list. Requests whose X-DorkNet-Version header isn't in this set get a 426 Upgrade Required. To accept multiple client builds add their keys here.",
"SupportedVersions": [ "december_2020_12_18" ],
"_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header — the transition path while not every shipped client mod yet stamps the header. Set to '' (empty) once everyone's caught up to also reject headerless calls.",
"DefaultClientVersion": "december_2020_12_18",
"_comment_BuildIdToVersionKey": "Maps the raw build identifier the 2020 watch sends to /api/versioncheck/v4?v={buildId} (read from KMJJGELKNAP.MCFMPCNNEBH, e.g. '20201210') to our internal ClientVersion.Key. Builds not listed here get UpdateRequired and the watch shows its native dialog. Add a new build by listing its raw ID + matching version-plugin key.",
"_comment_SupportedVersions": "Per-deployment allow-list. This branch serves only the 2023.03.21 client.",
"SupportedVersions": [ "march_2023_03_21" ],
"_comment_DefaultClientVersion": "Fallback for requests without an X-DorkNet-Version header.",
"DefaultClientVersion": "march_2023_03_21",
"_comment_BuildIdToVersionKey": "Maps raw /api/versioncheck/v4?v= values to the internal client key. Add the exact 2023.03.21 build id once captured from the client log.",
"BuildIdToVersionKey": {
"20201210": "december_2020_12_18"
"20230317": "march_2023_03_21",
"20230321": "march_2023_03_21"
}
}
}