mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
feat: Add new API controllers for progression events, room consumables, currencies, earnings distributions, screen share reports, subscription seasons, and UGC purchasables
- Implemented ProgressionEventsController to handle progression event retrieval and active events. - Created RoomConsumablesController for listing and purchasing room consumables. - Developed RoomCurrenciesController for managing room currencies, including creation, updates, and balance retrieval. - Added RoomEarningsDistributionsController to fetch earnings distribution data for rooms. - Introduced ScreenShareReportsController for reporting screen share incidents. - Established SubscriptionSeasonsController to provide current subscription season details and rewards. - Launched UgcPurchasablesController for managing user-generated content purchasables, including bulk operations. - Added new data entities for customization and room economy, including custom avatar items, room currencies, and UGC purchasables.
This commit is contained in:
@@ -106,9 +106,11 @@ public class AccountApiController(
|
||||
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ageverification")]
|
||||
[Authorize]
|
||||
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("generateCode")]
|
||||
[HttpGet("generateCode")]
|
||||
public async Task<IActionResult> GenerateCode()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Value = code,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||
|
||||
[ApiController]
|
||||
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> IosProducts()
|
||||
{
|
||||
var rows = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(100)
|
||||
.Select(i => new
|
||||
{
|
||||
ProductId = i.Slug,
|
||||
i.Price,
|
||||
i.CurrencyType,
|
||||
Name = i.DisplayName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||
{
|
||||
db.BugReports.Add(new BugReportEntity
|
||||
{
|
||||
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||
Title = "iOS payment queue failed",
|
||||
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||
Category = "appintegrity",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Apple;
|
||||
|
||||
[ApiController]
|
||||
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/apple/musicpromotion/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddYears(1);
|
||||
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||
}
|
||||
|
||||
[HttpPost("api/apple/musicpromotion/code")]
|
||||
[HttpGet("api/apple/musicpromotion/code")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Code()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||
if (row is null)
|
||||
{
|
||||
row = new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "applemusic:promotion_code",
|
||||
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||
};
|
||||
db.PlayerSettings.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(new { Code = row.Value, Redeemed = false });
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,22 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult DefaultBaseAvatarItems()
|
||||
{
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultunlocked")]
|
||||
[AllowAnonymous]
|
||||
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
||||
|
||||
/// <summary>The set of GUIDs the bundled
|
||||
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
||||
/// contains. Exposed so other controllers (StoreService seed,
|
||||
|
||||
@@ -73,6 +73,11 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
|
||||
=> Content("true", "application/json");
|
||||
|
||||
[HttpPost("api/clubreporting/v1/report")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
|
||||
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.Config.V2;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/config")]
|
||||
[Route("api/[controller]/v2")]
|
||||
public class ConfigController(ConfigService configService) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/customAvatarItems")]
|
||||
[Route("api/customAvatarItems/v1")]
|
||||
public class CustomAvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(i =>
|
||||
i.Name.ToLower().Contains(needle) ||
|
||||
i.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
||||
if (item is null) return NotFound();
|
||||
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("bulk")]
|
||||
[HttpPost("bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
|
||||
{
|
||||
var ids = await ReadIdsAsync(body);
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("featured")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Featured([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic && i.IsFeatured)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("hot")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Hot([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CheerCount)
|
||||
.ThenByDescending(i => i.PurchaseCount)
|
||||
.ThenByDescending(i => i.UpdatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("new")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> New([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.CustomAvatarItems
|
||||
.Where(i => i.IsPublic)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var created = await db.CustomAvatarItems
|
||||
.Where(i => i.CreatorPlayerId == pid)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
var ownedIds = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Select(o => o.CustomAvatarItemId)
|
||||
.ToListAsync();
|
||||
var owned = ownedIds.Count == 0
|
||||
? new List<CustomAvatarItemEntity>()
|
||||
: await db.CustomAvatarItems
|
||||
.Where(i => ownedIds.Contains(i.Id))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Created = created.Select(ToWire),
|
||||
Owned = owned.Select(ToWire),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("owned")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Owned()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("isCreationAllowedForAccount")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> IsCreationAllowedForAccount()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var player = await db.Players
|
||||
.Where(p => p.Id == pid)
|
||||
.Select(p => new { p.IsJunior, p.BannedUntil })
|
||||
.FirstOrDefaultAsync();
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true });
|
||||
|
||||
[HttpGet("isRenderingEnabled")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true });
|
||||
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
|
||||
|
||||
[HttpPost("design")]
|
||||
[Authorize]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Design()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var req = await ReadDesignRequestAsync();
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
||||
if (name.Length > 128) name = name[..128];
|
||||
|
||||
var description = (req.Description ?? string.Empty).Trim();
|
||||
if (description.Length > 1024) description = description[..1024];
|
||||
|
||||
CustomAvatarItemEntity item;
|
||||
if (req.Id is Guid id)
|
||||
{
|
||||
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
|
||||
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
|
||||
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
|
||||
if (item.Id == 0) db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
|
||||
db.CustomAvatarItems.Add(item);
|
||||
}
|
||||
|
||||
item.Name = name;
|
||||
item.Description = description;
|
||||
item.Price = Math.Max(100, req.Price ?? item.Price);
|
||||
item.ItemType = req.ItemType ?? item.ItemType;
|
||||
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
||||
item.Color = Trim(req.Color, 64);
|
||||
item.ImageName = Trim(req.ImageName, 256);
|
||||
item.AssetName = Trim(req.AssetName, 256);
|
||||
item.IsPublic = req.IsPublic ?? item.IsPublic;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/purchase")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Purchase(Guid id)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
|
||||
if (item is null) return NotFound();
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
||||
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, 2);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
|
||||
await EnsureOwnershipAsync(pid, item.Id);
|
||||
item.PurchaseCount += 1;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
|
||||
}
|
||||
|
||||
private async Task EnsureOwnershipAsync(long playerId, long itemId)
|
||||
{
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
||||
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
CustomAvatarItemId = itemId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
|
||||
{
|
||||
var result = new List<Guid>();
|
||||
if (body?.Ids is { Count: > 0 })
|
||||
result.AddRange(body.Ids);
|
||||
if (body?.ItemIds is { Count: > 0 })
|
||||
result.AddRange(body.ItemIds);
|
||||
|
||||
foreach (var pair in Request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
}
|
||||
|
||||
return result.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private async Task<DesignRequest> ReadDesignRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new DesignRequest
|
||||
{
|
||||
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
|
||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
|
||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
|
||||
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
|
||||
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
|
||||
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<DesignRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new DesignRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new DesignRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private object ToWire(CustomAvatarItemEntity item) => new
|
||||
{
|
||||
Id = item.PublicId,
|
||||
CustomAvatarItemId = item.PublicId,
|
||||
CreatorId = (int)item.CreatorPlayerId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Price,
|
||||
item.ItemType,
|
||||
item.BaseAvatarItemId,
|
||||
item.Color,
|
||||
item.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
|
||||
item.AssetName,
|
||||
item.IsPublic,
|
||||
item.IsFeatured,
|
||||
item.CheerCount,
|
||||
item.PurchaseCount,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
Status = item.IsPublic ? 1 : 0,
|
||||
};
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class BulkRequest
|
||||
{
|
||||
public List<Guid>? Ids { get; set; }
|
||||
public List<Guid>? ItemIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DesignRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? Price { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public string? AssetName { get; set; }
|
||||
public bool? IsPublic { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Events.V3;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]/v3")]
|
||||
public class EventsController : ControllerBase
|
||||
public class EventsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("list")]
|
||||
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
|
||||
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.Select(e => new
|
||||
{
|
||||
EventId = e.Id,
|
||||
PlayerEventId = e.Id,
|
||||
Name = e.Title,
|
||||
e.Description,
|
||||
StartTime = e.StartsAt,
|
||||
EndTime = e.EndsAt,
|
||||
CreatorPlayerId = (int)e.CreatorPlayerId,
|
||||
e.RoomId,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows.Cast<object>().ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
|
||||
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N")[..10];
|
||||
var key = $"externalinvite:platform:{code}";
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = key,
|
||||
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
|
||||
return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty });
|
||||
}
|
||||
|
||||
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
|
||||
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N")[..10];
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"externalinvite:text:{code}",
|
||||
Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
|
||||
}
|
||||
|
||||
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
|
||||
public async Task<IActionResult> GetPlatformReferrers()
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
|
||||
public async Task<IActionResult> GetTextMessageReferrers()
|
||||
{
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToInviteWire));
|
||||
}
|
||||
|
||||
private static object ToInviteWire(PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new
|
||||
{
|
||||
InviteCode = row.Key.Split(':').Last(),
|
||||
Kind = row.Key.Contains(":text:") ? "text" : "platform",
|
||||
Value = parts.FirstOrDefault() ?? string.Empty,
|
||||
CreatedAt = parts.LastOrDefault() ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.FreeGifts;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/freegifts/v1/sendmultiple")]
|
||||
public async Task<IActionResult> SendMultiple()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var sender = this.RequireCurrentPlayerId();
|
||||
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
|
||||
if (recipients.Count == 0) return BadRequest("missing_recipients");
|
||||
|
||||
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
|
||||
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
|
||||
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
|
||||
var message = ReadString(fields, "message", "Message") ?? string.Empty;
|
||||
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
|
||||
|
||||
foreach (var recipient in recipients.Distinct().Take(100))
|
||||
{
|
||||
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = recipient,
|
||||
FromPlayerId = (int)sender,
|
||||
CurrencyType = currency > 0 ? currencyType : 0,
|
||||
Currency = currency,
|
||||
Xp = xp,
|
||||
Level = 1,
|
||||
GiftContext = 0,
|
||||
GiftRarity = 0,
|
||||
Message = message,
|
||||
Platform = -1,
|
||||
PackageVariant = "FreeGift",
|
||||
PackageMaterial = string.Empty,
|
||||
Consumed = false,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
created.Add((recipient, gift));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var row in created)
|
||||
{
|
||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||
PushNotificationId.GiftPackageReceived,
|
||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, Sent = created.Count });
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(100)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.GameSight;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class GameSightController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/gamesight/event")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Event([FromBody] object? body)
|
||||
{
|
||||
var playerId = this.CurrentPlayerId();
|
||||
if (playerId is null)
|
||||
return Ok(new { Success = true });
|
||||
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = playerId.Value,
|
||||
Key = $"gamesight:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,48 @@ public class ImagesController(
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
|
||||
}
|
||||
|
||||
public sealed class ImageBulkRequest
|
||||
{
|
||||
public List<long>? ImageIds { get; set; }
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v5/bulk")]
|
||||
[HttpPost("api/images/v5/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
|
||||
.Concat(Request.Query.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0))
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.Photos
|
||||
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
|
||||
&& (p.IsPublic || p.UploaderPlayerId == pid))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
[HttpGet("api/images/v6")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
@@ -424,6 +466,7 @@ public class ImagesController(
|
||||
}
|
||||
|
||||
[HttpPost("api/images/v4/cheered/bulk")]
|
||||
[HttpPost("api/images/v5/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
{
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/incentivizedreferrals")]
|
||||
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("")]
|
||||
[HttpGet("progress")]
|
||||
public async Task<IActionResult> Progress()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
var claimed = await db.PlayerSettings
|
||||
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
|
||||
return Ok(new
|
||||
{
|
||||
ReferralCount = referralCount,
|
||||
RequiredReferralCount = 1,
|
||||
CanClaim = referralCount >= 1 && !claimed,
|
||||
Claimed = claimed,
|
||||
RewardCurrencyType = 2,
|
||||
RewardCurrency = 250,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("claim")]
|
||||
public async Task<IActionResult> Claim()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
if (referralCount < 1)
|
||||
return Ok(new { Success = false, Error = "not_enough_referrals" });
|
||||
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
|
||||
return Ok(new { Success = false, Error = "already_claimed" });
|
||||
|
||||
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = "referral:reward_claimed",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
|
||||
|
||||
[ApiController]
|
||||
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/influencerpartnerprogram/influencers")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
|
||||
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
||||
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
||||
.Distinct()
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
var players = await db.Players
|
||||
.Where(p => creatorIds.Contains(p.Id))
|
||||
.OrderBy(p => p.Username)
|
||||
.ToListAsync();
|
||||
return Ok(players.Select(ToInfluencerWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/influencerpartnerprogram/influencer")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
|
||||
{
|
||||
var player = accountId is long id
|
||||
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
|
||||
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(ToInfluencerWire(player));
|
||||
}
|
||||
|
||||
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MyInfluencer()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
if (setting is null || !long.TryParse(setting.Value, out var influencerId))
|
||||
return Ok(new { Influencer = (object?)null });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId);
|
||||
return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) });
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/support")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var influencer = influencerAccountId is long id
|
||||
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
|
||||
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (influencer is null) return NotFound();
|
||||
|
||||
var setting = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
|
||||
if (setting is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "influencer:supported",
|
||||
Value = influencer.Id.ToString(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.Value = influencer.Id.ToString();
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) });
|
||||
}
|
||||
|
||||
[HttpPost("api/influencerpartnerprogram/remove")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Remove()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
private static object ToInfluencerWire(PlayerEntity player) => new
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
player.Username,
|
||||
DisplayName = player.DisplayName,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,9 @@ public class InventionsController(
|
||||
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("api/inventions/v1/featured")]
|
||||
[HttpGet("api/inventions/v1/featureddormskins")]
|
||||
[HttpGet("api/inventions/v1/toptoday")]
|
||||
[HttpGet("api/inventions/v3/popular")]
|
||||
public async Task<ActionResult> Popular([FromQuery] int take = 50)
|
||||
{
|
||||
@@ -78,6 +81,7 @@ public class InventionsController(
|
||||
public Task<ActionResult> Mine() => Saved(100);
|
||||
|
||||
[HttpGet("api/inventions/v1/search")]
|
||||
[HttpGet("api/inventions/v2/search")]
|
||||
public async Task<ActionResult> Search([FromQuery] string value = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -127,6 +131,8 @@ public class InventionsController(
|
||||
public sealed record InventionBatchRequest(List<long> InventionIds);
|
||||
|
||||
[HttpPost("api/inventions/v1/batch")]
|
||||
[HttpPost("api/inventions/v2/batch")]
|
||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||
{
|
||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
||||
@@ -138,6 +144,27 @@ public class InventionsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/fromcreators")]
|
||||
public async Task<ActionResult> FromCreators()
|
||||
{
|
||||
var creatorIds = Request.Query
|
||||
.SelectMany(q => q.Value)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
|
||||
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
|
||||
/// response as <c>RecNet.GetFiltersResponse</c> (an
|
||||
@@ -356,6 +383,7 @@ public class InventionsController(
|
||||
/// save flow. v4 wraps the new invention plus its first version in
|
||||
/// a Status/Invention/InventionVersion object.</summary>
|
||||
[HttpPost("api/inventions/v4/save")]
|
||||
[HttpPost("api/inventions/v6/save")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
|
||||
{
|
||||
@@ -426,6 +454,7 @@ public class InventionsController(
|
||||
int? InstantiationCost, int? LightsCost);
|
||||
|
||||
[HttpPost("api/inventions/v3/addversion")]
|
||||
[HttpPost("api/inventions/v4/addversion")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
|
||||
{
|
||||
@@ -491,6 +520,22 @@ public class InventionsController(
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> UpdatePrice(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
inv.Price = Math.Clamp(price, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Delete([FromQuery] long inventionId)
|
||||
@@ -685,7 +730,7 @@ public class InventionsController(
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
Price = 0,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
|
||||
@@ -182,6 +182,44 @@ public class InventoryController(
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
[HttpPost("api/consumables/v1/transfer")]
|
||||
public async Task<IActionResult> TransferConsumable(
|
||||
[FromForm] long? id,
|
||||
[FromForm] long? recipientPlayerId,
|
||||
[FromForm] int quantity = 1)
|
||||
{
|
||||
var pid = Me;
|
||||
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
|
||||
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
|
||||
var amount = Math.Max(1, quantity);
|
||||
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
|
||||
return BadRequest("invalid_transfer");
|
||||
|
||||
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
|
||||
if (source is null || source.Quantity < amount) return NotFound();
|
||||
var target = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
|
||||
if (target is null)
|
||||
{
|
||||
target = new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = recipient,
|
||||
ItemSlug = source.ItemSlug,
|
||||
Quantity = 0,
|
||||
};
|
||||
db.PlayerInventory.Add(target);
|
||||
}
|
||||
|
||||
source.Quantity -= amount;
|
||||
target.Quantity += amount;
|
||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(recipient,
|
||||
PushNotificationId.ConsumableMappingAdded,
|
||||
new { target.Id, target.ItemSlug, target.Quantity });
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
public sealed class ActivateConsumableRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ItemWishlists;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/itemWishlists")]
|
||||
[Authorize]
|
||||
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
[HttpGet("v1/wishlist/me")]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("v1/wishlist")]
|
||||
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
|
||||
{
|
||||
var pid = playerId is > 0 ? playerId.Value : Me;
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == pid)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HttpPost("v1/wishlist")]
|
||||
public async Task<IActionResult> Add()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
||||
if (key.Length > 128) key = key[..128];
|
||||
var itemType = req.ItemType ?? 0;
|
||||
|
||||
var exists = await db.ItemWishlists.AnyAsync(w =>
|
||||
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
|
||||
if (!exists)
|
||||
{
|
||||
db.ItemWishlists.Add(new ItemWishlistEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
ItemKey = key,
|
||||
ItemType = itemType,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return await Mine();
|
||||
}
|
||||
|
||||
[HttpDelete("v1/wishlist")]
|
||||
[HttpPost("v1/wishlist/remove")]
|
||||
public async Task<IActionResult> Remove()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
||||
var itemType = req.ItemType ?? 0;
|
||||
await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
|
||||
.ExecuteDeleteAsync();
|
||||
return await Mine();
|
||||
}
|
||||
|
||||
private async Task<WishlistRequest> ReadRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new WishlistRequest
|
||||
{
|
||||
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
|
||||
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
|
||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
||||
};
|
||||
}
|
||||
|
||||
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
|
||||
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
|
||||
{
|
||||
return new WishlistRequest
|
||||
{
|
||||
ItemKey = queryKey,
|
||||
ItemId = queryId,
|
||||
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new WishlistRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new WishlistRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private static object ToWire(ItemWishlistEntity row) => new
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.ItemKey,
|
||||
ItemId = row.ItemKey,
|
||||
row.ItemType,
|
||||
row.CreatedAt,
|
||||
};
|
||||
|
||||
public sealed class WishlistRequest
|
||||
{
|
||||
public string? ItemKey { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Keepsakes;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/keepsakes")]
|
||||
[Authorize]
|
||||
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
await EnsureLoginKeepsakeAsync(Me);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me)
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Categories() => Ok(new[]
|
||||
{
|
||||
new { Category = "account", DisplayName = "Account" },
|
||||
new { Category = "event", DisplayName = "Events" },
|
||||
new { Category = "room", DisplayName = "Rooms" },
|
||||
});
|
||||
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> Events()
|
||||
{
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me && k.Category == "event")
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("globalconfig")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GlobalConfig() => Ok(new
|
||||
{
|
||||
Enabled = true,
|
||||
Categories = new[] { "account", "event", "room" },
|
||||
CdnBaseUrl = $"https://{domain.Sub("cdn")}/",
|
||||
});
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = Trim(req.EventKey, 128);
|
||||
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
|
||||
var category = Trim(req.Category, 64);
|
||||
if (category.Length == 0) category = "event";
|
||||
|
||||
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
|
||||
k.PlayerId == Me && k.EventKey == key);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new KeepsakeEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
EventKey = key,
|
||||
};
|
||||
db.Keepsakes.Add(existing);
|
||||
}
|
||||
|
||||
existing.Category = category;
|
||||
existing.Title = Trim(req.Title, 128);
|
||||
existing.Description = Trim(req.Description, 1024);
|
||||
existing.ImageName = Trim(req.ImageName, 256);
|
||||
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(existing));
|
||||
}
|
||||
|
||||
private async Task EnsureLoginKeepsakeAsync(long playerId)
|
||||
{
|
||||
const string key = "first-login";
|
||||
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
|
||||
return;
|
||||
db.Keepsakes.Add(new KeepsakeEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Category = "account",
|
||||
EventKey = key,
|
||||
Title = "First Login",
|
||||
Description = "Joined this server.",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<KeepsakeRequest> ReadRequestAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
return new KeepsakeRequest
|
||||
{
|
||||
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
|
||||
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
|
||||
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new KeepsakeRequest();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new KeepsakeRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private object ToWire(KeepsakeEntity row) => new
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.Category,
|
||||
row.EventKey,
|
||||
row.Title,
|
||||
row.Description,
|
||||
row.ImageName,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
|
||||
row.EarnedAt,
|
||||
};
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
public sealed class KeepsakeRequest
|
||||
{
|
||||
public string? Category { get; set; }
|
||||
public string? EventKey { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageName { get; set; }
|
||||
public DateTime? EarnedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,18 @@ public class PlatformLoginController(
|
||||
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
|
||||
[HttpDelete("cached")]
|
||||
[HttpDelete("cachedlogins")]
|
||||
public IActionResult RemoveCachedLogin() => Ok(new { });
|
||||
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
|
||||
{
|
||||
var targetId = accountId ?? this.CurrentPlayerId();
|
||||
if (targetId is not long playerId) return Unauthorized();
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return NotFound();
|
||||
player.LastPlatform = 0;
|
||||
player.LastPlatformId = string.Empty;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, AccountId = (int)player.Id });
|
||||
}
|
||||
|
||||
// Login.RefreshLogin → POST api/platformlogin/refresh
|
||||
// Response is Login.RefreshLoginResponse { Token } — single field, the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playstationplus/membership")]
|
||||
public async Task<IActionResult> Membership()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
|
||||
? parsed
|
||||
: DateTime.MinValue;
|
||||
var active = expires > DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
IsMember = active,
|
||||
MembershipActive = active,
|
||||
ExpiresAt = active ? expires : (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("api/playstationplus/expire")]
|
||||
public async Task<IActionResult> Expire()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = "playstationplus:expires",
|
||||
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, IsMember = false });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -45,6 +46,7 @@ public class PlayerEventsController(
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> GetOne(long eventId)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
@@ -52,6 +54,121 @@ public class PlayerEventsController(
|
||||
return Ok(ToWire(ev));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
|
||||
public async Task<ActionResult> ForRoom(long roomId)
|
||||
{
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
|
||||
public async Task<ActionResult> ForClub(long clubId)
|
||||
{
|
||||
var memberIds = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == clubId)
|
||||
.Select(m => m.PlayerId)
|
||||
.ToListAsync();
|
||||
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/clubs")]
|
||||
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var clubPlayerIds = await db.ClubMemberships
|
||||
.Select(m => m.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed class EventBulkRequest
|
||||
{
|
||||
public List<long>? PlayerEventIds { get; set; }
|
||||
public List<long>? EventIds { get; set; }
|
||||
public List<long>? Ids { get; set; }
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
|
||||
{
|
||||
var ids = await ReadEventIdsAsync(body);
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/tagfilters")]
|
||||
public IActionResult TagFilters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
@@ -93,6 +210,39 @@ public class PlayerEventsController(
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
{
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == accountId)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == accountId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
@@ -233,6 +383,123 @@ public class PlayerEventsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<IActionResult> UpdateName(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("name", "Name", "value");
|
||||
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
|
||||
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
|
||||
public async Task<IActionResult> UpdateDescription(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
|
||||
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
|
||||
public async Task<IActionResult> UpdateRoom(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
|
||||
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
|
||||
evt.RoomId = id;
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
|
||||
public async Task<IActionResult> UpdateTime(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
|
||||
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
|
||||
public async Task<IActionResult> UpdateImage(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "image", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
public async Task<IActionResult> UpdateAccessibility(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
|
||||
if (value is not int accessibility) return BadRequest("missing_accessibility");
|
||||
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
|
||||
public async Task<IActionResult> UpdateTags(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "tags", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
public async Task<IActionResult> UpdateMultiInstance(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
|
||||
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
|
||||
public async Task<IActionResult> UpdateClub(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
|
||||
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
await SetEventSettingAsync(evt, "club", id.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
public async Task<ActionResult> Delete(long eventId)
|
||||
{
|
||||
@@ -297,6 +564,44 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class BroadcastRequest
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == ev.Id && r.Response != 2)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var body = string.IsNullOrWhiteSpace(req.Message)
|
||||
? $"Update for '{ev.Title}'"
|
||||
: req.Message.Trim();
|
||||
foreach (var playerId in recipients.Where(id => id != Me))
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
Body = body,
|
||||
});
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, ev.Title, Message = body });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Sent = recipients.Count });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
@@ -306,6 +611,130 @@ public class PlayerEventsController(
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds);
|
||||
if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds);
|
||||
if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids);
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return bool.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return DateTime.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
|
||||
{
|
||||
const string itemKey = "__playerevent_fields";
|
||||
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
|
||||
&& cached is Dictionary<string, string> existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[itemKey] = fields;
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
|
||||
{
|
||||
var settingKey = $"playerevent:{evt.Id}:{key}";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = evt.CreatorPlayerId,
|
||||
Key = settingKey,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/playerwarnings")]
|
||||
public async Task<IActionResult> Warnings()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
return Ok(rows
|
||||
.Select(ToWarningWire)
|
||||
.Where(w => !w.Acknowledged)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpPost("api/playerwarnings/acknowledge")]
|
||||
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return NotFound();
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, WarningId = id });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
return new WarningWire
|
||||
{
|
||||
WarningId = row.Key["warning:".Length..],
|
||||
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
|
||||
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
|
||||
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
|
||||
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WarningWire
|
||||
{
|
||||
public string WarningId { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int Category { get; set; }
|
||||
public string CreatedAt { get; set; } = string.Empty;
|
||||
public bool Acknowledged { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ public class PlayersController(PlayerService playerService, NotificationService
|
||||
return Ok(PlayerService.ToProfile(player));
|
||||
}
|
||||
|
||||
[HttpGet("/api/players/v4/current/contact")]
|
||||
public async Task<IActionResult> CurrentContact()
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(CurrentPlayerId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
CanReceiveInvites = player.CanReceiveInvites,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<PlayerProfile>> GetById(long id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
|
||||
|
||||
[ApiController]
|
||||
public class ProgressionEventsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/progressionEvents")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Progress()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rows = await db.ObjectiveProgress
|
||||
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
|
||||
.OrderByDescending(o => o.ClearedAt)
|
||||
.Take(100)
|
||||
.Select(o => new
|
||||
{
|
||||
EventKey = o.Key,
|
||||
o.IsCompleted,
|
||||
o.ClearedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("api/progressionEvents/active")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active()
|
||||
{
|
||||
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
|
||||
var end = start.AddDays(7);
|
||||
return Ok(new
|
||||
{
|
||||
EventKey = $"weekly:{start:yyyyMMdd}",
|
||||
Name = "Weekly Progression",
|
||||
StartAt = start,
|
||||
EndAt = end,
|
||||
Active = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
return Ok(new { Imported = added });
|
||||
}
|
||||
|
||||
[HttpGet("api/relationships/mutualfriends")]
|
||||
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
|
||||
var mine = await FriendIdsAsync(me);
|
||||
var theirs = await FriendIdsAsync(playerId);
|
||||
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
|
||||
var players = await db.Players
|
||||
.Where(p => mutualIds.Contains(p.Id))
|
||||
.Select(p => new
|
||||
{
|
||||
AccountId = (int)p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
ProfileImage = p.ProfileImageName ?? string.Empty,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(players);
|
||||
}
|
||||
|
||||
[HttpPost("api/relationships/sendfriendintroductions")]
|
||||
public async Task<IActionResult> SendFriendIntroductions(
|
||||
[FromForm] long playerId,
|
||||
[FromForm] long introducedPlayerId)
|
||||
{
|
||||
var me = CurrentPlayerId;
|
||||
if (playerId <= 0 || introducedPlayerId <= 0)
|
||||
return BadRequest("missing_player");
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = me,
|
||||
RecipientPlayerId = playerId,
|
||||
Type = 30,
|
||||
Body = $"Friend introduction:{introducedPlayerId}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("api/relationships/v1/addfriendwithcode")]
|
||||
[HttpGet("api/relationships/v1/addfriendwithcode")]
|
||||
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
|
||||
{
|
||||
var raw = (code ?? formCode ?? string.Empty).Trim();
|
||||
if (raw.Length == 0) return Ok(EmptyRelationship(0));
|
||||
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
|
||||
if (id is null)
|
||||
{
|
||||
id = await db.Players
|
||||
.Where(p => p.Username == raw)
|
||||
.Select(p => (long?)p.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
if (id is not long playerId) return Ok(EmptyRelationship(0));
|
||||
return await AddFriend(playerId);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
||||
{
|
||||
var rows = await db.Relationships
|
||||
.Where(r => r.Status == EntityStatus.Friend
|
||||
&& (r.RequesterId == playerId || r.TargetId == playerId))
|
||||
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
|
||||
.ToListAsync();
|
||||
return rows.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>Map a storage row to the watch's wire RelationshipType
|
||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||
/// direction depending on which side originated.</summary>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomConsumables;
|
||||
|
||||
[ApiController]
|
||||
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/roomconsumables")]
|
||||
[HttpGet("api/roomconsumables/v1/roomConsumable")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List([FromQuery] long? id = null)
|
||||
{
|
||||
var query = db.StoreItems
|
||||
.Where(i => i.IsActive && i.Category.ToLower() == "consumable");
|
||||
if (id is long itemId && itemId > 0)
|
||||
query = query.Where(i => i.Id == itemId);
|
||||
var rows = await query
|
||||
.OrderBy(i => i.DisplayName)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/currency")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PurchaseCurrency(long itemId, [FromQuery] int? currencyType = null) =>
|
||||
Purchase(itemId, currencyType);
|
||||
|
||||
[HttpPost("api/roomconsumables/v1/roomconsumable/{itemId:long}/purchase/tokens")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> PurchaseTokens(long itemId) => Purchase(itemId, 2);
|
||||
|
||||
private async Task<IActionResult> Purchase(long itemId, int? currencyType)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive && i.Category.ToLower() == "consumable");
|
||||
if (item is null) return NotFound();
|
||||
|
||||
var spendCurrency = currencyType ?? item.CurrencyType;
|
||||
var balance = await level.GetBalanceAsync(me, spendCurrency);
|
||||
if (balance < item.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, CurrencyType = spendCurrency });
|
||||
|
||||
var newBalance = await level.GrantCurrencyAsync(me, spendCurrency, -item.Price, $"roomConsumable:{item.Slug}");
|
||||
var inventory = await db.PlayerInventory
|
||||
.FirstOrDefaultAsync(i => i.PlayerId == me && i.ItemSlug == item.Slug);
|
||||
if (inventory is null)
|
||||
{
|
||||
inventory = new PlayerInventoryEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
ItemSlug = item.Slug,
|
||||
Quantity = 0,
|
||||
};
|
||||
db.PlayerInventory.Add(inventory);
|
||||
}
|
||||
inventory.Quantity += 1;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Balance = newBalance,
|
||||
CurrencyType = spendCurrency,
|
||||
Item = ToWire(item),
|
||||
Quantity = inventory.Quantity,
|
||||
});
|
||||
}
|
||||
|
||||
private static object ToWire(StoreItemEntity item) => new
|
||||
{
|
||||
RoomConsumableId = item.Id,
|
||||
ItemId = item.Id,
|
||||
item.Slug,
|
||||
Name = item.DisplayName,
|
||||
item.Description,
|
||||
item.ImageName,
|
||||
item.Price,
|
||||
item.CurrencyType,
|
||||
item.Category,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomCurrencies;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/currencies")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Currencies([FromQuery] long roomId)
|
||||
{
|
||||
var rows = await db.RoomCurrencies
|
||||
.Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId))
|
||||
.OrderBy(c => c.Name)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/createCurrency")]
|
||||
public async Task<IActionResult> CreateCurrency()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var roomId = ReadLong(fields, "roomId", "RoomId");
|
||||
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
|
||||
if (room is null) return NotFound("room_not_found");
|
||||
if (!await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64);
|
||||
var currency = new RoomCurrencyEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
CreatorPlayerId = Me,
|
||||
Name = name,
|
||||
Description = Trim(ReadString(fields, "description", "Description"), 512),
|
||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
||||
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
|
||||
};
|
||||
db.RoomCurrencies.Add(currency);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/updateCurrency")]
|
||||
[HttpPut("api/roomcurrencies/v1/updateCurrency")]
|
||||
public async Task<IActionResult> UpdateCurrency()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
|
||||
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
|
||||
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
|
||||
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
|
||||
currency.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(currency));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/getBalance")]
|
||||
public async Task<IActionResult> GetBalance()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
|
||||
var balance = await GetBalanceAsync(playerId, currency.Id);
|
||||
return Ok(new
|
||||
{
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Balance = balance,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("api/roomcurrencies/v1/getAllBalances")]
|
||||
public async Task<IActionResult> GetAllBalances([FromQuery] long? roomId = null)
|
||||
{
|
||||
var playerId = Me;
|
||||
var query = db.RoomCurrencyBalances
|
||||
.Where(b => b.PlayerId == playerId)
|
||||
.Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c })
|
||||
.Where(x => !x.c.IsDeleted);
|
||||
if (roomId is long rid && rid > 0)
|
||||
query = query.Where(x => x.c.RoomId == rid);
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(x => x.c.RoomId)
|
||||
.ThenBy(x => x.c.Name)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(x => new
|
||||
{
|
||||
PlayerId = (int)playerId,
|
||||
RoomCurrencyId = x.c.PublicId,
|
||||
x.b.Balance,
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
|
||||
public async Task<IActionResult> AwardCurrencyBulk()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds");
|
||||
var amount = ReadLong(fields, "amount", "Amount") ?? 0;
|
||||
if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards");
|
||||
|
||||
var results = new List<object>();
|
||||
foreach (var playerId in playerIds)
|
||||
{
|
||||
var balance = await AddBalanceAsync(playerId, currency.Id, amount);
|
||||
results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/createPurchaseOffer")]
|
||||
public async Task<IActionResult> CreatePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var currency = await FindCurrencyAsync(fields);
|
||||
if (currency is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
var offer = new RoomCurrencyPurchaseOfferEntity
|
||||
{
|
||||
RoomCurrencyId = currency.Id,
|
||||
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
|
||||
Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1),
|
||||
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
|
||||
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
|
||||
};
|
||||
db.RoomCurrencyPurchaseOffers.Add(offer);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")]
|
||||
[HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")]
|
||||
public async Task<IActionResult> UpdatePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
|
||||
if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount);
|
||||
if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price);
|
||||
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")]
|
||||
[HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")]
|
||||
public async Task<IActionResult> DeletePurchaseOffer()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
|
||||
|
||||
offer.IsDeleted = true;
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetPurchaseOffersBatch()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
|
||||
var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds");
|
||||
|
||||
var query = db.RoomCurrencyPurchaseOffers
|
||||
.Where(o => !o.IsDeleted)
|
||||
.Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c })
|
||||
.Where(x => !x.c.IsDeleted);
|
||||
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
|
||||
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
|
||||
|
||||
var rows = await query.Take(200).ToListAsync();
|
||||
return Ok(rows.Select(x => ToOfferWire(x.o, x.c)));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomCurrencies/v2/purchase")]
|
||||
[HttpGet("api/roomCurrencies/v2/purchase")]
|
||||
public async Task<IActionResult> Purchase()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var offer = await FindOfferAsync(fields);
|
||||
if (offer is null) return NotFound();
|
||||
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, offer.CurrencyType);
|
||||
if (balance < offer.Price)
|
||||
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType });
|
||||
var newBalance = offer.Price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
|
||||
var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Balance = newBalance,
|
||||
offer.CurrencyType,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
RoomCurrencyBalance = customBalance,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id");
|
||||
if (guid is Guid publicId)
|
||||
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
|
||||
var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId");
|
||||
if (localId is long id)
|
||||
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id");
|
||||
if (guid is Guid publicId)
|
||||
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted);
|
||||
var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId");
|
||||
if (localId is long id)
|
||||
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long> GetBalanceAsync(long playerId, long currencyId) =>
|
||||
await db.RoomCurrencyBalances
|
||||
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
|
||||
.Select(b => b.Balance)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
|
||||
{
|
||||
var row = await db.RoomCurrencyBalances
|
||||
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
|
||||
db.RoomCurrencyBalances.Add(row);
|
||||
}
|
||||
|
||||
row.Balance = Math.Max(0, row.Balance + amount);
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
return row.Balance;
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
|
||||
return true;
|
||||
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
|
||||
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => long.TryParse(v, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
private static object ToWire(RoomCurrencyEntity currency) => new
|
||||
{
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
Id = currency.PublicId,
|
||||
InternalId = currency.Id,
|
||||
currency.RoomId,
|
||||
CreatorPlayerId = (int)currency.CreatorPlayerId,
|
||||
currency.Name,
|
||||
currency.Description,
|
||||
currency.ImageName,
|
||||
currency.DailyLimit,
|
||||
currency.CreatedAt,
|
||||
currency.UpdatedAt,
|
||||
};
|
||||
|
||||
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
|
||||
{
|
||||
PurchaseOfferId = offer.PublicId,
|
||||
RoomCurrencyPackageId = offer.PublicId,
|
||||
Id = offer.PublicId,
|
||||
InternalId = offer.Id,
|
||||
RoomCurrencyId = currency.PublicId,
|
||||
offer.Name,
|
||||
offer.Amount,
|
||||
offer.Price,
|
||||
offer.CurrencyType,
|
||||
offer.CreatedAt,
|
||||
offer.UpdatedAt,
|
||||
};
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/roomEarningsDistributions")]
|
||||
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
|
||||
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me);
|
||||
if (roomId is long rid && rid > 0)
|
||||
rooms = rooms.Where(r => r.Id == rid);
|
||||
|
||||
var roomRows = await rooms
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToListAsync();
|
||||
var roomIds = roomRows.Select(r => r.Id).ToList();
|
||||
var earnings = await db.RoomKeyPurchases
|
||||
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
|
||||
.Where(x => roomIds.Contains(x.k.RoomId))
|
||||
.GroupBy(x => x.k.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Gross = g.Sum(x => x.p.PaidPrice),
|
||||
PurchaseCount = g.Count(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var byRoom = earnings.ToDictionary(e => e.RoomId);
|
||||
return Ok(roomRows.Select(room =>
|
||||
{
|
||||
byRoom.TryGetValue(room.Id, out var value);
|
||||
var gross = value?.Gross ?? 0;
|
||||
return new
|
||||
{
|
||||
RoomId = room.Id,
|
||||
room.Name,
|
||||
GrossEarnings = gross,
|
||||
NetEarnings = gross,
|
||||
PurchaseCount = value?.PurchaseCount ?? 0,
|
||||
CurrencyType = 2,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}).ToList());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,22 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys")]
|
||||
[HttpGet("api/roomkeys/v1")]
|
||||
public async Task<IActionResult> List([FromQuery] long? roomId = null)
|
||||
{
|
||||
var rows = db.RoomKeys.Where(k => !k.IsDeleted);
|
||||
if (roomId is long id && id > 0)
|
||||
rows = rows.Where(k => k.RoomId == id);
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(k => k.RoomId)
|
||||
.ThenBy(k => k.Price)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/roomkeys/v1/create")]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
@@ -132,6 +148,29 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/owns")]
|
||||
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
|
||||
{
|
||||
var pid = Me;
|
||||
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
|
||||
return Content(owns ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("api/roomkeys/v1/owns/bulk")]
|
||||
[HttpPost("api/roomkeys/v1/owns/bulk")]
|
||||
public async Task<IActionResult> OwnsBulk()
|
||||
{
|
||||
var ids = await ReadRoomKeyIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var pid = Me;
|
||||
var owned = await db.RoomKeyPurchases
|
||||
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
|
||||
.Select(p => p.RoomKeyId)
|
||||
.ToListAsync();
|
||||
var ownedSet = owned.ToHashSet();
|
||||
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
|
||||
}
|
||||
|
||||
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
|
||||
{
|
||||
Status = (int)status,
|
||||
@@ -173,6 +212,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<List<long>> ReadRoomKeyIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private async Task<T> ReadBodyAsync<T>() where T : new()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ScreenShareReports;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/screensharereports/v1/report")]
|
||||
public async Task<IActionResult> Report(
|
||||
[FromForm] long? targetPlayerId,
|
||||
[FromForm] long? roomId,
|
||||
[FromForm] int? reportCategory,
|
||||
[FromForm] string? details)
|
||||
{
|
||||
var reporter = this.RequireCurrentPlayerId();
|
||||
var target = targetPlayerId
|
||||
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
|
||||
var room = roomId
|
||||
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
|
||||
var category = reportCategory
|
||||
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
|
||||
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
|
||||
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = reporter,
|
||||
TargetPlayerId = target,
|
||||
RoomId = room,
|
||||
Category = category,
|
||||
Message = message[..Math.Min(1000, message.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty });
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,7 @@ public class StorefrontsBuyController(
|
||||
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
||||
/// price it displayed so we reject stale-price purchases.</summary>
|
||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||
public async Task<IActionResult> BuyRoomKey(
|
||||
[FromQuery] long roomKeyId,
|
||||
[FromQuery] int requestedPrice)
|
||||
@@ -242,6 +243,95 @@ public class StorefrontsBuyController(
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v2/buyInvention")]
|
||||
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
|
||||
if (inv is null) return NotFound();
|
||||
var price = Math.Max(0, inv.Price);
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
|
||||
var key = $"invention_purchase:{inv.Id}";
|
||||
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
|
||||
db.GiftPackages.Add(new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
CurrencyType = 2,
|
||||
Currency = 25,
|
||||
Level = 1,
|
||||
PackageVariant = "FreeGiftButton",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
|
||||
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
|
||||
public async Task<IActionResult> BuyPurchaseReminder()
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"purchaseReminder:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task GrantWardrobeAsync(long pid, string guid)
|
||||
|
||||
@@ -32,6 +32,8 @@ public class StorefrontsController(
|
||||
[HttpGet("api/storefronts/v1/all")]
|
||||
[HttpGet("api/storefronts/v2/all")]
|
||||
[HttpGet("api/storefronts/v3/all")]
|
||||
[HttpGet("api/storefronts/v1/toptoday")]
|
||||
[HttpGet("api/storefronts/v1/adcarouselitems")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.SubscriptionSeasons;
|
||||
|
||||
[ApiController]
|
||||
public class SubscriptionSeasonsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/subscriptionseasons/v1/seasons/current")]
|
||||
public async Task<IActionResult> Current()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var start = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = start.AddMonths(1);
|
||||
var activeSubscribers = await db.Subscriptions.CountAsync();
|
||||
var rewards = await db.StoreItems
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(10)
|
||||
.Select(i => new
|
||||
{
|
||||
RewardId = i.Id,
|
||||
i.Slug,
|
||||
Name = i.DisplayName,
|
||||
i.Category,
|
||||
i.ImageName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
SeasonId = $"{start:yyyyMM}",
|
||||
Name = $"Season {start:yyyy.MM}",
|
||||
StartAt = start,
|
||||
EndAt = end,
|
||||
ActiveSubscriberCount = activeSubscribers,
|
||||
Rewards = rewards,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.UgcPurchasables;
|
||||
|
||||
[ApiController]
|
||||
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/ugcPurchasables")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List([FromQuery] long roomId = 0)
|
||||
{
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Name)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost("api/ugcPurchasables")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var roomId = ReadLong(fields, "roomId", "RoomId");
|
||||
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
|
||||
if (room is null) return NotFound("room_not_found");
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
if (!await CanManageRoomAsync(room, me)) return Forbid();
|
||||
|
||||
var item = new UgcPurchasableEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
CreatorPlayerId = me,
|
||||
Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128),
|
||||
Description = Trim(ReadString(fields, "description", "Description"), 1024),
|
||||
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
|
||||
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
|
||||
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
|
||||
ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0),
|
||||
SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0,
|
||||
IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false,
|
||||
};
|
||||
db.UgcPurchasables.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpPut("api/ugcPurchasables")]
|
||||
[HttpPost("api/ugcPurchasables/update")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Update()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var item = await FindItemAsync(fields);
|
||||
if (item is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
|
||||
|
||||
if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128);
|
||||
if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024);
|
||||
if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256);
|
||||
if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price);
|
||||
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType);
|
||||
if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType);
|
||||
if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder;
|
||||
if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpDelete("api/ugcPurchasables")]
|
||||
[HttpPost("api/ugcPurchasables/delete")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Delete()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var item = await FindItemAsync(fields);
|
||||
if (item is null) return NotFound();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
|
||||
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
|
||||
|
||||
item.IsDeleted = true;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(item));
|
||||
}
|
||||
|
||||
[HttpGet("api/ugcPurchasables/v1/items/bulk")]
|
||||
[HttpPost("api/ugcPurchasables/v1/items/bulk")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
|
||||
if (publicId is Guid guid)
|
||||
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted);
|
||||
var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId");
|
||||
if (localId is long id)
|
||||
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
|
||||
return true;
|
||||
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
|
||||
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
|
||||
long.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
|
||||
int.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
|
||||
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
|
||||
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
|
||||
|
||||
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
|
||||
names.SelectMany(name => fields.TryGetValue(name, out var value)
|
||||
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
: Array.Empty<string>())
|
||||
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
private static string Trim(string? value, int max)
|
||||
{
|
||||
var s = (value ?? string.Empty).Trim();
|
||||
return s.Length <= max ? s : s[..max];
|
||||
}
|
||||
|
||||
private static object ToWire(UgcPurchasableEntity item) => new
|
||||
{
|
||||
UgcPurchasableId = item.PublicId,
|
||||
PurchasableItemId = item.PublicId,
|
||||
Id = item.PublicId,
|
||||
InternalId = item.Id,
|
||||
item.RoomId,
|
||||
CreatorPlayerId = (int)item.CreatorPlayerId,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.ImageName,
|
||||
item.Price,
|
||||
item.CurrencyType,
|
||||
item.ItemType,
|
||||
item.IsFeatured,
|
||||
item.SortOrder,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
};
|
||||
}
|
||||
@@ -73,9 +73,11 @@ public class AccountsController(
|
||||
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? auth["Bearer ".Length..].Trim()
|
||||
: Request.Cookies[AuthService.AccessCookieName];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return null;
|
||||
return await playerService.GetByIdAsync(playerId);
|
||||
@@ -170,6 +172,7 @@ public class AccountsController(
|
||||
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
|
||||
// localhost public site's account search. Anonymous-safe (no JWT
|
||||
// required) so the front-facing site can call it directly.
|
||||
[HttpGet("/account/search")]
|
||||
[HttpGet("/account/v1/search")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
|
||||
@@ -405,6 +408,26 @@ public class AccountsController(
|
||||
return age;
|
||||
}
|
||||
|
||||
private async Task SetPlayerSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Everyone is a dev for now — exposes the in-game debug console + dev
|
||||
// tools. Per user request: easier debugging while iterating on the
|
||||
// private server. Flip back to false later if you want shipping behavior.
|
||||
@@ -509,6 +532,120 @@ public class AccountsController(
|
||||
[HttpGet("/account/v1/me/disallowiap")]
|
||||
public IActionResult DisallowIap() => Ok(new { Disallow = false });
|
||||
|
||||
[HttpGet("/account/isactivecreator/me")]
|
||||
public async Task<IActionResult> IsActiveCreator()
|
||||
{
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Content("false", "application/json");
|
||||
|
||||
var active = await db.Rooms.AnyAsync(r => r.CreatorPlayerId == me)
|
||||
|| await db.Inventions.AnyAsync(i => i.CreatorPlayerId == me && !i.IsDeleted)
|
||||
|| await db.CustomAvatarItems.AnyAsync(i => i.CreatorPlayerId == me);
|
||||
return Content(active ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpPost("/account/me/createlogintoken")]
|
||||
[HttpGet("/account/me/createlogintoken")]
|
||||
public async Task<IActionResult> CreateLoginToken()
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Token = token,
|
||||
LoginToken = token,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/account/me/remoteauth")]
|
||||
[HttpGet("/account/me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth()
|
||||
{
|
||||
var token = Request.Query["token"].FirstOrDefault()
|
||||
?? Request.Query["loginToken"].FirstOrDefault()
|
||||
?? Request.Query["code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
token = form["token"].FirstOrDefault()
|
||||
?? form["loginToken"].FirstOrDefault()
|
||||
?? form["code"].FirstOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var pair = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = pair.AccessToken,
|
||||
RefreshToken = pair.RefreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
var prefix = token.Trim() + "|";
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return Unauthorized();
|
||||
|
||||
var rawExpiry = row.Value[prefix.Length..];
|
||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
||||
return Unauthorized();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/account/recoverpassword")]
|
||||
[HttpGet("/account/recoverpassword")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> RecoverPassword(
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? email)
|
||||
{
|
||||
var nameOrEmail = username
|
||||
?? email
|
||||
?? Request.Query["username"].FirstOrDefault()
|
||||
?? Request.Query["email"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(nameOrEmail))
|
||||
return Ok(new RecNetResult { Success = false, Error = "missing_account" });
|
||||
|
||||
var needle = nameOrEmail.Trim().ToLowerInvariant();
|
||||
var player = await db.Players
|
||||
.FirstOrDefaultAsync(p => p.Username.ToLower() == needle
|
||||
|| (p.Email != null && p.Email.ToLower() == needle));
|
||||
if (player is null)
|
||||
return Ok(new RecNetResult { Success = false, Error = "unknown_account" });
|
||||
|
||||
var code = Random.Shared.Next(100000, 999999).ToString();
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(30);
|
||||
await SetPlayerSettingAsync(player.Id, "password_recovery_code", $"{code}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Code = code,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
|
||||
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
|
||||
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
|
||||
|
||||
@@ -31,6 +31,42 @@ public class AuthController(
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> BuildTokenResponse(string accessToken, string refreshToken) => new()
|
||||
{
|
||||
["access_token"] = accessToken,
|
||||
["accessToken"] = accessToken,
|
||||
["AccessToken"] = accessToken,
|
||||
["token"] = accessToken,
|
||||
["Token"] = accessToken,
|
||||
["token_type"] = "Bearer",
|
||||
["tokenType"] = "Bearer",
|
||||
["TokenType"] = "Bearer",
|
||||
["expires_in"] = 43200,
|
||||
["expiresIn"] = 43200,
|
||||
["ExpiresIn"] = 43200,
|
||||
["refresh_token"] = refreshToken,
|
||||
["refreshToken"] = refreshToken,
|
||||
["RefreshToken"] = refreshToken,
|
||||
["scope"] = "openid profile",
|
||||
};
|
||||
|
||||
private IActionResult TokenOk(string accessToken, string refreshToken)
|
||||
{
|
||||
var cookie = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
|
||||
SameSite = SameSiteMode.None,
|
||||
Expires = DateTimeOffset.UtcNow.AddHours(12),
|
||||
IsEssential = true,
|
||||
};
|
||||
if (domain.Apex.Contains('.'))
|
||||
cookie.Domain = "." + domain.Apex;
|
||||
Response.Cookies.Append(AuthService.AccessCookieName, accessToken, cookie);
|
||||
return Ok(BuildTokenResponse(accessToken, refreshToken));
|
||||
}
|
||||
|
||||
// OAuth token endpoint. The 2020 client uses three grant types here:
|
||||
// - grant_type=cached_login → user clicked a remembered account on the
|
||||
// account-selection screen. Body includes `account_id` (and platform
|
||||
@@ -90,14 +126,7 @@ public class AuthController(
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc1,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr1,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(acc1, refr1);
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -111,14 +140,7 @@ public class AuthController(
|
||||
if (existing is null)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(acc, refr);
|
||||
}
|
||||
|
||||
// password grant: two paths.
|
||||
@@ -150,14 +172,7 @@ public class AuthController(
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
var pair = authService.GenerateTokenPair(byName.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = pair.AccessToken,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = pair.RefreshToken,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(pair.AccessToken, pair.RefreshToken);
|
||||
}
|
||||
|
||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||
@@ -196,14 +211,7 @@ public class AuthController(
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refresh,
|
||||
scope = "openid profile",
|
||||
});
|
||||
return TokenOk(access, refresh);
|
||||
}
|
||||
|
||||
[HttpGet("/connect/userinfo")]
|
||||
@@ -307,6 +315,62 @@ public class AuthController(
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpGet("/cachedlogin/current")]
|
||||
public async Task<IActionResult> CurrentCachedLogin()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
|
||||
}
|
||||
|
||||
[HttpPost("/cachedlogin/migrate")]
|
||||
public async Task<IActionResult> MigrateCachedLogin(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null)
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
await playerService.TagPlatformAsync(playerId, platform, platformId);
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
return player is null
|
||||
? Unauthorized()
|
||||
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
|
||||
}
|
||||
|
||||
[HttpPost("/connect/deviceauthorization")]
|
||||
[HttpGet("/connect/deviceauthorization")]
|
||||
public async Task<IActionResult> DeviceAuthorization()
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = $"deviceauthorization:{deviceCode}",
|
||||
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
device_code = deviceCode,
|
||||
user_code = userCode,
|
||||
verification_uri = $"https://{domain.Sub("auth")}/device",
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
|
||||
expires_in = 900,
|
||||
interval = 5,
|
||||
});
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>The boot-time client-version gate the 2020.12 watch hits
|
||||
/// <summary>The boot-time client-version gate the 2023.03.21 client hits
|
||||
/// at startup as <c>GET api/versioncheck/v4?v={build}&p={platform}</c>.
|
||||
///
|
||||
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
|
||||
@@ -20,10 +20,9 @@ namespace DorkNet.Server.Controllers.Auth;
|
||||
/// front door — the watch has dedicated UX for it, no custom client
|
||||
/// mod work needed.</para>
|
||||
///
|
||||
/// <para>The <c>v</c> query param is the raw build identifier the watch
|
||||
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
|
||||
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
|
||||
/// to our internal <see cref="ClientVersion.Key"/>s via the
|
||||
/// <para>The <c>v</c> query param is the raw build identifier supplied
|
||||
/// by the client. We map raw build IDs to our internal
|
||||
/// <see cref="ClientVersion.Key"/>s via the
|
||||
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
|
||||
/// don't recognise OR that maps to a key not in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
|
||||
@@ -49,7 +48,8 @@ public class VersionCheckController(
|
||||
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["20201210"] = "december_2020_12_18",
|
||||
["20230317"] = "march_2023_03_21",
|
||||
["20230321"] = "march_2023_03_21",
|
||||
};
|
||||
|
||||
[HttpGet("/api/versioncheck/v4")]
|
||||
@@ -66,8 +66,7 @@ public class VersionCheckController(
|
||||
// Map raw build ID → ClientVersion key via config. Format:
|
||||
// "DorkNet": {
|
||||
// "BuildIdToVersionKey": {
|
||||
// "20201210": "december_2020_12_18",
|
||||
// "20200310": "march_2020_03_10"
|
||||
// "20230321": "march_2023_03_21"
|
||||
// }
|
||||
// }
|
||||
// Mapping ABSENT → unknown build → UpdateRequired.
|
||||
|
||||
@@ -71,6 +71,38 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("/econ/customAvatarItems")]
|
||||
[HttpGet("/econ/customAvatarItems/v1/owned")]
|
||||
public async Task<IActionResult> GetOwnedCustomAvatarItems()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var rows = await db.CustomAvatarItemOwnership
|
||||
.Where(o => o.PlayerId == pid)
|
||||
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(i => new
|
||||
{
|
||||
CustomAvatarItemId = i.PublicId,
|
||||
Id = i.PublicId,
|
||||
CreatorPlayerId = (int)i.CreatorPlayerId,
|
||||
i.Name,
|
||||
i.Description,
|
||||
i.Price,
|
||||
i.ItemType,
|
||||
i.ImageName,
|
||||
i.AssetName,
|
||||
i.Color,
|
||||
}));
|
||||
}
|
||||
|
||||
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
|
||||
public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new
|
||||
{
|
||||
Limit = 1000,
|
||||
ItemOwnershipLimit = 1000,
|
||||
});
|
||||
}
|
||||
|
||||
public class TokenBalance
|
||||
|
||||
Reference in New Issue
Block a user