feat: Add account privacy settings endpoint

- Implemented new endpoints for retrieving account privacy settings.
- Added fields for privacy preferences such as profile visibility, online status, and activity visibility.

feat: Extend play menu tags functionality

- Updated PlayMenuTagsRequest to include TrendingTags.
- Modified SetPlayMenuTags to handle trending tags.
- Enhanced logging to include trending tags.

fix: Increase maximum archive size for room zip imports

- Updated MaxArchiveBytes from 12GB to 25GB in RoomZipImportController.

feat: Improve CDN image handling

- Added local image path resolution for room images.
- Implemented lazy loading of room image aliases from JSON file.

feat: Enhance match player presence resolution

- Updated ResolveDataBlobForPresenceAsync to consider sub-room IDs and private instance status.

feat: Extend RoomEntity with new properties

- Added AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson fields.

feat: Update database schema for new room properties

- Added new columns to the Rooms table for AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson.

feat: Implement platform notifications controller

- Created PlatformNotificationsController for managing user notification preferences.
- Added endpoints for retrieving and setting notification preferences and categories.
This commit is contained in:
Alexa
2026-06-25 01:23:57 +03:00
parent b841256bd5
commit 2892cb1698
18 changed files with 1615 additions and 239 deletions
@@ -13,6 +13,7 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController]
[Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")]
[Route("api/customAvatarItems/v2")]
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
@@ -110,6 +111,26 @@ public class CustomAvatarItemsController(
return Ok(rows.Select(ToWire));
}
[HttpGet("fromCreator/{creatorId:long}")]
[AllowAnonymous]
public async Task<IActionResult> FromCreator(
long creatorId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var rows = await db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
@@ -396,19 +396,63 @@ public class ImagesController(
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0)
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var rows = await db.Photos
var query = db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task<IActionResult> GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] int sort = 0,
[FromQuery] int filter = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
@@ -56,6 +56,30 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
return Ok(rows.Select(ToWire));
}
[HttpGet("events/{eventId:long}")]
public async Task<IActionResult> EventInstances(long eventId)
{
var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me
&& k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(new
{
KeepsakeProgressionEventId = eventId,
Instances = rows.Select(ToWire),
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = new[] { eventId },
});
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId)
{
@@ -74,6 +74,95 @@ public class RoomsController(
});
}
[HttpGet("rooms/topcreators")]
[HttpGet("roomserver/rooms/topcreators")]
public async Task<IActionResult> TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.Take(250)
.ToListAsync();
var page = rows
.GroupBy(r => r.CreatorPlayerId)
.Select(g => g.First())
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToList();
return Ok(await BuildRoomServerListAsync(page));
}
[HttpGet("rooms/contestwinners")]
[HttpGet("roomserver/rooms/contestwinners")]
public async Task<IActionResult> ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.Where(r =>
EF.Functions.Like(r.TagsCsv, "%contest%") ||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
EF.Functions.Like(r.TagsCsv, "%featured%"))
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
if (rows.Count == 0)
{
rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
}
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/fromcreators")]
[HttpPost("rooms/fromcreators")]
[HttpGet("roomserver/rooms/fromcreators")]
[HttpPost("roomserver/rooms/fromcreators")]
public async Task<IActionResult> FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var creatorIds = Request.Query["creatorId"]
.Concat(Request.Query["creatorIds"])
.Concat(Request.Query["accountId"])
.Concat(Request.Query["accountIds"])
.Concat(Request.Query["id"])
.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()
.ToList();
var query = PublicRoomQuery();
if (creatorIds.Count > 0)
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.UpdatedAt)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task<IActionResult> MagicDoor()
{
var room = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Id)
.FirstOrDefaultAsync();
if (room is null) return NotFound();
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
}
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
@@ -127,6 +216,38 @@ public class RoomsController(
return Ok(result);
}
[HttpGet("rooms/{roomId:long}/similar")]
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
public async Task<IActionResult> SimilarRooms(long roomId, [FromQuery] int take = 12)
{
var seed = await rooms.GetByIdAsync(roomId);
if (seed is null) return NotFound();
var tags = (seed.TagsCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
var query = PublicRoomQuery().Where(r => r.Id != roomId);
if (tags.Length > 0)
{
var primaryTag = tags[0];
query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%"));
}
var similar = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First();
var similarWire = await BuildRoomServerListAsync(similar);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = seedWire,
["Rooms"] = similarWire,
});
}
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
@@ -172,6 +293,7 @@ public class RoomsController(
{
PinnedFilters = tags.PinnedTags,
PopularFilters = tags.PopularTags,
TrendingFilters = tags.TrendingTags,
});
}
@@ -179,7 +301,11 @@ public class RoomsController(
public async Task<IActionResult> Tags()
{
var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
return Ok(tags.PinnedTags
.Concat(tags.PopularTags)
.Concat(tags.TrendingTags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray());
}
[HttpGet("api/rooms/v1/pinnedtags")]
@@ -190,6 +316,10 @@ public class RoomsController(
public async Task<IActionResult> PopularTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
[HttpGet("api/rooms/v1/trendingtags")]
public async Task<IActionResult> TrendingTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
@@ -1388,10 +1518,19 @@ public class RoomsController(
SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors,
AllowNewUsers = src.AllowNewUsers,
MinLevel = src.MinLevel,
MaxPlayerCalculationMode = src.MaxPlayerCalculationMode,
LoadScreensJson = src.LoadScreensJson,
PromoImagesJson = src.PromoImagesJson,
PromoExternalContentJson = src.PromoExternalContentJson,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
IsStudioRoom = src.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio,
StudioSessionId = src.StudioSessionId,
TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount,
@@ -1425,7 +1564,12 @@ public class RoomsController(
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToArray();
// Scenes population: every room must produce a Scenes[0] entry
@@ -1710,7 +1854,12 @@ public class RoomsController(
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToArray();
return new
@@ -1733,6 +1882,9 @@ public class RoomsController(
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
AllowNewUsers = room.AllowNewUsers,
AllowsNewUsers = room.AllowNewUsers,
MinLevel = room.MinLevel,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
@@ -1743,10 +1895,14 @@ public class RoomsController(
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
IsStudioRoom = room.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
StudioSessionId = room.StudioSessionId,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
LoadScreenLocked = false,
// Studio/UGC version family. The 2023 client reads UgcVersion to
// decide the asset-bundle version it requests
// (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
@@ -1758,17 +1914,74 @@ public class RoomsController(
UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
IsJuniorCreated = false,
IsRecRoomApproved = false,
ExcludeFromLists = false,
BoostCount = 0,
RestrictedCircuitsAllowListNames = Array.Empty<string>(),
PublishStateAvailability = new
{
CanSaveAsBeta = false,
CanSaveAsUpdate = true,
AvailableUpdateTokenCount = 3,
NextAvailableUpdateDateTimeUtc = (string?)null,
},
PublishState = 0,
MaxPlayerCalculationMode = 0,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = Array.Empty<object>(),
PromoImages = Array.Empty<string>(),
PromoExternalContent = Array.Empty<object>(),
LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
Tags = tags,
};
}
private static int RoomTagTypeForWire(string tag)
{
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
return tag.ToLowerInvariant() switch
{
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
_ => 0,
};
}
private static bool HasRoomTag(RoomEntity room, string tag)
{
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
}
private static JsonElement[] JsonArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json) ?? Array.Empty<JsonElement>();
}
catch (JsonException)
{
return Array.Empty<JsonElement>();
}
}
private static string[] JsonStringArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
/// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio
/// scene's CurrentSave. The 2023 client's room-load gate treats anything
/// below the Studio-asset-bundle era as a legacy non-baked save and skips
@@ -1845,7 +2058,9 @@ public class RoomsController(
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/ownedby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/ownedby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
@@ -1855,7 +2070,36 @@ public class RoomsController(
[HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> VisitedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return await VisitedBy(pid.Value);
}
[HttpGet("rooms/visitedby/{playerId:long}")]
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
public async Task<IActionResult> VisitedBy(long playerId)
{
var ids = await db.RoomVisits.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.OrderByDescending(v => v.LastVisitAt)
.Select(v => v.RoomId)
.Take(100)
.ToListAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = rows.ToDictionary(r => r.Id);
var ordered = ids
.Select(id => byId.TryGetValue(id, out var room) ? room : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
return Ok(await BuildRoomServerListAsync(ordered));
}
[HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")]
@@ -1863,7 +2107,23 @@ public class RoomsController(
[HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")]
[Authorize]
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> ModeratedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.RoomRoles.AsNoTracking()
.Where(r => r.PlayerId == pid.Value && r.Accepted)
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.RoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.UpdatedAt)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")]
@@ -1871,7 +2131,13 @@ public class RoomsController(
[HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")]
[Authorize]
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> FavoritedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var rows = await rooms.BookmarkedByAsync(pid.Value);
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")]
@@ -1879,7 +2145,23 @@ public class RoomsController(
[HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")]
[Authorize]
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> CheeredByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
var ids = await db.Cheers.AsNoTracking()
.Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0)
.OrderByDescending(c => c.CheeredAt)
.Select(c => c.TargetRoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.HotScore)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
@@ -2061,6 +2343,40 @@ public class RoomsController(
return Ok(new { success = true, error = "" });
}
private IQueryable<RoomEntity> PublicRoomQuery() =>
db.Rooms.AsNoTracking().Where(r =>
r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
private async Task<List<object>> BuildRoomServerListAsync(IReadOnlyCollection<RoomEntity> roomRows)
{
if (roomRows.Count == 0) return new List<object>();
var ids = roomRows.Select(r => r.Id).Distinct().ToList();
var sceneRows = await db.RoomScenes.AsNoTracking()
.Where(s => ids.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var roleRows = await db.RoomRoles.AsNoTracking()
.Where(r => ids.Contains(r.RoomId))
.ToListAsync();
var rolesByRoom = roleRows
.GroupBy(r => r.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomRoleEntity>)g.ToList());
return roomRows
.Select(r => BuildRoomServerDetails(
r,
scenesByRoom.GetValueOrDefault(r.Id),
roles: rolesByRoom.GetValueOrDefault(r.Id)))
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
@@ -2585,7 +2901,12 @@ public class RoomsController(
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
@@ -2865,6 +3186,15 @@ public class RoomsController(
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
public long? SaveId { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
public string? DataBlobName { get; set; }
public string? Filename { get; set; }
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
@@ -2878,6 +3208,95 @@ public class RoomsController(
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var request = await ReadPublishSubRoomSaveRequestAsync();
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
if (!string.IsNullOrWhiteSpace(dataBlob))
{
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
scene.DataBlobName = dataBlob;
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
}
if (saveId is > 0)
scene.StudioSubRoomDataSaveId = saveId.Value;
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
{
var request = new PublishSubRoomSaveRequest();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId))
request.SubRoomDataSaveId = saveId;
request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename");
return request;
}
if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault()
?? Request.Query["SubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["saveId"].FirstOrDefault()
?? Request.Query["SaveId"].FirstOrDefault()
?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId))
request.SubRoomDataSaveId = querySaveId;
request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault()
?? Request.Query["DataBlobName"].FirstOrDefault()
?? Request.Query["dataBlob"].FirstOrDefault()
?? Request.Query["DataBlob"].FirstOrDefault()
?? Request.Query["filename"].FirstOrDefault()
?? Request.Query["Filename"].FirstOrDefault();
if ((Request.ContentLength ?? 0) <= 0) return request;
try
{
var body = await JsonSerializer.DeserializeAsync<PublishSubRoomSaveRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? request;
}
catch (JsonException)
{
return request;
}
}
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
@@ -3033,8 +3452,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
@@ -3042,6 +3460,28 @@ public class RoomsController(
return Ok(SceneWire(scene));
}
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
{
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
if (scene is not null || subRoomId != 0) return scene;
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
DataBlobName = CurrentOrSyntheticDataBlobName(room),
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
db.RoomScenes.Add(scene);
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
@@ -3079,6 +3519,9 @@ public class RoomsController(
if (room.IsDormRoom)
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
return string.Empty;
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
@@ -527,6 +527,204 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return null;
}
private async Task<long?> ReadBareLongAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n;
if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<bool?> ReadBareBoolAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return doc.RootElement.ValueKind == JsonValueKind.True
? true
: doc.RootElement.ValueKind == JsonValueKind.False
? false
: null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.True) return true;
if (prop.ValueKind == JsonValueKind.False) return false;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0;
if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<string?> ReadStringValueAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
foreach (var key in keys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.String)
return doc.RootElement.GetString();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.String) return prop.GetString();
if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
return prop.ToString();
}
}
catch (JsonException)
{
return null;
}
return null;
}
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var key in form.Keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
}
if (dict.Count == 0) return null;
return JsonSerializer.SerializeToElement(dict);
}
if ((Request.ContentLength ?? 0) > 0)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
return doc.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
foreach (var key in stringKeys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
}
return null;
}
private static bool TryParseBool(string? raw, out bool value)
{
if (bool.TryParse(raw, out value)) return true;
if (int.TryParse(raw, out var n))
{
value = n != 0;
return true;
}
value = false;
return false;
}
private static List<JsonElement> ReadJsonElementList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json)?.ToList() ?? new List<JsonElement>();
}
catch (JsonException)
{
return new List<JsonElement>();
}
}
private static List<string> ReadStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList()
?? new List<string>();
}
catch (JsonException)
{
return new List<string>();
}
}
private static string SerializeLimited<T>(IEnumerable<T> values, int maxChars = 4096)
{
var json = JsonSerializer.Serialize(values);
return json.Length <= maxChars ? json : "[]";
}
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
@@ -569,6 +767,171 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
});
[HttpPost("rooms/{roomId:long}/creator")]
[HttpPut("rooms/{roomId:long}/creator")]
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
"creatorAccountId", "CreatorAccountId",
"creatorPlayerId", "CreatorPlayerId",
"accountId", "AccountId",
"value", "Value");
if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" });
var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value);
if (!exists) return NotFound(new { error = "creator_not_found", creatorId });
return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value);
}
[HttpPost("rooms/{roomId:long}/allow_new_users")]
[HttpPut("rooms/{roomId:long}/allow_new_users")]
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is bool v) r.AllowNewUsers = v;
});
}
[HttpPost("rooms/{roomId:long}/min_level")]
[HttpPut("rooms/{roomId:long}/min_level")]
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MinLevel = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
"maxPlayerCalculationMode", "MaxPlayerCalculationMode",
"mode", "Mode",
"value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v);
});
}
[HttpPost("rooms/{roomId:long}/loadscreen")]
[HttpPut("rooms/{roomId:long}/loadscreen")]
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (payload is JsonElement p)
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
? p.GetRawText()
: SerializeLimited(new[] { p });
});
}
[HttpPost("rooms/{roomId:long}/promo_images")]
[HttpPut("rooms/{roomId:long}/promo_images")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson);
if (!images.Contains(image, StringComparer.OrdinalIgnoreCase))
images.Add(image);
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
return await ApplyAndReturn(roomId, r =>
{
var images = ReadStringList(r.PromoImagesJson)
.Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase))
.ToList();
r.PromoImagesJson = SerializeLimited(images);
});
}
[HttpPost("rooms/{roomId:long}/promo_external")]
[HttpPut("rooms/{roomId:long}/promo_external")]
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson);
var raw = payload.Value.GetRawText();
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
items.Add(payload.Value);
r.PromoExternalContentJson = SerializeLimited(items);
});
}
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
return await ApplyAndReturn(roomId, r =>
{
var items = ReadJsonElementList(r.PromoExternalContentJson)
.Where(i => !PromoExternalMatches(i, match))
.ToList();
r.PromoExternalContentJson = SerializeLimited(items);
});
}
private static bool PromoExternalMatches(JsonElement item, string match)
{
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
if (item.ValueKind == JsonValueKind.String)
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
if (item.ValueKind != JsonValueKind.Object) return false;
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
{
if (item.TryGetProperty(key, out var prop) &&
prop.ValueKind == JsonValueKind.String &&
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
@@ -639,10 +1002,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
AllowNewUsers = source.AllowNewUsers,
MinLevel = source.MinLevel,
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
LoadScreensJson = source.LoadScreensJson,
PromoImagesJson = source.PromoImagesJson,
PromoExternalContentJson = source.PromoExternalContentJson,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
IsStudioRoom = source.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
StudioSessionId = source.StudioSessionId,
TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName,
};
@@ -667,6 +667,31 @@ public class AccountsController(
[HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
[HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
[HttpGet("/account/{accountId:long}/privacysettings")]
public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new
{
AccountId = (int)accountId,
IsProfileVisible = true,
IsOnlineStatusVisible = true,
IsActivityVisible = true,
ShowOnlineStatus = true,
ShowActivity = true,
ShowCurrentRoom = true,
ReceiveFriendRequests = true,
ReceiveInvites = true,
ReceiveMessages = true,
WhoCanSeeOnlineStatus = 0,
WhoCanSeeActivity = 0,
WhoCanSeeCurrentRoom = 0,
WhoCanSendFriendRequests = 0,
WhoCanInviteMe = 0,
WhoCanMessageMe = 0,
VoicePrivacy = 0,
TextChatPrivacy = 0,
});
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints.
@@ -1609,16 +1609,18 @@ public class AdminController(
public sealed record PlayMenuTagsRequest(
List<string>? PinnedTags,
List<string>? PopularTags);
List<string>? PopularTags,
List<string>? TrendingTags);
[HttpPost("settings/play-menu-tags")]
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
{
var tags = await serverSettings.SetPlayMenuTagsAsync(
body.PinnedTags ?? [],
body.PopularTags ?? []);
body.PopularTags ?? [],
body.TrendingTags ?? []);
await LogAsync("play_menu_tags_updated", "system", 0,
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)} trending={string.Join(",", tags.TrendingTags)}");
await db.SaveChangesAsync();
return Ok(tags);
}
@@ -59,7 +59,7 @@ public class RoomZipImportController(
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
private const long MaxArchiveBytes = 12_000_000_000L;
private const long MaxArchiveBytes = 25_000_000_000L;
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService):
@@ -60,6 +60,8 @@ public class CdnController(
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly Lazy<Dictionary<string, string>> RoomImageAliases =
new(LoadRoomImageAliases);
// Specific route prefixes only — NO global catch-all wildcard.
// A previous version had [Route("/{*path:minlength(1)}")] which
@@ -340,8 +342,8 @@ public class CdnController(
return RespondWithTransforms(s3Bytes, contentType);
}
var full = Path.Combine(ImageDir, safe);
if (System.IO.File.Exists(full))
var full = ResolveLocalImagePath(safe);
if (full is not null)
{
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
Request.Host.Host, path, full);
@@ -506,10 +508,21 @@ public class CdnController(
// S3 is the only canonical store — DB carries text-only
// metadata (BlobName, owner, timestamps) and never holds
// bytes. If S3 misses, the bytes don't exist on this server.
// bytes. RRO thumbnail images are the exception for local dev:
// tools/fetch-room-images.py stores them under data/images/, and
// the 2023 client requests them through cdn.* rather than img.*.
if (IsImageName(fileName))
{
var imagePath = ResolveLocalImagePath(fileName);
if (imagePath is not null)
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}",
Request.Host.Host, fileName, imagePath, imageBytes.Length);
Response.Headers.CacheControl = "public, max-age=300";
return RespondWithTransforms(imageBytes, MimeFromName(fileName));
}
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
Request.Host.Host, fileName, Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
@@ -630,4 +643,52 @@ public class CdnController(
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
}
private static string? ResolveLocalImagePath(string fileName)
{
var direct = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(direct)) return direct;
var baseName = Path.GetFileNameWithoutExtension(fileName);
if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
return null;
var roomName = baseName["image_".Length..];
if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName))
return null;
var mapped = Path.Combine(ImageDir, mappedName);
return System.IO.File.Exists(mapped) ? mapped : null;
}
private static Dictionary<string, string> LoadRoomImageAliases()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
};
foreach (var path in candidates)
{
if (!System.IO.File.Exists(path)) continue;
try
{
using var fs = System.IO.File.OpenRead(path);
var map = JsonSerializer.Deserialize<Dictionary<string, string>>(fs);
if (map is null) continue;
foreach (var (roomName, imageName) in map)
if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName))
result[roomName] = imageName;
break;
}
catch
{
// Fall back to direct filename lookup.
}
}
return result;
}
}
@@ -1300,6 +1300,8 @@ public class GoToController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
@@ -1358,6 +1360,7 @@ public class GoToController(
"cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038",
"recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f",
"recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11",
"recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5",
_ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom
};
}
@@ -329,7 +329,12 @@ public class MatchPlayerController(
return null;
}
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId, room.RoomInstanceId);
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
room.RoomId,
playerId,
room.RoomInstanceId,
room.SubRoomId,
room.IsPrivate);
if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
{
@@ -434,7 +439,9 @@ public class MatchPlayerController(
private async Task<string> ResolveDataBlobForPresenceAsync(
long roomId,
int playerId,
long? roomInstanceId = null)
long? roomInstanceId = null,
long? subRoomId = null,
bool isPrivateInstance = false)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
@@ -445,7 +452,7 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (roomInstanceId is > 0)
if (isPrivateInstance && roomInstanceId is > 0)
{
var instanceBlob = await db.PrivateInstances.AsNoTracking()
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
@@ -454,8 +461,19 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
}
if (subRoomId is >= 0)
{
var sceneBlob = await db.RoomScenes.AsNoTracking()
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
.Select(scene => scene.DataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
}
@@ -0,0 +1,122 @@
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.PlatformNotifications;
[ApiController]
[Authorize]
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("/preferences")]
[HttpGet("/platformnotifications/preferences")]
public async Task<IActionResult> Preferences()
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
return Ok(ToPreferencesWire(row));
}
[HttpPut("/preferences")]
[HttpPost("/preferences")]
[HttpPut("/platformnotifications/preferences")]
[HttpPost("/platformnotifications/preferences")]
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body)
{
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = Me,
Platform = "platform",
};
db.NotificationPrefs.Add(row);
}
if (body is not null)
{
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll;
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage;
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest;
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite;
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
row.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(ToPreferencesWire(row));
}
[HttpGet("/config/categories")]
[HttpGet("/platformnotifications/config/categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var categories = new[]
{
Category(1, "messages", "Messages"),
Category(2, "friendRequests", "Friend Requests"),
Category(3, "eventInvites", "Event Invites"),
Category(4, "announcements", "Announcements"),
};
return Ok(new
{
Categories = categories,
Results = categories,
TotalResults = categories.Length,
});
}
private static object Category(int id, string key, string name) => new
{
Id = id,
CategoryId = id,
Key = key,
Name = name,
DisplayName = name,
IsEnabled = true,
Enabled = true,
DefaultEnabled = true,
};
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
{
AllowAll = row?.AllowAll ?? true,
Enabled = row?.AllowAll ?? true,
AllowMessages = row?.AllowMessage ?? true,
Messages = row?.AllowMessage ?? true,
AllowFriendRequests = row?.AllowFriendRequest ?? true,
FriendRequests = row?.AllowFriendRequest ?? true,
AllowEventInvites = row?.AllowEventInvite ?? true,
EventInvites = row?.AllowEventInvite ?? true,
AllowAnnouncements = row?.AllowAnnouncements ?? true,
Announcements = row?.AllowAnnouncements ?? true,
Categories = new[]
{
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
},
};
public sealed class PlatformNotificationPrefsRequest
{
public bool? AllowAll { get; set; }
public bool? Enabled { get; set; }
public bool? AllowMessages { get; set; }
public bool? Messages { get; set; }
public bool? AllowFriendRequests { get; set; }
public bool? FriendRequests { get; set; }
public bool? AllowEventInvites { get; set; }
public bool? EventInvites { get; set; }
public bool? AllowAnnouncements { get; set; }
public bool? Announcements { get; set; }
}
}