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 950e6e7c17
commit 0779aa8439
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