1982 lines
79 KiB
C#
1982 lines
79 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using LiteDB;
|
|
|
|
namespace Mocha2023.Classes.DBs
|
|
{
|
|
|
|
public static class CreatorFeatureDB
|
|
{
|
|
private static readonly object Sync = new();
|
|
private static readonly LiteDatabase Database = OpenDatabase();
|
|
|
|
private static readonly ILiteCollection<InventionRecord> Inventions =
|
|
Database.GetCollection<InventionRecord>("Inventions");
|
|
|
|
private const long MaxClientInventionId = 1_000_000_000;
|
|
|
|
private static readonly ILiteCollection<InventionCheerRecord> InventionCheers =
|
|
Database.GetCollection<InventionCheerRecord>("InventionCheers");
|
|
private static readonly ILiteCollection<InventionAliasRecord> InventionAliases =
|
|
Database.GetCollection<InventionAliasRecord>("InventionAliases");
|
|
private static readonly ILiteCollection<InventionPurchaseRecord> InventionPurchases =
|
|
Database.GetCollection<InventionPurchaseRecord>("InventionPurchases");
|
|
private static readonly ILiteCollection<MeetupCodeRecord> MeetupCodes =
|
|
Database.GetCollection<MeetupCodeRecord>("MeetupCodes");
|
|
private static readonly ILiteCollection<CloudVariableRecord> CloudVariables =
|
|
Database.GetCollection<CloudVariableRecord>("CloudVariables");
|
|
private static readonly ILiteCollection<RoomPlayerDataRecord> RoomPlayerData =
|
|
Database.GetCollection<RoomPlayerDataRecord>("RoomPlayerData");
|
|
|
|
private static LiteDatabase OpenDatabase()
|
|
{
|
|
string directory = Path.Combine(Program.dataDir, "DBs");
|
|
Directory.CreateDirectory(directory);
|
|
return new LiteDatabase(Path.Combine(directory, "CreatorFeatures.db"));
|
|
}
|
|
|
|
static CreatorFeatureDB()
|
|
{
|
|
Directory.CreateDirectory(Path.Combine(Program.dataDir, "DBs"));
|
|
LiteDbMaintenance.StartPeriodicCheckpoint("CreatorFeatures.db", Database);
|
|
|
|
Inventions.EnsureIndex(value => value.CreatorAccountId);
|
|
Inventions.EnsureIndex(value => value.RoomId);
|
|
Inventions.EnsureIndex(value => value.UpdatedAtUtc);
|
|
InventionCheers.EnsureIndex(value => value.InventionId);
|
|
InventionCheers.EnsureIndex(value => value.AccountId);
|
|
InventionAliases.EnsureIndex(value => value.CurrentInventionId);
|
|
InventionPurchases.EnsureIndex(value => value.InventionId);
|
|
InventionPurchases.EnsureIndex(value => value.AccountId);
|
|
MeetupCodes.EnsureIndex(value => value.CreatorAccountId);
|
|
MeetupCodes.EnsureIndex(value => value.RoomInstanceId);
|
|
MeetupCodes.EnsureIndex(value => value.ExpiresAtUtc);
|
|
CloudVariables.EnsureIndex(value => value.RoomId);
|
|
CloudVariables.EnsureIndex(value => value.AccountId);
|
|
CloudVariables.EnsureIndex(value => value.UpdatedAtUtc);
|
|
RoomPlayerData.EnsureIndex(value => value.RoomId);
|
|
RoomPlayerData.EnsureIndex(value => value.AccountId);
|
|
RoomPlayerData.EnsureIndex(value => value.UpdatedAtUtc);
|
|
|
|
MigrateLegacyInventionFiles();
|
|
RepairOversizedInventionIds();
|
|
BackfillInventionSaveMetadata();
|
|
RepairStoredInventionBlobs();
|
|
}
|
|
|
|
public sealed class InventionRecord
|
|
{
|
|
[BsonId]
|
|
public long InventionId { get; set; }
|
|
public long CreatorAccountId { get; set; }
|
|
public long RoomId { get; set; }
|
|
public string Name { get; set; } = string.Empty;
|
|
public string Description { get; set; } = string.Empty;
|
|
public string ImageName { get; set; } = string.Empty;
|
|
public string DataBlob { get; set; } = string.Empty;
|
|
public string DataBlobHash { get; set; } = string.Empty;
|
|
public long DataBlobSize { get; set; }
|
|
|
|
public int InstantiationCost { get; set; }
|
|
public int LightsCost { get; set; }
|
|
public int ChipsCost { get; set; }
|
|
public int CloudVariablesCost { get; set; }
|
|
public int AiCost { get; set; }
|
|
public List<long> ReferencedInventionIds { get; set; } = new();
|
|
public int CreatorAccountRole { get; set; }
|
|
public List<string> Tags { get; set; } = new();
|
|
public int Version { get; set; } = 1;
|
|
public bool IsPublished { get; set; } = true;
|
|
public int Uses { get; set; }
|
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
public string RawPayloadJson { get; set; } = "{}";
|
|
}
|
|
|
|
public sealed class InventionAliasRecord
|
|
{
|
|
[BsonId]
|
|
public long LegacyInventionId { get; set; }
|
|
public long CurrentInventionId { get; set; }
|
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public sealed class InventionPurchaseRecord
|
|
{
|
|
[BsonId]
|
|
public string Id { get; set; } = string.Empty;
|
|
public long InventionId { get; set; }
|
|
public long AccountId { get; set; }
|
|
public int PricePaid { get; set; }
|
|
public DateTime PurchasedAtUtc { get; set; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public sealed class InventionCheerRecord
|
|
{
|
|
[BsonId]
|
|
public string Id { get; set; } = string.Empty;
|
|
public long InventionId { get; set; }
|
|
public long AccountId { get; set; }
|
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public sealed class MeetupCodeRecord
|
|
{
|
|
[BsonId]
|
|
public string Code { get; set; } = string.Empty;
|
|
public long CreatorAccountId { get; set; }
|
|
public long RoomId { get; set; }
|
|
public long SubRoomId { get; set; }
|
|
public long RoomInstanceId { get; set; }
|
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
public DateTime ExpiresAtUtc { get; set; } = DateTime.UtcNow.AddHours(6);
|
|
public int UseCount { get; set; }
|
|
public int MaxUses { get; set; } = 100;
|
|
public bool IsActive { get; set; } = true;
|
|
}
|
|
|
|
public sealed class CloudVariableRecord
|
|
{
|
|
[BsonId]
|
|
public string Id { get; set; } = string.Empty;
|
|
public long RoomId { get; set; }
|
|
public long AccountId { get; set; }
|
|
public string Key { get; set; } = string.Empty;
|
|
public string ValueJson { get; set; } = "null";
|
|
public int Version { get; set; } = 1;
|
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
public long UpdatedByAccountId { get; set; }
|
|
}
|
|
|
|
public sealed class RoomPlayerDataRecord
|
|
{
|
|
[BsonId]
|
|
public string Id { get; set; } = string.Empty;
|
|
public long RoomId { get; set; }
|
|
public long AccountId { get; set; }
|
|
public string DataJson { get; set; } = "{}";
|
|
public int Version { get; set; } = 1;
|
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
|
public long UpdatedByAccountId { get; set; }
|
|
}
|
|
|
|
public static InventionRecord SaveInvention(
|
|
long accountId,
|
|
JsonElement payload,
|
|
long? forcedInventionId = null)
|
|
{
|
|
if (accountId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(accountId));
|
|
|
|
lock (Sync)
|
|
{
|
|
bool isForcedImport = forcedInventionId.GetValueOrDefault() > 0;
|
|
long requestedId = isForcedImport
|
|
? forcedInventionId!.Value
|
|
: FindLong(payload,
|
|
"InventionId", "inventionId", "Id", "id") ?? 0;
|
|
|
|
InventionRecord? existing = requestedId > 0
|
|
? FindInventionNoLock(requestedId)
|
|
: null;
|
|
|
|
if (existing != null && existing.CreatorAccountId != accountId)
|
|
throw new UnauthorizedAccessException(
|
|
"An invention can only be edited by its creator.");
|
|
|
|
bool canPreserveImportedId = isForcedImport &&
|
|
requestedId > 0 &&
|
|
requestedId <= MaxClientInventionId &&
|
|
Inventions.FindById(requestedId) == null;
|
|
long inventionId = existing?.InventionId ??
|
|
(canPreserveImportedId ? requestedId : NextInventionIdNoLock());
|
|
DateTime now = DateTime.UtcNow;
|
|
List<string> tags = ReadStringList(payload,
|
|
"Tags", "tags", "TagNames", "tagNames");
|
|
|
|
var record = existing ?? new InventionRecord
|
|
{
|
|
InventionId = inventionId,
|
|
CreatorAccountId = accountId,
|
|
CreatedAtUtc = now
|
|
};
|
|
|
|
record.RoomId = FindLong(payload,
|
|
"CreationRoomId", "creationRoomId",
|
|
"RoomId", "roomId", "SourceRoomId", "sourceRoomId")
|
|
?? record.RoomId;
|
|
record.InstantiationCost = ReadNonNegativeInt(
|
|
payload,
|
|
record.InstantiationCost,
|
|
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
|
record.LightsCost = ReadNonNegativeInt(
|
|
payload,
|
|
record.LightsCost,
|
|
"LightsCost", "lightsCost");
|
|
record.ChipsCost = ReadNonNegativeInt(
|
|
payload,
|
|
record.ChipsCost,
|
|
"ChipsCost", "chipsCost");
|
|
record.CloudVariablesCost = ReadNonNegativeInt(
|
|
payload,
|
|
record.CloudVariablesCost,
|
|
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
|
record.AiCost = ReadNonNegativeInt(
|
|
payload,
|
|
record.AiCost,
|
|
"AiCost", "aiCost", "AICost");
|
|
record.CreatorAccountRole = ReadNonNegativeInt(
|
|
payload,
|
|
record.CreatorAccountRole,
|
|
"CreatorAccountRole", "creatorAccountRole");
|
|
List<long> referencedInventions = ReadLongList(
|
|
payload,
|
|
"ReferencedInventions", "referencedInventions",
|
|
"ReferencedInventionIds", "referencedInventionIds");
|
|
if (referencedInventions.Count > 0)
|
|
record.ReferencedInventionIds = referencedInventions.Take(512).ToList();
|
|
record.Name = Limit(
|
|
FindString(payload,
|
|
"Name", "name", "Title", "title",
|
|
"InventionName", "inventionName")
|
|
?? record.Name,
|
|
128);
|
|
if (string.IsNullOrWhiteSpace(record.Name))
|
|
record.Name = $"Invention {inventionId}";
|
|
|
|
record.Description = Limit(
|
|
FindString(payload, "Description", "description")
|
|
?? record.Description,
|
|
1_000);
|
|
record.ImageName = Limit(
|
|
FindString(payload,
|
|
"ImageName", "imageName", "Image", "image")
|
|
?? record.ImageName,
|
|
512);
|
|
record.DataBlob = Limit(
|
|
FindString(payload,
|
|
"ObjectDataFilename", "objectDataFilename",
|
|
"ObjectDataBlob", "objectDataBlob",
|
|
"SubRoomDataFilename", "subRoomDataFilename",
|
|
"DataBlob", "dataBlob", "DataBlobPath", "dataBlobPath",
|
|
"Filename", "filename", "FileName", "fileName",
|
|
"DataBlobName", "dataBlobName", "BlobName", "blobName")
|
|
?? record.DataBlob,
|
|
512);
|
|
|
|
string? requestedBlobHash = FindString(payload,
|
|
"DataBlobHash", "dataBlobHash",
|
|
"ObjectDataBlobHash", "objectDataBlobHash",
|
|
"ObjectDataHash", "objectDataHash", "Hash", "hash");
|
|
if (!string.IsNullOrWhiteSpace(requestedBlobHash))
|
|
record.DataBlobHash = Limit(requestedBlobHash, 256);
|
|
|
|
long? requestedBlobSize = FindLong(payload,
|
|
"DataBlobSize", "dataBlobSize", "ObjectDataSize", "objectDataSize",
|
|
"ContentLength", "contentLength", "Length", "length");
|
|
if (requestedBlobSize is >= 0)
|
|
record.DataBlobSize = requestedBlobSize.Value;
|
|
|
|
if (tags.Count > 0)
|
|
record.Tags = tags.Take(30).ToList();
|
|
|
|
bool? requestedPublished = FindBool(payload,
|
|
"IsPublished", "isPublished", "Published", "published");
|
|
if (requestedPublished.HasValue)
|
|
record.IsPublished = requestedPublished.Value;
|
|
|
|
int requestedVersion = (int)Math.Clamp(
|
|
FindLong(payload, "Version", "version") ?? 0,
|
|
0,
|
|
int.MaxValue);
|
|
record.Version = Math.Max(
|
|
Math.Max(1, record.Version + (existing == null ? 0 : 1)),
|
|
requestedVersion);
|
|
record.UpdatedAtUtc = now;
|
|
record.RawPayloadJson = payload.GetRawText();
|
|
RepairInventionBlobMetadataNoLock(record);
|
|
|
|
Inventions.Upsert(record);
|
|
|
|
if (existing == null && requestedId > 0 && requestedId != inventionId)
|
|
{
|
|
InventionAliases.Upsert(new InventionAliasRecord
|
|
{
|
|
LegacyInventionId = requestedId,
|
|
CurrentInventionId = inventionId,
|
|
CreatedAtUtc = now
|
|
});
|
|
}
|
|
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static InventionRecord? GetInvention(long inventionId)
|
|
{
|
|
if (inventionId <= 0)
|
|
return null;
|
|
|
|
lock (Sync)
|
|
return FindInventionNoLock(inventionId);
|
|
}
|
|
|
|
public static long ResolveInventionId(long inventionId)
|
|
{
|
|
InventionRecord? record = GetInvention(inventionId);
|
|
return record?.InventionId ?? inventionId;
|
|
}
|
|
|
|
public static bool RecordInventionPurchase(
|
|
long accountId,
|
|
long inventionId,
|
|
int pricePaid = 0)
|
|
{
|
|
if (accountId <= 0 || inventionId <= 0 || pricePaid < 0)
|
|
return false;
|
|
|
|
lock (Sync)
|
|
{
|
|
InventionRecord? invention = FindInventionNoLock(inventionId);
|
|
if (invention == null ||
|
|
(!invention.IsPublished && invention.CreatorAccountId != accountId) ||
|
|
!HasValidInventionBlob(invention))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
InventionPurchases.Upsert(new InventionPurchaseRecord
|
|
{
|
|
Id = InventionPurchaseId(invention.InventionId, accountId),
|
|
InventionId = invention.InventionId,
|
|
AccountId = accountId,
|
|
PricePaid = pricePaid,
|
|
PurchasedAtUtc = DateTime.UtcNow
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public static bool HasInventionAccess(long accountId, long inventionId)
|
|
{
|
|
if (accountId <= 0 || inventionId <= 0)
|
|
return false;
|
|
|
|
lock (Sync)
|
|
{
|
|
InventionRecord? invention = FindInventionNoLock(inventionId);
|
|
if (invention == null)
|
|
return false;
|
|
return invention.CreatorAccountId == accountId ||
|
|
InventionPurchases.Exists(value =>
|
|
value.Id == InventionPurchaseId(invention.InventionId, accountId));
|
|
}
|
|
}
|
|
|
|
public static InventionRecord? SetInventionTags(
|
|
long inventionId,
|
|
long accountId,
|
|
IEnumerable<string> tags)
|
|
{
|
|
if (inventionId <= 0)
|
|
return null;
|
|
|
|
lock (Sync)
|
|
{
|
|
InventionRecord? record = Inventions.FindById(inventionId);
|
|
if (record == null)
|
|
return null;
|
|
if (record.CreatorAccountId != accountId)
|
|
{
|
|
throw new UnauthorizedAccessException(
|
|
"An invention can only be edited by its creator.");
|
|
}
|
|
|
|
record.Tags = tags
|
|
.Select(value => Limit(value.Trim(), 32))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(30)
|
|
.ToList();
|
|
record.Version = Math.Max(1, record.Version + 1);
|
|
record.UpdatedAtUtc = DateTime.UtcNow;
|
|
Inventions.Update(record);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static InventionRecord? IncrementInventionUse(long inventionId)
|
|
{
|
|
if (inventionId <= 0)
|
|
return null;
|
|
|
|
lock (Sync)
|
|
{
|
|
InventionRecord? record = Inventions.FindById(inventionId);
|
|
if (record == null)
|
|
return null;
|
|
record.Uses = Math.Max(0, record.Uses) + 1;
|
|
record.UpdatedAtUtc = DateTime.UtcNow;
|
|
Inventions.Update(record);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static bool DeleteInvention(long inventionId, long accountId)
|
|
{
|
|
lock (Sync)
|
|
{
|
|
InventionRecord? record = Inventions.FindById(inventionId);
|
|
if (record == null || record.CreatorAccountId != accountId ||
|
|
!Inventions.Delete(inventionId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
InventionCheers.DeleteMany(value => value.InventionId == inventionId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public static bool SetInventionCheer(
|
|
long inventionId,
|
|
long accountId,
|
|
bool cheered)
|
|
{
|
|
if (inventionId <= 0 || accountId <= 0)
|
|
return false;
|
|
|
|
lock (Sync)
|
|
{
|
|
if (Inventions.FindById(inventionId) == null)
|
|
return false;
|
|
|
|
string id = InventionCheerId(inventionId, accountId);
|
|
if (!cheered)
|
|
{
|
|
InventionCheers.Delete(id);
|
|
return true;
|
|
}
|
|
|
|
InventionCheers.Upsert(new InventionCheerRecord
|
|
{
|
|
Id = id,
|
|
InventionId = inventionId,
|
|
AccountId = accountId,
|
|
CreatedAtUtc = DateTime.UtcNow
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public static bool IsInventionCheered(long inventionId, long accountId)
|
|
{
|
|
if (inventionId <= 0 || accountId <= 0)
|
|
return false;
|
|
return InventionCheers.Exists(value =>
|
|
value.Id == InventionCheerId(inventionId, accountId));
|
|
}
|
|
|
|
public static int GetInventionCheerCount(long inventionId) =>
|
|
inventionId <= 0
|
|
? 0
|
|
: InventionCheers.Count(value => value.InventionId == inventionId);
|
|
|
|
private static string InventionCheerId(long inventionId, long accountId) =>
|
|
$"{accountId}:{inventionId}";
|
|
|
|
public static List<InventionRecord> SearchInventions(
|
|
string? query = null,
|
|
long? creatorAccountId = null,
|
|
long? roomId = null,
|
|
string? tag = null,
|
|
bool includeUnpublished = false,
|
|
int skip = 0,
|
|
int take = 100)
|
|
{
|
|
skip = Math.Max(0, skip);
|
|
take = Math.Clamp(take, 1, 100);
|
|
string q = query?.Trim() ?? string.Empty;
|
|
string wantedTag = tag?.Trim() ?? string.Empty;
|
|
|
|
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
|
|
|
if (!includeUnpublished)
|
|
rows = rows.Where(HasValidInventionBlob);
|
|
if (!includeUnpublished)
|
|
rows = rows.Where(value => value.IsPublished);
|
|
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
|
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
|
if (roomId.HasValue && roomId.Value > 0)
|
|
rows = rows.Where(value => value.RoomId == roomId.Value);
|
|
if (!string.IsNullOrWhiteSpace(wantedTag))
|
|
{
|
|
rows = rows.Where(value => value.Tags?.Any(item =>
|
|
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
rows = rows.Where(value =>
|
|
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
|
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
|
value.Tags.Any(item =>
|
|
item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
|
|
return rows
|
|
.OrderByDescending(value => value.Uses)
|
|
.ThenByDescending(value => value.UpdatedAtUtc)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToList();
|
|
}
|
|
|
|
public static int CountInventions(
|
|
string? query = null,
|
|
long? creatorAccountId = null,
|
|
long? roomId = null,
|
|
string? tag = null,
|
|
bool includeUnpublished = false)
|
|
{
|
|
string q = query?.Trim() ?? string.Empty;
|
|
string wantedTag = tag?.Trim() ?? string.Empty;
|
|
IEnumerable<InventionRecord> rows = Inventions.FindAll();
|
|
if (!includeUnpublished)
|
|
rows = rows.Where(HasValidInventionBlob);
|
|
if (!includeUnpublished)
|
|
rows = rows.Where(value => value.IsPublished);
|
|
if (creatorAccountId.HasValue && creatorAccountId.Value > 0)
|
|
rows = rows.Where(value => value.CreatorAccountId == creatorAccountId.Value);
|
|
if (roomId.HasValue && roomId.Value > 0)
|
|
rows = rows.Where(value => value.RoomId == roomId.Value);
|
|
if (!string.IsNullOrWhiteSpace(wantedTag))
|
|
{
|
|
rows = rows.Where(value => value.Tags?.Any(item =>
|
|
string.Equals(item, wantedTag, StringComparison.OrdinalIgnoreCase)) == true);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(q))
|
|
{
|
|
rows = rows.Where(value =>
|
|
value.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
|
value.Description.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
|
value.Tags.Any(item => item.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
return rows.Count();
|
|
}
|
|
|
|
public static object? ToClientInventionVersion(InventionRecord value)
|
|
{
|
|
if (!TryGetInventionBlob(
|
|
value,
|
|
out _,
|
|
out string dataBlobPath,
|
|
out string dataBlobName,
|
|
out string dataBlobHash,
|
|
out long dataBlobSize))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
int versionNumber = Math.Max(1, value.Version);
|
|
return new
|
|
{
|
|
InventionId = value.InventionId,
|
|
Id = value.InventionId,
|
|
ReplicationId = StableReplicationId(
|
|
"invention-version",
|
|
value.InventionId,
|
|
versionNumber),
|
|
VersionNumber = versionNumber,
|
|
Version = versionNumber,
|
|
InventionVersionId = value.InventionId,
|
|
VersionId = value.InventionId,
|
|
BlobName = dataBlobName,
|
|
DataBlob = dataBlobName,
|
|
DataBlobName = dataBlobName,
|
|
DataBlobPath = dataBlobPath,
|
|
ObjectDataFilename = dataBlobName,
|
|
BlobHash = dataBlobHash,
|
|
DataBlobHash = dataBlobHash,
|
|
BlobSize = dataBlobSize,
|
|
DataBlobSize = dataBlobSize,
|
|
BlobUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
|
DownloadUrl = "/cdn/" + dataBlobPath.TrimStart('/'),
|
|
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
|
LightsCost = Math.Max(0, value.LightsCost),
|
|
ChipsCost = Math.Max(0, value.ChipsCost),
|
|
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
|
AiCost = Math.Max(0, value.AiCost),
|
|
CanSpawn = true
|
|
};
|
|
}
|
|
|
|
public static object ToClientInvention(InventionRecord value)
|
|
{
|
|
bool hasBlob = TryGetInventionBlob(
|
|
value,
|
|
out _,
|
|
out string dataBlobPath,
|
|
out string dataBlobName,
|
|
out string dataBlobHash,
|
|
out long dataBlobSize);
|
|
|
|
if (hasBlob &&
|
|
(!string.Equals(value.DataBlob, dataBlobPath, StringComparison.Ordinal) ||
|
|
!string.Equals(value.DataBlobHash, dataBlobHash, StringComparison.Ordinal) ||
|
|
value.DataBlobSize != dataBlobSize))
|
|
{
|
|
lock (Sync)
|
|
{
|
|
value.DataBlob = dataBlobPath;
|
|
value.DataBlobHash = dataBlobHash;
|
|
value.DataBlobSize = dataBlobSize;
|
|
Inventions.Upsert(value);
|
|
}
|
|
}
|
|
|
|
int currentVersionNumber = Math.Max(1, value.Version);
|
|
string inventionReplicationId = StableReplicationId(
|
|
"invention",
|
|
value.InventionId,
|
|
0);
|
|
object? currentVersion = hasBlob
|
|
? ToClientInventionVersion(value)
|
|
: null;
|
|
|
|
return new
|
|
{
|
|
InventionId = value.InventionId,
|
|
Id = value.InventionId,
|
|
ReplicationId = inventionReplicationId,
|
|
Name = value.Name,
|
|
InventionName = value.Name,
|
|
Description = value.Description,
|
|
CreatorAccountId = value.CreatorAccountId,
|
|
CreatorPlayerId = value.CreatorAccountId,
|
|
CreatorId = value.CreatorAccountId,
|
|
RoomId = value.RoomId,
|
|
ImageName = value.ImageName,
|
|
Image = value.ImageName,
|
|
PreviewImageName = value.ImageName,
|
|
DataBlob = hasBlob ? dataBlobName : string.Empty,
|
|
DataBlobPath = hasBlob ? dataBlobPath : string.Empty,
|
|
DataBlobName = hasBlob ? dataBlobName : string.Empty,
|
|
Filename = hasBlob ? dataBlobName : string.Empty,
|
|
ObjectDataFilename = hasBlob ? dataBlobName : string.Empty,
|
|
ObjectDataBlob = hasBlob ? dataBlobName : string.Empty,
|
|
DataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
|
ObjectDataBlobHash = hasBlob ? dataBlobHash : string.Empty,
|
|
DataBlobSize = hasBlob ? dataBlobSize : 0,
|
|
InstantiationCost = Math.Max(0, value.InstantiationCost),
|
|
LightsCost = Math.Max(0, value.LightsCost),
|
|
ChipsCost = Math.Max(0, value.ChipsCost),
|
|
CloudVariablesCost = Math.Max(0, value.CloudVariablesCost),
|
|
AiCost = Math.Max(0, value.AiCost),
|
|
ReferencedInventions = value.ReferencedInventionIds ?? new List<long>(),
|
|
ReferencedInventionIds = value.ReferencedInventionIds ?? new List<long>(),
|
|
DataBlobUrl = hasBlob
|
|
? "/cdn/" + dataBlobPath.TrimStart('/')
|
|
: string.Empty,
|
|
HasValidObjects = hasBlob,
|
|
CanSpawn = hasBlob,
|
|
Tags = value.Tags ?? new List<string>(),
|
|
TagNames = value.Tags ?? new List<string>(),
|
|
CurrentVersionNumber = currentVersionNumber,
|
|
CurrentVersion = currentVersion,
|
|
InventionVersion = currentVersion,
|
|
LatestVersion = currentVersion,
|
|
Version = currentVersionNumber,
|
|
Accessibility = 1,
|
|
CreationRoomId = value.RoomId,
|
|
CreatorPermission = 100,
|
|
|
|
GeneralPermission = 20,
|
|
IsFeatured = false,
|
|
IsAGInvention = false,
|
|
IsCertifiedInvention = false,
|
|
|
|
Price = 0,
|
|
AllowTrial = true,
|
|
HideFromPlayer = false,
|
|
IsPublished = value.IsPublished,
|
|
Published = value.IsPublished,
|
|
Uses = value.Uses,
|
|
NumDownloads = value.Uses,
|
|
NumPlayersHaveUsedInRoom = value.Uses,
|
|
CheerCount = GetInventionCheerCount(value.InventionId),
|
|
CreatedAt = value.CreatedAtUtc,
|
|
FirstPublishedAt = value.CreatedAtUtc,
|
|
CreationDate = value.CreatedAtUtc,
|
|
ModifiedAt = value.UpdatedAtUtc,
|
|
UpdatedAt = value.UpdatedAtUtc,
|
|
LastUpdated = value.UpdatedAtUtc
|
|
};
|
|
}
|
|
|
|
public static bool HasValidInventionBlob(InventionRecord value) =>
|
|
TryGetInventionBlob(
|
|
value,
|
|
out _,
|
|
out _,
|
|
out _,
|
|
out _,
|
|
out _);
|
|
|
|
public static bool TryGetInventionBlob(
|
|
InventionRecord value,
|
|
out string fullPath,
|
|
out string relativePath,
|
|
out string filename,
|
|
out string hashBase64,
|
|
out long size)
|
|
{
|
|
fullPath = string.Empty;
|
|
relativePath = string.Empty;
|
|
filename = string.Empty;
|
|
hashBase64 = string.Empty;
|
|
size = 0;
|
|
|
|
var candidates = new List<(string Source, string Value)>();
|
|
if (!string.IsNullOrWhiteSpace(value.DataBlob))
|
|
candidates.Add(("storedDataBlob", value.DataBlob));
|
|
|
|
if (!string.IsNullOrWhiteSpace(value.RawPayloadJson))
|
|
{
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(value.RawPayloadJson);
|
|
CollectInventionBlobCandidates(document.RootElement, candidates);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
}
|
|
|
|
string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN"));
|
|
string inventionDirectory = Path.Combine(cdnRoot, "invention");
|
|
string roomDirectory = Path.Combine(cdnRoot, "room");
|
|
Directory.CreateDirectory(inventionDirectory);
|
|
|
|
(string FullPath, string Name, long Size, int Score)? best = null;
|
|
foreach ((string source, string rawValue) in candidates)
|
|
{
|
|
string normalized = NormalizeCandidateBlobValue(rawValue);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
continue;
|
|
|
|
string safeName = Path.GetFileName(normalized);
|
|
if (safeName.Length == 0 || safeName.Length > 180 ||
|
|
safeName.Any(character =>
|
|
!char.IsLetterOrDigit(character) &&
|
|
character is not '-' and not '_' and not '.'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var blobNames = new List<string> { safeName };
|
|
if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
string extensionless = safeName[..^4];
|
|
if (!string.IsNullOrWhiteSpace(extensionless))
|
|
blobNames.Add(extensionless);
|
|
}
|
|
else
|
|
{
|
|
string baseName = Path.GetFileNameWithoutExtension(safeName);
|
|
if (string.IsNullOrWhiteSpace(baseName))
|
|
baseName = safeName;
|
|
blobNames.Add(baseName + ".inv");
|
|
}
|
|
|
|
var paths = new List<string>();
|
|
if (normalized.Contains('/'))
|
|
paths.Add(Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
|
|
foreach (string blobName in blobNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
paths.Add(Path.Combine(inventionDirectory, blobName));
|
|
paths.Add(Path.Combine(roomDirectory, blobName));
|
|
paths.Add(Path.Combine(cdnRoot, blobName));
|
|
}
|
|
|
|
foreach (string candidatePath in paths.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
string resolved;
|
|
try
|
|
{
|
|
resolved = Path.GetFullPath(candidatePath);
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is ArgumentException or NotSupportedException or PathTooLongException)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar,
|
|
StringComparison.OrdinalIgnoreCase) ||
|
|
!File.Exists(resolved))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var info = new FileInfo(resolved);
|
|
if (info.Length <= 0 || LooksLikeImageFile(resolved))
|
|
continue;
|
|
|
|
int score = ScoreInventionBlobCandidate(source, resolved, info.Length);
|
|
if (best == null || score > best.Value.Score)
|
|
best = (resolved, info.Name, info.Length, score);
|
|
}
|
|
}
|
|
|
|
if (best == null)
|
|
return false;
|
|
|
|
string canonicalBaseName = Path.GetFileNameWithoutExtension(best.Value.Name);
|
|
if (string.IsNullOrWhiteSpace(canonicalBaseName))
|
|
canonicalBaseName = best.Value.Name;
|
|
string canonicalName = best.Value.Name.EndsWith(
|
|
".inv",
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? best.Value.Name
|
|
: canonicalBaseName + ".inv";
|
|
string canonicalPath = Path.Combine(inventionDirectory, canonicalName);
|
|
if (!string.Equals(best.Value.FullPath, canonicalPath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(canonicalPath))
|
|
File.Copy(best.Value.FullPath, canonicalPath, overwrite: false);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
}
|
|
}
|
|
|
|
fullPath = File.Exists(canonicalPath) ? canonicalPath : best.Value.FullPath;
|
|
var finalInfo = new FileInfo(fullPath);
|
|
if (!finalInfo.Exists || finalInfo.Length <= 0 || LooksLikeImageFile(fullPath))
|
|
return false;
|
|
|
|
filename = finalInfo.Name;
|
|
relativePath = string.Equals(
|
|
Path.GetDirectoryName(fullPath),
|
|
inventionDirectory,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? "invention/" + filename
|
|
: "room/" + filename;
|
|
size = finalInfo.Length;
|
|
if (!string.IsNullOrWhiteSpace(value.DataBlobHash) &&
|
|
value.DataBlobSize == size)
|
|
{
|
|
hashBase64 = value.DataBlobHash;
|
|
}
|
|
else
|
|
{
|
|
using FileStream stream = File.OpenRead(fullPath);
|
|
hashBase64 = Convert.ToBase64String(SHA256.HashData(stream));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static void RepairInventionBlobMetadataNoLock(InventionRecord value)
|
|
{
|
|
if (!TryGetInventionBlob(
|
|
value,
|
|
out _,
|
|
out string relativePath,
|
|
out _,
|
|
out string hashBase64,
|
|
out long size))
|
|
{
|
|
return;
|
|
}
|
|
|
|
value.DataBlob = relativePath;
|
|
value.DataBlobHash = hashBase64;
|
|
value.DataBlobSize = size;
|
|
}
|
|
|
|
private static void BackfillInventionSaveMetadata()
|
|
{
|
|
lock (Sync)
|
|
{
|
|
int repaired = 0;
|
|
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
|
{
|
|
if (string.IsNullOrWhiteSpace(record.RawPayloadJson))
|
|
continue;
|
|
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(record.RawPayloadJson);
|
|
JsonElement payload = document.RootElement;
|
|
bool changed = false;
|
|
|
|
long roomId = FindLong(payload,
|
|
"CreationRoomId", "creationRoomId",
|
|
"RoomId", "roomId", "SourceRoomId", "sourceRoomId") ?? 0;
|
|
if (record.RoomId <= 0 && roomId > 0)
|
|
{
|
|
record.RoomId = roomId;
|
|
changed = true;
|
|
}
|
|
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.InstantiationCost,
|
|
value => record.InstantiationCost = value,
|
|
"InstantiationCost", "instantiationCost", "InstantiateCost", "instantiateCost");
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.LightsCost,
|
|
value => record.LightsCost = value,
|
|
"LightsCost", "lightsCost");
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.ChipsCost,
|
|
value => record.ChipsCost = value,
|
|
"ChipsCost", "chipsCost");
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.CloudVariablesCost,
|
|
value => record.CloudVariablesCost = value,
|
|
"CloudVariablesCost", "cloudVariablesCost", "CloudVariableCost", "cloudVariableCost");
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.AiCost,
|
|
value => record.AiCost = value,
|
|
"AiCost", "aiCost", "AICost");
|
|
changed |= TryBackfillNonNegativeInt(
|
|
payload,
|
|
record.CreatorAccountRole,
|
|
value => record.CreatorAccountRole = value,
|
|
"CreatorAccountRole", "creatorAccountRole");
|
|
|
|
List<long> references = ReadLongList(payload,
|
|
"ReferencedInventions", "referencedInventions",
|
|
"ReferencedInventionIds", "referencedInventionIds");
|
|
if ((record.ReferencedInventionIds == null || record.ReferencedInventionIds.Count == 0) &&
|
|
references.Count > 0)
|
|
{
|
|
record.ReferencedInventionIds = references.Take(512).ToList();
|
|
changed = true;
|
|
}
|
|
|
|
if (!changed)
|
|
continue;
|
|
|
|
Inventions.Update(record);
|
|
repaired++;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
}
|
|
|
|
if (repaired > 0)
|
|
Console.WriteLine($"[INVENTION METADATA REPAIR] repaired={repaired}");
|
|
}
|
|
}
|
|
|
|
private static bool TryBackfillNonNegativeInt(
|
|
JsonElement payload,
|
|
int currentValue,
|
|
Action<int> setter,
|
|
params string[] names)
|
|
{
|
|
long? parsed = FindLong(payload, names);
|
|
if (!parsed.HasValue)
|
|
return false;
|
|
|
|
int value = (int)Math.Clamp(parsed.Value, 0, int.MaxValue);
|
|
if (currentValue == value)
|
|
return false;
|
|
|
|
setter(value);
|
|
return true;
|
|
}
|
|
|
|
private static int ReadNonNegativeInt(
|
|
JsonElement payload,
|
|
int fallback,
|
|
params string[] names)
|
|
{
|
|
long? parsed = FindLong(payload, names);
|
|
return parsed.HasValue
|
|
? (int)Math.Clamp(parsed.Value, 0, int.MaxValue)
|
|
: Math.Max(0, fallback);
|
|
}
|
|
|
|
private static void RepairStoredInventionBlobs()
|
|
{
|
|
lock (Sync)
|
|
{
|
|
int repaired = 0;
|
|
foreach (InventionRecord record in Inventions.FindAll().ToList())
|
|
{
|
|
string oldBlob = record.DataBlob;
|
|
string oldHash = record.DataBlobHash;
|
|
long oldSize = record.DataBlobSize;
|
|
RepairInventionBlobMetadataNoLock(record);
|
|
if (!string.Equals(oldBlob, record.DataBlob, StringComparison.Ordinal) ||
|
|
!string.Equals(oldHash, record.DataBlobHash, StringComparison.Ordinal) ||
|
|
oldSize != record.DataBlobSize)
|
|
{
|
|
Inventions.Update(record);
|
|
repaired++;
|
|
}
|
|
}
|
|
|
|
if (repaired > 0)
|
|
Console.WriteLine($"[INVENTION BLOB REPAIR] repaired={repaired}");
|
|
}
|
|
}
|
|
|
|
private static void CollectInventionBlobCandidates(
|
|
JsonElement element,
|
|
ICollection<(string Source, string Value)> output)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
string lowerName = property.Name.ToLowerInvariant();
|
|
bool looksLikeBlobField =
|
|
lowerName.Contains("blob", StringComparison.Ordinal) ||
|
|
lowerName.Contains("filename", StringComparison.Ordinal) ||
|
|
lowerName is "file" or "datafile" or "objectdata";
|
|
bool looksLikePreview =
|
|
lowerName.Contains("image", StringComparison.Ordinal) ||
|
|
lowerName.Contains("preview", StringComparison.Ordinal) ||
|
|
lowerName.Contains("thumbnail", StringComparison.Ordinal) ||
|
|
lowerName.Contains("icon", StringComparison.Ordinal);
|
|
|
|
if (looksLikeBlobField && !looksLikePreview)
|
|
{
|
|
if (property.Value.ValueKind == JsonValueKind.String)
|
|
{
|
|
string? text = property.Value.GetString();
|
|
if (!string.IsNullOrWhiteSpace(text))
|
|
output.Add((property.Name, text));
|
|
}
|
|
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
|
{
|
|
CollectInventionBlobCandidates(property.Value, output);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
CollectInventionBlobCandidates(property.Value, output);
|
|
}
|
|
|
|
if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
|
CollectInventionBlobCandidates(embedded, output);
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
CollectInventionBlobCandidates(item, output);
|
|
}
|
|
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
|
{
|
|
CollectInventionBlobCandidates(embedded, output);
|
|
}
|
|
}
|
|
|
|
private static string NormalizeCandidateBlobValue(string? value)
|
|
{
|
|
string normalized = (value ?? string.Empty).Replace('\\', '/').Trim();
|
|
if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase))
|
|
normalized = normalized[5..];
|
|
normalized = normalized.TrimStart('/');
|
|
if (normalized.Contains("..", StringComparison.Ordinal))
|
|
return string.Empty;
|
|
|
|
if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase))
|
|
normalized = normalized[5..];
|
|
if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase))
|
|
normalized = normalized[10..];
|
|
return normalized;
|
|
}
|
|
|
|
private static int ScoreInventionBlobCandidate(
|
|
string source,
|
|
string fullPath,
|
|
long length)
|
|
{
|
|
string lower = source.ToLowerInvariant();
|
|
int score = 0;
|
|
if (lower.Contains("object", StringComparison.Ordinal)) score += 500;
|
|
if (lower.Contains("data", StringComparison.Ordinal)) score += 300;
|
|
if (lower.Contains("blob", StringComparison.Ordinal)) score += 200;
|
|
if (lower.Contains("filename", StringComparison.Ordinal)) score += 100;
|
|
if (fullPath.Contains(
|
|
Path.DirectorySeparatorChar + "invention" + Path.DirectorySeparatorChar,
|
|
StringComparison.OrdinalIgnoreCase)) score += 50;
|
|
score += (int)Math.Min(length / 1024, 100);
|
|
return score;
|
|
}
|
|
|
|
private static bool LooksLikeImageFile(string path)
|
|
{
|
|
try
|
|
{
|
|
Span<byte> header = stackalloc byte[12];
|
|
using FileStream stream = File.OpenRead(path);
|
|
int read = stream.Read(header);
|
|
if (read >= 8 &&
|
|
header[0] == 0x89 && header[1] == 0x50 &&
|
|
header[2] == 0x4E && header[3] == 0x47)
|
|
return true;
|
|
if (read >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
|
|
return true;
|
|
if (read >= 6 &&
|
|
header[0] == (byte)'G' && header[1] == (byte)'I' &&
|
|
header[2] == (byte)'F')
|
|
return true;
|
|
if (read >= 12 &&
|
|
header[0] == (byte)'R' && header[1] == (byte)'I' &&
|
|
header[2] == (byte)'F' && header[3] == (byte)'F' &&
|
|
header[8] == (byte)'W' && header[9] == (byte)'E' &&
|
|
header[10] == (byte)'B' && header[11] == (byte)'P')
|
|
return true;
|
|
return read >= 2 && header[0] == (byte)'B' && header[1] == (byte)'M';
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return false;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static MeetupCodeRecord CreateMeetupCode(
|
|
long creatorAccountId,
|
|
long roomId,
|
|
long subRoomId,
|
|
long roomInstanceId,
|
|
int maxUses = 100,
|
|
int lifetimeMinutes = 360)
|
|
{
|
|
if (creatorAccountId <= 0 || roomId <= 0 || roomInstanceId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(roomInstanceId));
|
|
|
|
lock (Sync)
|
|
{
|
|
DeleteExpiredMeetupCodesNoLock();
|
|
|
|
MeetupCodeRecord? existing = MeetupCodes.Find(value =>
|
|
value.CreatorAccountId == creatorAccountId &&
|
|
value.RoomInstanceId == roomInstanceId &&
|
|
value.IsActive)
|
|
.OrderByDescending(value => value.CreatedAtUtc)
|
|
.FirstOrDefault(value => value.ExpiresAtUtc > DateTime.UtcNow);
|
|
if (existing != null)
|
|
return existing;
|
|
|
|
string code;
|
|
do
|
|
{
|
|
code = GenerateMeetupCode();
|
|
}
|
|
while (MeetupCodes.Exists(value => value.Code == code));
|
|
|
|
DateTime now = DateTime.UtcNow;
|
|
var record = new MeetupCodeRecord
|
|
{
|
|
Code = code,
|
|
CreatorAccountId = creatorAccountId,
|
|
RoomId = roomId,
|
|
SubRoomId = subRoomId,
|
|
RoomInstanceId = roomInstanceId,
|
|
CreatedAtUtc = now,
|
|
ExpiresAtUtc = now.AddMinutes(Math.Clamp(lifetimeMinutes, 5, 1_440)),
|
|
MaxUses = Math.Clamp(maxUses, 1, 1_000),
|
|
IsActive = true
|
|
};
|
|
MeetupCodes.Insert(record);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static MeetupCodeRecord? GetMeetupCode(string? code)
|
|
{
|
|
string normalized = NormalizeMeetupCode(code);
|
|
if (normalized.Length == 0)
|
|
return null;
|
|
|
|
lock (Sync)
|
|
{
|
|
DeleteExpiredMeetupCodesNoLock();
|
|
MeetupCodeRecord? record = MeetupCodes.FindById(normalized);
|
|
return record != null && record.IsActive &&
|
|
record.ExpiresAtUtc > DateTime.UtcNow &&
|
|
record.UseCount < record.MaxUses
|
|
? record
|
|
: null;
|
|
}
|
|
}
|
|
|
|
public static void RecordMeetupCodeUse(string code)
|
|
{
|
|
lock (Sync)
|
|
{
|
|
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
|
if (record == null)
|
|
return;
|
|
record.UseCount++;
|
|
if (record.UseCount >= record.MaxUses)
|
|
record.IsActive = false;
|
|
MeetupCodes.Update(record);
|
|
}
|
|
}
|
|
|
|
public static bool RevokeMeetupCode(string code, long callerAccountId)
|
|
{
|
|
lock (Sync)
|
|
{
|
|
MeetupCodeRecord? record = MeetupCodes.FindById(NormalizeMeetupCode(code));
|
|
if (record == null || record.CreatorAccountId != callerAccountId)
|
|
return false;
|
|
record.IsActive = false;
|
|
return MeetupCodes.Update(record);
|
|
}
|
|
}
|
|
|
|
public static object ToClientMeetupCode(MeetupCodeRecord value) => new
|
|
{
|
|
Code = value.Code,
|
|
MeetupCode = value.Code,
|
|
CreatorAccountId = value.CreatorAccountId,
|
|
RoomId = value.RoomId,
|
|
SubRoomId = value.SubRoomId,
|
|
RoomInstanceId = value.RoomInstanceId,
|
|
CreatedAt = value.CreatedAtUtc,
|
|
ExpiresAt = value.ExpiresAtUtc,
|
|
UseCount = value.UseCount,
|
|
MaxUses = value.MaxUses,
|
|
IsActive = value.IsActive
|
|
};
|
|
|
|
public static CloudVariableRecord SetCloudVariable(
|
|
long roomId,
|
|
long accountId,
|
|
string key,
|
|
string valueJson,
|
|
long updatedByAccountId)
|
|
{
|
|
if (roomId <= 0 || accountId < 0 || updatedByAccountId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(roomId));
|
|
|
|
string normalizedKey = NormalizeCloudKey(key);
|
|
if (normalizedKey.Length == 0)
|
|
throw new ArgumentException("A cloud variable key is required.", nameof(key));
|
|
if (Encoding.UTF8.GetByteCount(valueJson) > 16 * 1024)
|
|
throw new ArgumentException("Cloud variable values are limited to 16 KiB.", nameof(valueJson));
|
|
|
|
lock (Sync)
|
|
{
|
|
string id = BuildCloudVariableId(roomId, accountId, normalizedKey);
|
|
CloudVariableRecord? existing = CloudVariables.FindById(id);
|
|
|
|
if (existing == null)
|
|
{
|
|
int count = CloudVariables.Count(value =>
|
|
value.RoomId == roomId && value.AccountId == accountId);
|
|
if (count >= 512)
|
|
throw new InvalidOperationException(
|
|
"This room/player scope already has 512 cloud variables.");
|
|
}
|
|
|
|
var record = existing ?? new CloudVariableRecord
|
|
{
|
|
Id = id,
|
|
RoomId = roomId,
|
|
AccountId = accountId,
|
|
Key = normalizedKey,
|
|
Version = 0
|
|
};
|
|
|
|
record.ValueJson = string.IsNullOrWhiteSpace(valueJson) ? "null" : valueJson;
|
|
record.Version = Math.Max(1, record.Version + 1);
|
|
record.UpdatedAtUtc = DateTime.UtcNow;
|
|
record.UpdatedByAccountId = updatedByAccountId;
|
|
CloudVariables.Upsert(record);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static CloudVariableRecord? GetCloudVariable(
|
|
long roomId,
|
|
long accountId,
|
|
string key)
|
|
{
|
|
if (roomId <= 0 || accountId < 0)
|
|
return null;
|
|
return CloudVariables.FindById(
|
|
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
|
}
|
|
|
|
public static List<CloudVariableRecord> GetCloudVariables(
|
|
long roomId,
|
|
long accountId,
|
|
bool includeShared = true)
|
|
{
|
|
if (roomId <= 0 || accountId < 0)
|
|
return new List<CloudVariableRecord>();
|
|
|
|
return CloudVariables.Find(value =>
|
|
value.RoomId == roomId &&
|
|
(value.AccountId == accountId ||
|
|
(includeShared && value.AccountId == 0)))
|
|
.OrderBy(value => value.Key)
|
|
.ThenBy(value => value.AccountId)
|
|
.ToList();
|
|
}
|
|
|
|
public static bool DeleteCloudVariable(
|
|
long roomId,
|
|
long accountId,
|
|
string key) =>
|
|
CloudVariables.Delete(
|
|
BuildCloudVariableId(roomId, accountId, NormalizeCloudKey(key)));
|
|
|
|
public static RoomPlayerDataRecord SaveRoomPlayerData(
|
|
long roomId,
|
|
long accountId,
|
|
string dataJson,
|
|
long updatedByAccountId)
|
|
{
|
|
if (roomId <= 0 || accountId <= 0 || updatedByAccountId <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(roomId));
|
|
|
|
string normalizedJson = string.IsNullOrWhiteSpace(dataJson)
|
|
? "{}"
|
|
: dataJson;
|
|
if (Encoding.UTF8.GetByteCount(normalizedJson) > 256 * 1024)
|
|
{
|
|
throw new ArgumentException(
|
|
"Room player data is limited to 256 KiB.",
|
|
nameof(dataJson));
|
|
}
|
|
|
|
using (JsonDocument.Parse(normalizedJson))
|
|
{
|
|
}
|
|
|
|
lock (Sync)
|
|
{
|
|
string id = BuildRoomPlayerDataId(roomId, accountId);
|
|
RoomPlayerDataRecord? existing = RoomPlayerData.FindById(id);
|
|
var record = existing ?? new RoomPlayerDataRecord
|
|
{
|
|
Id = id,
|
|
RoomId = roomId,
|
|
AccountId = accountId,
|
|
Version = 0
|
|
};
|
|
|
|
record.DataJson = normalizedJson;
|
|
record.Version = Math.Max(1, record.Version + 1);
|
|
record.UpdatedAtUtc = DateTime.UtcNow;
|
|
record.UpdatedByAccountId = updatedByAccountId;
|
|
RoomPlayerData.Upsert(record);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public static RoomPlayerDataRecord? GetRoomPlayerData(
|
|
long roomId,
|
|
long accountId)
|
|
{
|
|
if (roomId <= 0 || accountId <= 0)
|
|
return null;
|
|
return RoomPlayerData.FindById(BuildRoomPlayerDataId(roomId, accountId));
|
|
}
|
|
|
|
public static bool DeleteRoomPlayerData(long roomId, long accountId)
|
|
{
|
|
if (roomId <= 0 || accountId <= 0)
|
|
return false;
|
|
return RoomPlayerData.Delete(BuildRoomPlayerDataId(roomId, accountId));
|
|
}
|
|
|
|
public static object ToClientCloudVariable(CloudVariableRecord value)
|
|
{
|
|
object? parsedValue;
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(value.ValueJson);
|
|
parsedValue = document.RootElement.Clone();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
parsedValue = value.ValueJson;
|
|
}
|
|
|
|
return new
|
|
{
|
|
Key = value.Key,
|
|
Name = value.Key,
|
|
VariableName = value.Key,
|
|
CloudVariableName = value.Key,
|
|
Value = parsedValue,
|
|
VariableValue = parsedValue,
|
|
CloudVariableValue = parsedValue,
|
|
ValueJson = value.ValueJson,
|
|
RoomId = value.RoomId,
|
|
AccountId = value.AccountId,
|
|
PlayerId = value.AccountId,
|
|
IsShared = value.AccountId == 0,
|
|
IsPlayerVariable = value.AccountId != 0,
|
|
Scope = value.AccountId == 0 ? "room" : "player",
|
|
Version = value.Version,
|
|
UpdatedAt = value.UpdatedAtUtc,
|
|
UpdatedByAccountId = value.UpdatedByAccountId
|
|
};
|
|
}
|
|
|
|
private static void MigrateLegacyInventionFiles()
|
|
{
|
|
string root = Path.Combine(Program.dataDir, "Inventions");
|
|
if (!Directory.Exists(root))
|
|
return;
|
|
|
|
int imported = 0;
|
|
foreach (string accountDirectory in Directory.EnumerateDirectories(root))
|
|
{
|
|
if (!long.TryParse(Path.GetFileName(accountDirectory), out long accountId) ||
|
|
accountId <= 0)
|
|
continue;
|
|
|
|
foreach (string file in Directory.EnumerateFiles(
|
|
accountDirectory,
|
|
"*.json",
|
|
SearchOption.TopDirectoryOnly))
|
|
{
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(
|
|
File.ReadAllText(file));
|
|
long fileId = long.TryParse(
|
|
Path.GetFileNameWithoutExtension(file),
|
|
out long parsedId)
|
|
? parsedId
|
|
: 0;
|
|
if (fileId > 0 && Inventions.FindById(fileId) != null)
|
|
continue;
|
|
|
|
JsonElement payload = document.RootElement.Clone();
|
|
SaveInvention(
|
|
accountId,
|
|
payload,
|
|
fileId > 0 ? fileId : null);
|
|
imported++;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.WriteLine(
|
|
$"[INVENTION MIGRATION] skipped={file} error={exception.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (imported > 0)
|
|
Console.WriteLine($"[INVENTION MIGRATION] imported={imported}");
|
|
}
|
|
|
|
private static InventionRecord? FindInventionNoLock(long inventionId)
|
|
{
|
|
InventionRecord? direct = Inventions.FindById(inventionId);
|
|
if (direct != null)
|
|
return direct;
|
|
|
|
InventionAliasRecord? alias = InventionAliases.FindById(inventionId);
|
|
return alias == null
|
|
? null
|
|
: Inventions.FindById(alias.CurrentInventionId);
|
|
}
|
|
|
|
private static string InventionPurchaseId(long inventionId, long accountId) =>
|
|
$"{accountId}:{inventionId}";
|
|
|
|
private static long NextInventionIdNoLock()
|
|
{
|
|
long max = Inventions.FindAll()
|
|
.Where(value => value.InventionId > 0 &&
|
|
value.InventionId <= MaxClientInventionId)
|
|
.Select(value => value.InventionId)
|
|
.DefaultIfEmpty(0)
|
|
.Max();
|
|
|
|
long candidate = Math.Max(1, max + 1);
|
|
while (candidate <= MaxClientInventionId &&
|
|
Inventions.FindById(candidate) != null)
|
|
{
|
|
candidate++;
|
|
}
|
|
|
|
if (candidate > MaxClientInventionId)
|
|
throw new InvalidOperationException("No client-compatible invention IDs remain.");
|
|
return candidate;
|
|
}
|
|
|
|
private static void RepairOversizedInventionIds()
|
|
{
|
|
lock (Sync)
|
|
{
|
|
List<InventionRecord> oversized = Inventions.FindAll()
|
|
.Where(value => value.InventionId > MaxClientInventionId)
|
|
.OrderBy(value => value.CreatedAtUtc)
|
|
.ThenBy(value => value.InventionId)
|
|
.ToList();
|
|
if (oversized.Count == 0)
|
|
return;
|
|
|
|
int repaired = 0;
|
|
foreach (InventionRecord record in oversized)
|
|
{
|
|
long oldId = record.InventionId;
|
|
long newId = NextInventionIdNoLock();
|
|
|
|
record.InventionId = newId;
|
|
Inventions.Insert(record);
|
|
Inventions.Delete(oldId);
|
|
|
|
foreach (InventionCheerRecord cheer in InventionCheers
|
|
.Find(value => value.InventionId == oldId)
|
|
.ToList())
|
|
{
|
|
string oldCheerId = cheer.Id;
|
|
cheer.InventionId = newId;
|
|
cheer.Id = InventionCheerId(newId, cheer.AccountId);
|
|
InventionCheers.Upsert(cheer);
|
|
if (!string.Equals(oldCheerId, cheer.Id, StringComparison.Ordinal))
|
|
InventionCheers.Delete(oldCheerId);
|
|
}
|
|
|
|
foreach (InventionPurchaseRecord purchase in InventionPurchases
|
|
.Find(value => value.InventionId == oldId)
|
|
.ToList())
|
|
{
|
|
string oldPurchaseId = purchase.Id;
|
|
purchase.InventionId = newId;
|
|
purchase.Id = InventionPurchaseId(newId, purchase.AccountId);
|
|
InventionPurchases.Upsert(purchase);
|
|
if (!string.Equals(oldPurchaseId, purchase.Id, StringComparison.Ordinal))
|
|
InventionPurchases.Delete(oldPurchaseId);
|
|
}
|
|
|
|
foreach (InventionAliasRecord existingAlias in InventionAliases
|
|
.Find(value => value.CurrentInventionId == oldId)
|
|
.ToList())
|
|
{
|
|
existingAlias.CurrentInventionId = newId;
|
|
InventionAliases.Update(existingAlias);
|
|
}
|
|
|
|
InventionAliases.Upsert(new InventionAliasRecord
|
|
{
|
|
LegacyInventionId = oldId,
|
|
CurrentInventionId = newId,
|
|
CreatedAtUtc = DateTime.UtcNow
|
|
});
|
|
repaired++;
|
|
Console.WriteLine(
|
|
$"[INVENTION ID REPAIR] old={oldId} new={newId} name={record.Name}");
|
|
}
|
|
|
|
Console.WriteLine($"[INVENTION ID REPAIR] repaired={repaired}");
|
|
}
|
|
}
|
|
|
|
private static string GenerateMeetupCode()
|
|
{
|
|
const string alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
byte[] bytes = RandomNumberGenerator.GetBytes(8);
|
|
var chars = new char[8];
|
|
for (int i = 0; i < chars.Length; i++)
|
|
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
|
return new string(chars);
|
|
}
|
|
|
|
private static string NormalizeMeetupCode(string? code) =>
|
|
new((code ?? string.Empty)
|
|
.Where(char.IsLetterOrDigit)
|
|
.Select(char.ToLowerInvariant)
|
|
.Take(16)
|
|
.ToArray());
|
|
|
|
private static void DeleteExpiredMeetupCodesNoLock()
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
MeetupCodes.DeleteMany(value =>
|
|
!value.IsActive ||
|
|
value.ExpiresAtUtc <= now ||
|
|
value.UseCount >= value.MaxUses);
|
|
}
|
|
|
|
private static string NormalizeCloudKey(string? key)
|
|
{
|
|
string value = (key ?? string.Empty).Trim();
|
|
if (value.Length > 128)
|
|
value = value[..128];
|
|
return value.Replace('\0', '_');
|
|
}
|
|
|
|
public static void MigrateRoomId(long oldRoomId, long newRoomId)
|
|
{
|
|
lock (Sync)
|
|
{
|
|
foreach (InventionRecord invention in Inventions.Find(value => value.RoomId == oldRoomId).ToList())
|
|
{
|
|
invention.RoomId = newRoomId;
|
|
Inventions.Update(invention);
|
|
}
|
|
|
|
foreach (CloudVariableRecord variable in CloudVariables.Find(value => value.RoomId == oldRoomId).ToList())
|
|
{
|
|
CloudVariables.Delete(variable.Id);
|
|
variable.Id = BuildCloudVariableId(newRoomId, variable.AccountId, variable.Key);
|
|
variable.RoomId = newRoomId;
|
|
CloudVariables.Upsert(variable);
|
|
}
|
|
|
|
foreach (RoomPlayerDataRecord data in RoomPlayerData.Find(value => value.RoomId == oldRoomId).ToList())
|
|
{
|
|
RoomPlayerData.Delete(data.Id);
|
|
data.Id = BuildRoomPlayerDataId(newRoomId, data.AccountId);
|
|
data.RoomId = newRoomId;
|
|
RoomPlayerData.Upsert(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string BuildCloudVariableId(
|
|
long roomId,
|
|
long accountId,
|
|
string key)
|
|
{
|
|
byte[] digest = SHA256.HashData(
|
|
Encoding.UTF8.GetBytes(key.ToLowerInvariant()));
|
|
return $"{roomId}:{accountId}:{Convert.ToHexString(digest)}";
|
|
}
|
|
|
|
private static string BuildRoomPlayerDataId(long roomId, long accountId) =>
|
|
$"{roomId}:{accountId}";
|
|
|
|
private static string Limit(string? value, int maxLength)
|
|
{
|
|
string result = value?.Trim() ?? string.Empty;
|
|
return result.Length <= maxLength ? result : result[..maxLength];
|
|
}
|
|
|
|
private static string StableReplicationId(
|
|
string scope,
|
|
long inventionId,
|
|
int version)
|
|
{
|
|
byte[] source = Encoding.UTF8.GetBytes(
|
|
$"{scope}:{inventionId}:{version}");
|
|
byte[] hash = SHA256.HashData(source);
|
|
var guidBytes = new byte[16];
|
|
Array.Copy(hash, guidBytes, guidBytes.Length);
|
|
return new Guid(guidBytes).ToString();
|
|
}
|
|
|
|
private static long? FindLong(JsonElement element, params string[] names)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
if (names.Any(name => string.Equals(
|
|
name,
|
|
property.Name,
|
|
StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
if (property.Value.ValueKind == JsonValueKind.Number &&
|
|
property.Value.TryGetInt64(out long number))
|
|
return number;
|
|
if (long.TryParse(property.Value.ToString(), out number))
|
|
return number;
|
|
}
|
|
|
|
long? nested = FindLong(property.Value, names);
|
|
if (nested.HasValue)
|
|
return nested;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
{
|
|
long? nested = FindLong(item, names);
|
|
if (nested.HasValue)
|
|
return nested;
|
|
}
|
|
}
|
|
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
|
{
|
|
return FindLong(embedded, names);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static string? FindString(JsonElement element, params string[] names)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
if (names.Any(name => string.Equals(
|
|
name,
|
|
property.Name,
|
|
StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
if (property.Value.ValueKind == JsonValueKind.String)
|
|
return property.Value.GetString();
|
|
if (property.Value.ValueKind is JsonValueKind.Number or
|
|
JsonValueKind.True or JsonValueKind.False)
|
|
return property.Value.ToString();
|
|
}
|
|
|
|
string? nested = FindString(property.Value, names);
|
|
if (nested != null)
|
|
return nested;
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
{
|
|
string? nested = FindString(item, names);
|
|
if (nested != null)
|
|
return nested;
|
|
}
|
|
}
|
|
else if (TryParseEmbeddedJson(element, out JsonElement embedded))
|
|
{
|
|
return FindString(embedded, names);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool? FindBool(JsonElement element, params string[] names)
|
|
{
|
|
string? raw = FindString(element, names);
|
|
if (bool.TryParse(raw, out bool boolean))
|
|
return boolean;
|
|
if (int.TryParse(raw, out int number))
|
|
return number != 0;
|
|
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
if (!names.Any(name => string.Equals(
|
|
name,
|
|
property.Name,
|
|
StringComparison.OrdinalIgnoreCase)))
|
|
continue;
|
|
if (property.Value.ValueKind == JsonValueKind.True)
|
|
return true;
|
|
if (property.Value.ValueKind == JsonValueKind.False)
|
|
return false;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool TryParseEmbeddedJson(
|
|
JsonElement element,
|
|
out JsonElement parsed)
|
|
{
|
|
parsed = default;
|
|
if (element.ValueKind != JsonValueKind.String)
|
|
return false;
|
|
|
|
string? raw = element.GetString()?.Trim();
|
|
if (string.IsNullOrWhiteSpace(raw) ||
|
|
(raw[0] != '{' && raw[0] != '['))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(raw);
|
|
parsed = document.RootElement.Clone();
|
|
return true;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static List<long> ReadLongList(
|
|
JsonElement element,
|
|
params string[] names)
|
|
{
|
|
var output = new List<long>();
|
|
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
|
return ReadLongList(embeddedRoot, names);
|
|
|
|
if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
{
|
|
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long number))
|
|
{
|
|
if (number > 0)
|
|
output.Add(number);
|
|
}
|
|
else if (long.TryParse(item.ToString(), out number) && number > 0)
|
|
{
|
|
output.Add(number);
|
|
}
|
|
else if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
|
{
|
|
output.AddRange(ReadLongList(item, names));
|
|
}
|
|
}
|
|
return output.Distinct().ToList();
|
|
}
|
|
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
return output;
|
|
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
bool requested = names.Any(name => string.Equals(
|
|
name,
|
|
property.Name,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (requested)
|
|
{
|
|
JsonElement value = property.Value;
|
|
if (TryParseEmbeddedJson(value, out JsonElement embedded))
|
|
value = embedded;
|
|
|
|
if (value.ValueKind == JsonValueKind.Array)
|
|
{
|
|
output.AddRange(ReadLongList(value, names));
|
|
}
|
|
else if (value.ValueKind == JsonValueKind.Number &&
|
|
value.TryGetInt64(out long number) && number > 0)
|
|
{
|
|
output.Add(number);
|
|
}
|
|
else
|
|
{
|
|
foreach (string part in value.ToString().Split(
|
|
new[] { ',', ';' },
|
|
StringSplitOptions.RemoveEmptyEntries |
|
|
StringSplitOptions.TrimEntries))
|
|
{
|
|
if (long.TryParse(part, out number) && number > 0)
|
|
output.Add(number);
|
|
}
|
|
}
|
|
}
|
|
else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
|
{
|
|
output.AddRange(ReadLongList(property.Value, names));
|
|
}
|
|
else if (TryParseEmbeddedJson(property.Value, out JsonElement embedded))
|
|
{
|
|
output.AddRange(ReadLongList(embedded, names));
|
|
}
|
|
}
|
|
|
|
return output.Distinct().ToList();
|
|
}
|
|
|
|
private static List<string> ReadStringList(
|
|
JsonElement element,
|
|
params string[] names)
|
|
{
|
|
var output = new List<string>();
|
|
if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
output.AddRange(ReadStringList(item, names));
|
|
return output
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
if (TryParseEmbeddedJson(element, out JsonElement embeddedRoot))
|
|
return ReadStringList(embeddedRoot, names);
|
|
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
return output;
|
|
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
bool isRequestedProperty = names.Any(name => string.Equals(
|
|
name,
|
|
property.Name,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (!isRequestedProperty)
|
|
{
|
|
if (property.Value.ValueKind is JsonValueKind.Object or
|
|
JsonValueKind.Array)
|
|
{
|
|
output.AddRange(ReadStringList(property.Value, names));
|
|
}
|
|
else if (TryParseEmbeddedJson(
|
|
property.Value,
|
|
out JsonElement embedded))
|
|
{
|
|
output.AddRange(ReadStringList(embedded, names));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
JsonElement tagValue = property.Value;
|
|
if (TryParseEmbeddedJson(tagValue, out JsonElement parsedTags))
|
|
tagValue = parsedTags;
|
|
|
|
if (tagValue.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in tagValue.EnumerateArray())
|
|
{
|
|
string? tag = item.ValueKind == JsonValueKind.String
|
|
? item.GetString()
|
|
: item.ValueKind == JsonValueKind.Object
|
|
? FindString(item, "Tag", "tag", "Name", "name")
|
|
: item.ToString();
|
|
if (!string.IsNullOrWhiteSpace(tag))
|
|
output.Add(Limit(tag, 32));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (string item in tagValue.ToString().Split(
|
|
new[] { ',', ';' },
|
|
StringSplitOptions.RemoveEmptyEntries |
|
|
StringSplitOptions.TrimEntries))
|
|
{
|
|
output.Add(Limit(item, 32));
|
|
}
|
|
}
|
|
}
|
|
|
|
return output
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
}
|
|
}
|