feat: Enhance API endpoints with new methods, improved response structures, and added Photon access token endpoint

This commit is contained in:
Alexa
2026-06-23 12:42:24 +03:00
parent 766717abd2
commit ed6c13ec07
10 changed files with 304 additions and 67 deletions
@@ -60,16 +60,34 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
.ExecuteDeleteAsync();
}
// 2020.12 deserializer for this response expects `subscription` to be a
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
// fields), NOT a boolean. Sending a bool throws InvalidCastException
// on the client ("Unable to cast object of type 'Boolean' to type
// 'Dictionary`2'"). Send null when there's no active subscription —
// the client's generic-cast helper returns default(T) which is fine.
return Ok(new
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
var now = DateTime.UtcNow;
var subscriptionId = unchecked((pid * 397L) ^ targetId);
var subscription = new Dictionary<string, object?>
{
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
["subscriptionId"] = subscriptionId,
["recNetPlayerId"] = pid,
["platformAccountSubscribedPlayerId"] = targetId,
["isActive"] = active,
["startedAt"] = now,
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
["renewalDate"] = active ? now.AddYears(10) : now,
["SubscriptionId"] = subscriptionId,
["RecNetPlayerId"] = pid,
["PlatformAccountSubscribedPlayerId"] = targetId,
["IsActive"] = active,
["StartedAt"] = now,
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
["RenewalDate"] = active ? now.AddYears(10) : now,
};
return Ok(new Dictionary<string, object?>
{
["subscription"] = subscription,
["platformAccountSubscribedPlayerId"] = targetId,
["Subscription"] = subscription,
["PlatformAccountSubscribedPlayerId"] = targetId,
});
}
@@ -44,6 +44,7 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
}
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
{
var rows = await db.PlayerSettings
@@ -54,6 +55,7 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
}
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
{
var rows = await db.PlayerSettings
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
@@ -25,7 +26,14 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
.Where(p => creatorIds.Contains(p.Id))
.OrderBy(p => p.Username)
.ToListAsync();
return Ok(players.Select(ToInfluencerWire));
var influencers = players.Select(ToInfluencerWire).ToList();
return Ok(new Dictionary<string, object>
{
["Influencers"] = influencers,
["influencers"] = influencers,
["Accounts"] = influencers,
["accounts"] = influencers,
});
}
[HttpGet("api/influencerpartnerprogram/influencer")]
@@ -92,18 +100,16 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
return Ok(new { Success = true });
}
private static object ToInfluencerWire(PlayerEntity player) => new
private static RecNetAccount ToInfluencerWire(PlayerEntity player) => new()
{
AccountId = (int)player.Id,
RawUsername = player.Username,
player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
IsInfluencer = true,
SupportedInfluencerId = (int?)null,
LocalSupportedInfluencerId = (int?)null,
CreatedAt = player.CreatedAt == default ? DateTime.UtcNow : player.CreatedAt,
};
}
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -29,12 +31,20 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories() => Ok(new[]
public IActionResult Categories()
{
new { Category = "account", DisplayName = "Account" },
new { Category = "event", DisplayName = "Events" },
new { Category = "room", DisplayName = "Rooms" },
});
var results = new[]
{
new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
};
return Ok(new
{
Results = results,
TotalResults = results.Length,
});
}
[HttpGet("events")]
public async Task<IActionResult> Events()
@@ -62,16 +72,22 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
return Ok(new
{
Instances = instances,
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = Array.Empty<long>(),
});
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
Enabled = true,
Categories = new[] { "account", "event", "room" },
CdnBaseUrl = $"https://{domain.Sub("cdn")}/",
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 64,
SocialXpBoostEnabled = false,
});
[HttpPost]
@@ -162,6 +178,32 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
row.EarnedAt,
};
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
{
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
? (int)row.PlayerId
: 0;
return new
{
KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"),
KeepsakeCategoryConfigId = CategoryId(row.Category),
PlacedByAccountId = placedByAccountId,
RoomId = roomId,
SubRoomId = (long?)null,
};
}
private static int CategoryId(string? category) =>
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
0;
private static Guid StableKeepsakeInstanceId(long id)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
return new Guid(hash);
}
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
@@ -3,11 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController]
public class ProgressionEventsController(DorkNetDbContext db) : ControllerBase
public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase
{
[HttpGet("api/progressionEvents")]
[Authorize]
@@ -28,19 +29,60 @@ public class ProgressionEventsController(DorkNetDbContext db) : ControllerBase
return Ok(rows);
}
[HttpGet("api/progressionEvents/active")]
[HttpGet("api/progressionEvents/event/{eventId:long}")]
[AllowAnonymous]
public IActionResult Active()
public async Task<IActionResult> Event(long eventId)
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
var end = start.AddDays(7);
var activeId = CurrentProgressionEventId();
if (eventId != activeId)
return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var (start, end) = CurrentWeekWindow();
return Ok(new
{
EventKey = $"weekly:{start:yyyyMMdd}",
ProgressionEventId = activeId,
Name = "Weekly Progression",
StartAt = start,
EndAt = end,
Active = true,
Rewards = new[]
{
new
{
ProgressionEventRewardId = activeId * 1000L,
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId,
ImageName = string.Empty,
Xp = weekly.Reward.Xp,
RewardIndex = 0,
IsBonus = false,
},
},
KeepsakeRoomLists = Array.Empty<object>(),
StartTime = start,
EndTime = end,
CollectionEndTime = end,
UsesBoost = false,
BoostDailyGameplayMinutesLimit = 0,
BoostXpMultiplier = 1.0,
PurchasableXpBoostId = (Guid?)null,
ActiveExperiment = string.Empty,
ChallengesIconImageName = string.Empty,
RewardsPipImageName = string.Empty,
EventInfoImageName = string.Empty,
});
}
[HttpGet("api/progressionEvents/active")]
[AllowAnonymous]
public IActionResult Active() => Ok(CurrentProgressionEventId());
private static long CurrentProgressionEventId()
{
var (start, _) = CurrentWeekWindow();
return long.Parse(start.ToString("yyyyMMdd"));
}
private static (DateTime Start, DateTime End) CurrentWeekWindow()
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
return (start, start.AddDays(7));
}
}
@@ -25,15 +25,29 @@ public class QuickPlayController(DorkNetDbContext db) : ControllerBase
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
/// Correct behaviour: return the full no-target launch DTO unless
/// we actually have a queued match for the caller. The /set endpoint
/// is what queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(new { });
public IActionResult GetAndClear() => Ok(QuickPlayLaunchTarget.Empty);
private sealed class QuickPlayLaunchTarget
{
public static readonly QuickPlayLaunchTarget Empty = new();
public bool OKCGKFJELAC { get; init; }
public string INMKAFBAOPC { get; init; } = string.Empty;
public int? FIOPJIIOCGA { get; init; }
public string ICKOEFDNOPM { get; init; } = string.Empty;
public int? KLDCJBLLDKN { get; init; }
public bool OFBMKDAONNE { get; init; }
public object? FMKHKNCEBCN { get; init; }
public long? EEFNKAFLPLG { get; init; }
}
public sealed class SetQuickPlayRequest
{
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
@@ -13,8 +15,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
{
[HttpGet("api/roomconsumables")]
[HttpGet("api/roomconsumables/v1/roomConsumable")]
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
[AllowAnonymous]
public async Task<IActionResult> List([FromQuery] long? id = null)
public async Task<IActionResult> List([FromRoute] long? roomId = null, [FromQuery] long? id = null)
{
var query = db.StoreItems
.Where(i => i.IsActive && i.Category.ToLower() == "consumable");
@@ -27,6 +30,24 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
[Authorize]
public async Task<IActionResult> MineForRoom(long roomId)
{
var me = this.RequireCurrentPlayerId();
var rows = await (from inventory in db.PlayerInventory
join item in db.StoreItems on inventory.ItemSlug equals item.Slug
where inventory.PlayerId == me
&& inventory.Quantity > 0
&& item.IsActive
&& item.Category.ToLower() == "consumable"
orderby item.DisplayName
select new { Inventory = inventory, Item = item })
.Take(200)
.ToListAsync();
return Ok(rows.Select(row => ToInventoryWire(row.Item, row.Inventory)));
}
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
[Authorize]
public Task<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
@@ -73,16 +94,46 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
});
}
private static object ToWire(StoreItemEntity item) => new
private static object ToWire(StoreItemEntity item)
{
RoomConsumableId = item.Id,
ItemId = item.Id,
item.Slug,
Name = item.DisplayName,
item.Description,
item.ImageName,
item.Price,
item.CurrencyType,
item.Category,
};
var roomConsumableId = StableRoomConsumableId(item.Id).ToString("D");
return new
{
RoomConsumableId = roomConsumableId,
Name = item.DisplayName,
item.ImageName,
item.Description,
item.Price,
CurrencyId = (Guid?)null,
CreatedAt = DateTimeOffset.UnixEpoch,
ItemId = item.Id,
item.Slug,
item.CurrencyType,
item.Category,
};
}
private static object ToInventoryWire(StoreItemEntity item, PlayerInventoryEntity inventory)
{
var createdAt = inventory.AcquiredAt == default
? DateTimeOffset.UnixEpoch
: new DateTimeOffset(DateTime.SpecifyKind(inventory.AcquiredAt, DateTimeKind.Utc));
return new
{
Id = StableRoomConsumableId(item.Id).ToString("D"),
ConsumableItemDesc = ToWire(item),
Count = Math.Max(0, inventory.Quantity),
InitialCount = Math.Max(0, inventory.Quantity),
CreatedAt = createdAt,
ActiveDurationMinutes = 0,
IsActive = inventory.IsActive,
IsTransferable = false,
};
}
private static Guid StableRoomConsumableId(long itemId)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"roomConsumable:{itemId}"));
return new Guid(hash);
}
}
@@ -48,12 +48,32 @@ public class RoomsController(
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
[HttpGet("rooms/hot")]
public async Task<IActionResult> Hot(
public async Task<IActionResult> HotV1(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("rooms/hot")]
public async Task<IActionResult> HotRoomServer(
[FromQuery] string? tag,
[FromQuery] string? tags,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
var all = await rooms.HotAsync(tag ?? tags, take: 200);
var page = all
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.Select(RoomService.ToWireRoom)
.ToList();
return Ok(new
{
Results = page,
TotalResults = all.Count,
});
}
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
@@ -1543,7 +1563,6 @@ public class RoomsController(
.Where(r => r.Id == roomId)
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
.FirstOrDefaultAsync();
if (room is null) return NotFound();
var favorited = await db.RoomBookmarks
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
@@ -1556,7 +1575,7 @@ public class RoomsController(
return Ok(new
{
RoomId = room.Id,
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
Favorited = favorited,
@@ -1569,9 +1588,9 @@ public class RoomsController(
VisitCount = visit?.VisitCount ?? 0,
FirstVisitedAt = visit?.FirstVisitAt,
LastVisitedAt = visit?.LastVisitAt,
RoomVisitCount = room.VisitCount,
RoomCheerCount = room.CheerCount,
RoomFavoriteCount = room.FavoriteCount,
RoomVisitCount = room?.VisitCount ?? 0,
RoomCheerCount = room?.CheerCount ?? 0,
RoomFavoriteCount = room?.FavoriteCount ?? 0,
});
}
@@ -1613,7 +1632,7 @@ public class RoomsController(
public async Task<IActionResult> CuratedPlaylistsCompat()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(BuildPlaylistUnionEntry).ToList());
return Ok(curated.Select(p => p.Id).ToList());
}
[HttpGet("clubhousesearch/mostactivenow")]
@@ -1725,17 +1744,25 @@ public class RoomsController(
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
[HttpGet("featuredrooms/current")]
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
public async Task<IActionResult> FeaturedRoomsCurrent()
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
// Rooms (List<PPKJFAAAGDO>). Returning without the third
// key throws KeyNotFoundException on the watch and the
// Featured carousel stays empty.
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
var featuredRooms = await db.Rooms
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = featuredRooms.ToDictionary(r => r.Id);
var wireRooms = ids
.Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null)
.Where(room => room is not null)
.Cast<object>()
.ToList();
return Ok(new
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
Rooms = wireRooms,
});
}
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Commerce;
[ApiController]
public class PurchaseCampaignController : ControllerBase
{
[HttpGet("/purchasecampaign/allcurrent/v2")]
[HttpGet("/purchasecampaign/allcurrent")]
[AllowAnonymous]
public IActionResult AllCurrent() => Ok(Array.Empty<object>());
}
@@ -103,6 +103,28 @@ public class MatchController(
});
}
[HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken()
{
var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
return Ok(new Dictionary<string, object>
{
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
["PhotonAppId"] = appId,
["PhotonVoiceAppId"] = voiceAppId,
["AppId"] = appId,
["VoiceAppId"] = voiceAppId,
["CloudRegion"] = region,
["Region"] = region,
["Token"] = string.Empty,
});
}
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{