Server digest support for LBP 1 and LBP 3

This commit is contained in:
Michael VanOverbeek 2021-10-29 03:15:25 -04:00
commit 90201150cc
13 changed files with 368 additions and 146 deletions

View file

@ -0,0 +1 @@
ProjectLighthouse

View file

@ -2,36 +2,44 @@ using System.Diagnostics.CodeAnalysis;
using LBPUnion.ProjectLighthouse.Types.Settings; using LBPUnion.ProjectLighthouse.Types.Settings;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/")] [Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/plain")] [Produces("text/plain")]
public class ClientConfigurationController : ControllerBase { public class ClientConfigurationController : ControllerBase
{
[HttpGet("network_settings.nws")] [HttpGet("network_settings.nws")]
[SuppressMessage("ReSharper", "StringLiteralTypo")] [SuppressMessage("ReSharper", "StringLiteralTypo")]
public IActionResult NetworkSettings() { public IActionResult NetworkSettings()
return this.Ok("ProbabilityOfPacketDelay 0.0\nMinPacketDelayFrames 0\nMaxPacketDelayFrames 3\nProbabilityOfPacketDrop 0.0\nEnableFakeConditionsForLoopback true\nNumberOfFramesPredictionAllowedForNonLocalPlayer 1000\nEnablePrediction true\nMinPredictedFrames 0\nMaxPredictedFrames 10\nAllowGameRendCameraSplit true\nFramesBeforeAgressiveCatchup 30\nPredictionPadSides 200\nPredictionPadTop 200\nPredictionPadBottom 200\nShowErrorNumbers true\nAllowModeratedLevels true\nAllowModeratedPoppetItems true\nShowLevelBoos true\nTIMEOUT_WAIT_FOR_JOIN_RESPONSE_FROM_PREV_PARTY_HOST 50.0\nTIMEOUT_WAIT_FOR_CHANGE_LEVEL_PARTY_HOST 30.0\nTIMEOUT_WAIT_FOR_CHANGE_LEVEL_PARTY_MEMBER 45.0\nTIMEOUT_WAIT_FOR_REQUEST_JOIN_FRIEND 15.0\nTIMEOUT_WAIT_FOR_CONNECTION_FROM_HOST 30.0\nTIMEOUT_WAIT_FOR_ROOM_ID_TO_JOIN 60.0\nTIMEOUT_WAIT_FOR_GET_NUM_PLAYERS_ONLINE 60.0\nTIMEOUT_WAIT_FOR_SIGNALLING_CONNECTIONS 120.0\nTIMEOUT_WAIT_FOR_PARTY_DATA 60.0\nTIME_TO_WAIT_FOR_LEAVE_MESSAGE_TO_COME_BACK 20.0\nTIME_TO_WAIT_FOR_FOLLOWING_REQUESTS_TO_ARRIVE 30.0\nTIMEOUT_WAIT_FOR_FINISHED_MIGRATING_HOST 30.0\nTIMEOUT_WAIT_FOR_PARTY_LEADER_FINISH_JOINING 45.0\nTIMEOUT_WAIT_FOR_QUICKPLAY_LEVEL 60.0\nTIMEOUT_WAIT_FOR_PLAYERS_TO_JOIN 30.0\nTIMEOUT_WAIT_FOR_DIVE_IN_PLAYERS 120.0\nTIMEOUT_WAIT_FOR_FIND_BEST_ROOM 30.0\nTIMEOUT_DIVE_IN_TOTAL 1000000.0\nTIMEOUT_WAIT_FOR_SOCKET_CONNECTION 120.0\nTIMEOUT_WAIT_FOR_REQUEST_RESOURCE_MESSAGE 120.0\nTIMEOUT_WAIT_FOR_LOCAL_CLIENT_TO_GET_RESOURCE_LIST 120.0\nTIMEOUT_WAIT_FOR_CLIENT_TO_LOAD_RESOURCES 120.0\nTIMEOUT_WAIT_FOR_LOCAL_CLIENT_TO_SAVE_GAME_STATE 30.0\nTIMEOUT_WAIT_FOR_ADD_PLAYERS_TO_TAKE 30.0\nTIMEOUT_WAIT_FOR_UPDATE_FROM_CLIENT 90.0\nTIMEOUT_WAIT_FOR_HOST_TO_GET_RESOURCE_LIST 60.0\nTIMEOUT_WAIT_FOR_HOST_TO_SAVE_GAME_STATE 60.0\nTIMEOUT_WAIT_FOR_HOST_TO_ADD_US 30.0\nTIMEOUT_WAIT_FOR_UPDATE 60.0\nTIMEOUT_WAIT_FOR_REQUEST_JOIN 50.0\nTIMEOUT_WAIT_FOR_AUTOJOIN_PRESENCE 60.0\nTIMEOUT_WAIT_FOR_AUTOJOIN_CONNECTION 120.0\nSECONDS_BETWEEN_PINS_AWARDED_UPLOADS 300.0\nEnableKeepAlive true\nAllowVoIPRecordingPlayback true\nCDNHostName localhost\nTelemetryServer localhost\nOverheatingThresholdDisallowMidgameJoin 0.95\nMaxCatchupFrames 3\nMaxLagBeforeShowLoading 23\nMinLagBeforeHideLoading 30\nLagImprovementInflectionPoint -1.0\nFlickerThreshold 2.0\nClosedDemo2014Version 1\nClosedDemo2014Expired false\nEnablePlayedFilter true\nEnableCommunityDecorations true\nGameStateUpdateRate 10.0\nGameStateUpdateRateWithConsumers 1.0\nDisableDLCPublishCheck false\nEnableDiveIn true\nEnableHackChecks false"); {
return this.Ok(
"ProbabilityOfPacketDelay 0.0\nMinPacketDelayFrames 0\nMaxPacketDelayFrames 3\nProbabilityOfPacketDrop 0.0\nEnableFakeConditionsForLoopback true\nNumberOfFramesPredictionAllowedForNonLocalPlayer 1000\nEnablePrediction true\nMinPredictedFrames 0\nMaxPredictedFrames 10\nAllowGameRendCameraSplit true\nFramesBeforeAgressiveCatchup 30\nPredictionPadSides 200\nPredictionPadTop 200\nPredictionPadBottom 200\nShowErrorNumbers true\nAllowModeratedLevels true\nAllowModeratedPoppetItems true\nShowLevelBoos true\nTIMEOUT_WAIT_FOR_JOIN_RESPONSE_FROM_PREV_PARTY_HOST 50.0\nTIMEOUT_WAIT_FOR_CHANGE_LEVEL_PARTY_HOST 30.0\nTIMEOUT_WAIT_FOR_CHANGE_LEVEL_PARTY_MEMBER 45.0\nTIMEOUT_WAIT_FOR_REQUEST_JOIN_FRIEND 15.0\nTIMEOUT_WAIT_FOR_CONNECTION_FROM_HOST 30.0\nTIMEOUT_WAIT_FOR_ROOM_ID_TO_JOIN 60.0\nTIMEOUT_WAIT_FOR_GET_NUM_PLAYERS_ONLINE 60.0\nTIMEOUT_WAIT_FOR_SIGNALLING_CONNECTIONS 120.0\nTIMEOUT_WAIT_FOR_PARTY_DATA 60.0\nTIME_TO_WAIT_FOR_LEAVE_MESSAGE_TO_COME_BACK 20.0\nTIME_TO_WAIT_FOR_FOLLOWING_REQUESTS_TO_ARRIVE 30.0\nTIMEOUT_WAIT_FOR_FINISHED_MIGRATING_HOST 30.0\nTIMEOUT_WAIT_FOR_PARTY_LEADER_FINISH_JOINING 45.0\nTIMEOUT_WAIT_FOR_QUICKPLAY_LEVEL 60.0\nTIMEOUT_WAIT_FOR_PLAYERS_TO_JOIN 30.0\nTIMEOUT_WAIT_FOR_DIVE_IN_PLAYERS 120.0\nTIMEOUT_WAIT_FOR_FIND_BEST_ROOM 30.0\nTIMEOUT_DIVE_IN_TOTAL 1000000.0\nTIMEOUT_WAIT_FOR_SOCKET_CONNECTION 120.0\nTIMEOUT_WAIT_FOR_REQUEST_RESOURCE_MESSAGE 120.0\nTIMEOUT_WAIT_FOR_LOCAL_CLIENT_TO_GET_RESOURCE_LIST 120.0\nTIMEOUT_WAIT_FOR_CLIENT_TO_LOAD_RESOURCES 120.0\nTIMEOUT_WAIT_FOR_LOCAL_CLIENT_TO_SAVE_GAME_STATE 30.0\nTIMEOUT_WAIT_FOR_ADD_PLAYERS_TO_TAKE 30.0\nTIMEOUT_WAIT_FOR_UPDATE_FROM_CLIENT 90.0\nTIMEOUT_WAIT_FOR_HOST_TO_GET_RESOURCE_LIST 60.0\nTIMEOUT_WAIT_FOR_HOST_TO_SAVE_GAME_STATE 60.0\nTIMEOUT_WAIT_FOR_HOST_TO_ADD_US 30.0\nTIMEOUT_WAIT_FOR_UPDATE 60.0\nTIMEOUT_WAIT_FOR_REQUEST_JOIN 50.0\nTIMEOUT_WAIT_FOR_AUTOJOIN_PRESENCE 60.0\nTIMEOUT_WAIT_FOR_AUTOJOIN_CONNECTION 120.0\nSECONDS_BETWEEN_PINS_AWARDED_UPLOADS 300.0\nEnableKeepAlive true\nAllowVoIPRecordingPlayback true\nCDNHostName localhost\nTelemetryServer localhost\nOverheatingThresholdDisallowMidgameJoin 0.95\nMaxCatchupFrames 3\nMaxLagBeforeShowLoading 23\nMinLagBeforeHideLoading 30\nLagImprovementInflectionPoint -1.0\nFlickerThreshold 2.0\nClosedDemo2014Version 1\nClosedDemo2014Expired false\nEnablePlayedFilter true\nEnableCommunityDecorations true\nGameStateUpdateRate 10.0\nGameStateUpdateRateWithConsumers 1.0\nDisableDLCPublishCheck false\nEnableDiveIn true\nEnableHackChecks false");
} }
[HttpGet("t_conf")] [HttpGet("t_conf")]
[Produces("text/json")] [Produces("text/json")]
public IActionResult Conf() { public IActionResult Conf()
{
return this.Ok("[{\"StatusCode\":200}]"); return this.Ok("[{\"StatusCode\":200}]");
} }
[HttpGet("farc_hashes")] [HttpGet("farc_hashes")]
public IActionResult FarcHashes() { public IActionResult FarcHashes()
{
return this.Ok(); return this.Ok();
} }
[HttpGet("privacySettings")] [HttpGet("privacySettings")]
[Produces("text/xml")] [Produces("text/xml")]
public IActionResult PrivacySettings() { public IActionResult PrivacySettings()
PrivacySettings ps = new() { {
PrivacySettings ps = new()
{
LevelVisibility = "all", LevelVisibility = "all",
ProfileVisibility = "all", ProfileVisibility = "all",
}; };
return this.Ok(ps.Serialize()); return this.Ok(ps.Serialize());
} }
} }

View file

@ -5,34 +5,41 @@ using LBPUnion.ProjectLighthouse.Types;
using LBPUnion.ProjectLighthouse.Types.Settings; using LBPUnion.ProjectLighthouse.Types.Settings;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/login")] [Route("LITTLEBIGPLANETPS3_XML/login")]
[Produces("text/xml")] [Produces("text/xml")]
public class LoginController : ControllerBase { public class LoginController : ControllerBase
{
private readonly Database database; private readonly Database database;
public LoginController(Database database) { public LoginController(Database database)
{
this.database = database; this.database = database;
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Login() { public async Task<IActionResult> Login()
{
string body = await new StreamReader(this.Request.Body).ReadToEndAsync(); string body = await new StreamReader(this.Request.Body).ReadToEndAsync();
LoginData loginData; LoginData loginData;
try { try
{
loginData = LoginData.CreateFromString(body); loginData = LoginData.CreateFromString(body);
} }
catch { catch
{
return this.BadRequest(); return this.BadRequest();
} }
Token? token = await this.database.AuthenticateUser(loginData); Token? token = await this.database.AuthenticateUser(loginData);
if(token == null) return this.StatusCode(403, ""); if (token == null) return this.StatusCode(403, "");
return this.Ok(new LoginResult { return this.Ok(new LoginResult
{
AuthTicket = "MM_AUTH=" + token.UserToken, AuthTicket = "MM_AUTH=" + token.UserToken,
LbpEnvVer = ServerSettings.ServerName, LbpEnvVer = ServerSettings.ServerName,
}.Serialize()); }.Serialize());

View file

@ -11,57 +11,70 @@ using LBPUnion.ProjectLighthouse.Types.Profiles;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/")] [Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/xml")] [Produces("text/xml")]
public class MatchController : ControllerBase { public class MatchController : ControllerBase
{
private readonly Database database; private readonly Database database;
public MatchController(Database database) {
public MatchController(Database database)
{
this.database = database; this.database = database;
} }
[HttpPost("match")] [HttpPost("match")]
[Produces("text/json")] [Produces("text/json")]
public async Task<IActionResult> Match() { public async Task<IActionResult> Match()
{
User? user = await this.database.UserFromRequest(this.Request); User? user = await this.database.UserFromRequest(this.Request);
if(user == null) return this.StatusCode(403, ""); if (user == null) return this.StatusCode(403, "");
#region Parse match data #region Parse match data
// Example POST /match: [UpdateMyPlayerData,["Player":"FireGamer9872"]] // Example POST /match: [UpdateMyPlayerData,["Player":"FireGamer9872"]]
string bodyString = await new StreamReader(this.Request.Body).ReadToEndAsync(); string bodyString = await new StreamReader(this.Request.Body).ReadToEndAsync();
if(bodyString.Contains("FindBestRoom")) { if (bodyString.Contains("FindBestRoom"))
return this.Ok("[{\"StatusCode\":200},{\"Players\":[{\"PlayerId\":\"literally1984\",\"matching_res\":0},{\"PlayerId\":\"jvyden\",\"matching_res\":1}],\"Slots\":[[5,0]],\"RoomState\":\"E_ROOM_IN_POD\",\"HostMood\":\"E_MOOD_EVERYONE\",\"LevelCompletionEstimate\":0,\"PassedNoJoinPoint\":0,\"MoveConnected\":false,\"Location\":[\"127.0.0.1\"],\"BuildVersion\":289,\"Language\":1,\"FirstSeenTimestamp\":1427331263756,\"LastSeenTimestamp\":1635112546000,\"GameId\":1,\"NatType\":2,\"Friends\":[],\"Blocked\":[],\"RecentlyLeft\":[],\"FailedJoin\":[]}]"); {
return this.Ok(
"[{\"StatusCode\":200},{\"Players\":[{\"PlayerId\":\"literally1984\",\"matching_res\":0},{\"PlayerId\":\"jvyden\",\"matching_res\":1}],\"Slots\":[[5,0]],\"RoomState\":\"E_ROOM_IN_POD\",\"HostMood\":\"E_MOOD_EVERYONE\",\"LevelCompletionEstimate\":0,\"PassedNoJoinPoint\":0,\"MoveConnected\":false,\"Location\":[\"127.0.0.1\"],\"BuildVersion\":289,\"Language\":1,\"FirstSeenTimestamp\":1427331263756,\"LastSeenTimestamp\":1635112546000,\"GameId\":1,\"NatType\":2,\"Friends\":[],\"Blocked\":[],\"RecentlyLeft\":[],\"FailedJoin\":[]}]");
} }
if(bodyString[0] != '[') return this.BadRequest(); if (bodyString[0] != '[') return this.BadRequest();
IMatchData? matchData; IMatchData? matchData;
try { try
{
matchData = MatchHelper.Deserialize(bodyString); matchData = MatchHelper.Deserialize(bodyString);
} }
catch(Exception e) { catch (Exception e)
{
Logger.Log("Exception while parsing MatchData: " + e); Logger.Log("Exception while parsing MatchData: " + e);
Logger.Log("Data: " + bodyString); Logger.Log("Data: " + bodyString);
return this.BadRequest(); return this.BadRequest();
} }
if(matchData == null) return this.BadRequest(); if (matchData == null) return this.BadRequest();
#endregion #endregion
#region Update LastMatch #region Update LastMatch
LastMatch? lastMatch = await this.database.LastMatches LastMatch? lastMatch = await this.database.LastMatches
.Where(l => l.UserId == user.UserId).FirstOrDefaultAsync(); .Where(l => l.UserId == user.UserId).FirstOrDefaultAsync();
// below makes it not look like trash // below makes it not look like trash
// ReSharper disable once ConvertIfStatementToNullCoalescingExpression // ReSharper disable once ConvertIfStatementToNullCoalescingExpression
if(lastMatch == null) { if (lastMatch == null)
lastMatch = new LastMatch { {
lastMatch = new LastMatch
{
UserId = user.UserId, UserId = user.UserId,
}; };
this.database.LastMatches.Add(lastMatch); this.database.LastMatches.Add(lastMatch);
@ -70,14 +83,16 @@ namespace LBPUnion.ProjectLighthouse.Controllers {
lastMatch.Timestamp = TimestampHelper.Timestamp; lastMatch.Timestamp = TimestampHelper.Timestamp;
await this.database.SaveChangesAsync(); await this.database.SaveChangesAsync();
#endregion #endregion
return this.Ok("[{\"StatusCode\":200}]"); return this.Ok("[{\"StatusCode\":200}]");
} }
[HttpGet("playersInPodCount")] [HttpGet("playersInPodCount")]
[HttpGet("totalPlayerCount")] [HttpGet("totalPlayerCount")]
public async Task<IActionResult> TotalPlayerCount() { public async Task<IActionResult> TotalPlayerCount()
{
int recentMatches = await this.database.LastMatches int recentMatches = await this.database.LastMatches
.Where(l => TimestampHelper.Timestamp - l.Timestamp < 60) .Where(l => TimestampHelper.Timestamp - l.Timestamp < 60)
.CountAsync(); .CountAsync();

View file

@ -3,38 +3,48 @@ using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Types; using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/")] [Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/plain")] [Produces("text/plain")]
public class MessageController : ControllerBase { public class MessageController : ControllerBase
{
private readonly Database database; private readonly Database database;
public MessageController(Database database) {
public MessageController(Database database)
{
this.database = database; this.database = database;
} }
[HttpGet("eula")] [HttpGet("eula")]
public async Task<IActionResult> Eula() { public async Task<IActionResult> Eula()
{
User user = await this.database.UserFromRequest(this.Request); User user = await this.database.UserFromRequest(this.Request);
return user == null ? this.StatusCode(403, "") : return user == null
this.Ok($"You are now logged in as user {user.Username} (id {user.UserId}).\n" + ? this.Ok("You aren't logged in, but you're connected to a private LBP server.")
"This is a private testing instance. Please do not make anything public for now, and keep in mind security isn't as tight as a full release would."); : this.Ok($"You are now logged in as user {user.Username} (id {user.UserId}).\n" +
"This is a private testing instance. Please do not make anything public for now, and keep in mind security isn't as tight as a full release would.");
} }
[HttpGet("announce")] [HttpGet("announce")]
public IActionResult Announce() { public IActionResult Announce()
{
return this.Ok(""); return this.Ok("");
} }
[HttpGet("notification")] [HttpGet("notification")]
public IActionResult Notification() { public IActionResult Notification()
{
return this.Ok(); return this.Ok();
} }
/// <summary> /// <summary>
/// Filters chat messages sent by a user. /// Filters chat messages sent by a user.
/// </summary> /// </summary>
[HttpPost("filter")] [HttpPost("filter")]
public async Task<IActionResult> Filter() { public async Task<IActionResult> Filter()
{
return this.Ok(await new StreamReader(this.Request.Body).ReadToEndAsync()); return this.Ok(await new StreamReader(this.Request.Body).ReadToEndAsync());
} }
} }

View file

@ -1,18 +1,30 @@
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Serialization; using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Types.News; using LBPUnion.ProjectLighthouse.Types.News;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/news")] [Route("LITTLEBIGPLANETPS3_XML/news")]
[Produces("text/xml")] [Produces("text/xml")]
public class NewsController : ControllerBase { public class NewsController : ControllerBase
{
[HttpGet("/developer_videos")]
public async Task<IActionResult> DeveloperVideos()
{
return Ok();
}
[HttpGet] [HttpGet]
public IActionResult Get() { public IActionResult Get()
string newsEntry = LbpSerializer.StringElement("item", new NewsEntry { {
string newsEntry = LbpSerializer.StringElement("item", new NewsEntry
{
Category = "no_category", Category = "no_category",
Summary = "test summary", Summary = "test summary",
Image = new NewsImage { Image = new NewsImage
{
Hash = "4947269c5f7061b27225611ee58a9a91a8031bbe", Hash = "4947269c5f7061b27225611ee58a9a91a8031bbe",
Alignment = "right", Alignment = "right",
}, },
@ -21,7 +33,7 @@ namespace LBPUnion.ProjectLighthouse.Controllers {
Text = "Test Text", Text = "Test Text",
Date = 1348755214000, Date = 1348755214000,
}.Serialize()); }.Serialize());
return this.Ok(LbpSerializer.StringElement("news", newsEntry)); return this.Ok(LbpSerializer.StringElement("news", newsEntry));
} }
} }

View file

@ -10,60 +10,68 @@ using LBPUnion.ProjectLighthouse.Types.Files;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using IOFile = System.IO.File; using IOFile = System.IO.File;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/")] [Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/xml")] [Produces("text/xml")]
public class ResourcesController : ControllerBase { public class ResourcesController : ControllerBase
{
[HttpPost("showModerated")] [HttpPost("showModerated")]
public IActionResult ShowModerated() { public IActionResult ShowModerated()
{
return this.Ok(LbpSerializer.BlankElement("resources")); return this.Ok(LbpSerializer.BlankElement("resources"));
} }
[HttpPost("filterResources")] [HttpPost("filterResources")]
[HttpPost("showNotUploaded")] [HttpPost("showNotUploaded")]
public async Task<IActionResult> FilterResources() { public async Task<IActionResult> FilterResources()
string bodyString = await new StreamReader(this.Request.Body).ReadToEndAsync(); {
string bodyString = await new StreamReader(this.Request.Body).ReadToEndAsync();
XmlSerializer serializer = new(typeof(ResourceList));
ResourceList resourceList = (ResourceList)serializer.Deserialize(new StringReader(bodyString));
if(resourceList == null) return this.BadRequest(); XmlSerializer serializer = new(typeof(ResourceList));
ResourceList resourceList = (ResourceList) serializer.Deserialize(new StringReader(bodyString));
if (resourceList == null) return this.BadRequest();
string resources = resourceList.Resources string resources = resourceList.Resources
.Where(s => !FileHelper.ResourceExists(s)) .Where(s => !FileHelper.ResourceExists(s))
.Aggregate("", (current, hash) => .Aggregate("", (current, hash) =>
current + LbpSerializer.StringElement("resource", hash)); current + LbpSerializer.StringElement("resource", hash));
return this.Ok(LbpSerializer.StringElement("resources", resources)); return this.Ok(LbpSerializer.StringElement("resources", resources));
} }
[HttpGet("r/{hash}")] [HttpGet("r/{hash}")]
public IActionResult GetResource(string hash) { public IActionResult GetResource(string hash)
{
string path = FileHelper.GetResourcePath(hash); string path = FileHelper.GetResourcePath(hash);
if(FileHelper.ResourceExists(hash)) { if (FileHelper.ResourceExists(hash))
{
return this.File(IOFile.OpenRead(path), "application/octet-stream"); return this.File(IOFile.OpenRead(path), "application/octet-stream");
} }
return this.NotFound(); return this.NotFound();
} }
// TODO: check if this is a valid hash // TODO: check if this is a valid hash
[HttpPost("upload/{hash}")] [HttpPost("upload/{hash}")]
[AllowSynchronousIo] [AllowSynchronousIo]
public async Task<IActionResult> UploadResource(string hash) { public async Task<IActionResult> UploadResource(string hash)
{
string assetsDirectory = FileHelper.ResourcePath; string assetsDirectory = FileHelper.ResourcePath;
string path = FileHelper.GetResourcePath(hash); string path = FileHelper.GetResourcePath(hash);
FileHelper.EnsureDirectoryCreated(assetsDirectory); FileHelper.EnsureDirectoryCreated(assetsDirectory);
if(FileHelper.ResourceExists(hash)) this.Ok(); // no reason to fail if it's already uploaded if (FileHelper.ResourceExists(hash)) this.Ok(); // no reason to fail if it's already uploaded
Logger.Log($"Processing resource upload (hash: {hash})"); Logger.Log($"Processing resource upload (hash: {hash})");
LbpFile file = new(await BinaryHelper.ReadFromPipeReader(Request.BodyReader)); LbpFile file = new(await BinaryHelper.ReadFromPipeReader(Request.BodyReader));
if(!FileHelper.IsFileSafe(file)) return this.UnprocessableEntity(); if (!FileHelper.IsFileSafe(file)) return this.UnprocessableEntity();
await IOFile.WriteAllBytesAsync(path, file.Data); await IOFile.WriteAllBytesAsync(path, file.Data);
return this.Ok(); return this.Ok();
} }

View file

@ -8,26 +8,37 @@ using LBPUnion.ProjectLighthouse.Types.Profiles;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Controllers { namespace LBPUnion.ProjectLighthouse.Controllers
{
[ApiController] [ApiController]
[Route("LITTLEBIGPLANETPS3_XML/")] [Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/xml")] [Produces("text/xml")]
public class UserController : ControllerBase { public class UserController : ControllerBase
{
private readonly Database database; private readonly Database database;
public UserController(Database database) {
public UserController(Database database)
{
this.database = database; this.database = database;
} }
[HttpGet("user/{username}")] [HttpGet("user/{username}")]
public async Task<IActionResult> GetUser(string username) { public async Task<IActionResult> GetUser(string username)
{
User user = await this.database.Users User user = await this.database.Users
.Include(u => u.Location) .Include(u => u.Location)
.FirstOrDefaultAsync(u => u.Username == username); .FirstOrDefaultAsync(u => u.Username == username);
if(user == null) return this.NotFound(); if (user == null) return this.NotFound();
return this.Ok(user.Serialize()); return this.Ok(user.Serialize());
} }
[HttpGet("user/{username}/playlists")]
public async Task<IActionResult> GetUserPlaylists(string username)
{
return this.Ok();
}
// [HttpPost("user/{username}")] // [HttpPost("user/{username}")]
// public async Task<IActionResult> CreateUser(string username) { // public async Task<IActionResult> CreateUser(string username) {
// await new Database().CreateUser(username); // await new Database().CreateUser(username);
@ -35,12 +46,14 @@ namespace LBPUnion.ProjectLighthouse.Controllers {
// } // }
[HttpPost("updateUser")] [HttpPost("updateUser")]
public async Task<IActionResult> UpdateUser() { public async Task<IActionResult> UpdateUser()
{
User user = await this.database.UserFromRequest(this.Request); User user = await this.database.UserFromRequest(this.Request);
if(user == null) return this.StatusCode(403, ""); if (user == null) return this.StatusCode(403, "");
XmlReaderSettings settings = new() { XmlReaderSettings settings = new()
{
Async = true, // this is apparently not default Async = true, // this is apparently not default
}; };
@ -62,39 +75,57 @@ namespace LBPUnion.ProjectLighthouse.Controllers {
// </updateUser> // </updateUser>
// //
// if you find a way to make it not stupid feel free to replace this // if you find a way to make it not stupid feel free to replace this
using(XmlReader reader = XmlReader.Create(this.Request.Body, settings)) { using (XmlReader reader = XmlReader.Create(this.Request.Body, settings))
List<string> path = new(); // you can think of this as a file path in the XML, like <updateUser> -> <location> -> <x> {
while(await reader.ReadAsync()) { List<string>
path = new(); // you can think of this as a file path in the XML, like <updateUser> -> <location> -> <x>
while (await reader.ReadAsync())
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch(reader.NodeType) { switch (reader.NodeType)
{
case XmlNodeType.Element: case XmlNodeType.Element:
path.Add(reader.Name); path.Add(reader.Name);
break; break;
case XmlNodeType.Text: case XmlNodeType.Text:
switch(path[1]) { switch (path[1])
case "biography": { {
case "biography":
{
user.Biography = await reader.GetValueAsync(); user.Biography = await reader.GetValueAsync();
break; break;
} }
case "location": { case "location":
locationChanged = true; // if we're here then we're probably about to change the location. {
locationChanged =
true; // if we're here then we're probably about to change the location.
// ReSharper disable once ConvertIfStatementToSwitchStatement // ReSharper disable once ConvertIfStatementToSwitchStatement
if(path[2] == "x") { if (path[2] == "x")
user.Location.X = Convert.ToInt32(await reader.GetValueAsync()); // GetValue only returns a string, i guess we just hope its a number lol {
} else if(path[2] == "y") { user.Location.X =
Convert.ToInt32(
await reader
.GetValueAsync()); // GetValue only returns a string, i guess we just hope its a number lol
}
else if (path[2] == "y")
{
user.Location.Y = Convert.ToInt32(await reader.GetValueAsync()); user.Location.Y = Convert.ToInt32(await reader.GetValueAsync());
} }
break; break;
} }
case "icon": { case "icon":
{
user.IconHash = await reader.GetValueAsync(); user.IconHash = await reader.GetValueAsync();
break; break;
} }
case "planets": { case "planets":
{
user.PlanetHash = await reader.GetValueAsync(); user.PlanetHash = await reader.GetValueAsync();
break; break;
} }
} }
break; break;
case XmlNodeType.EndElement: case XmlNodeType.EndElement:
path.RemoveAt(path.Count - 1); path.RemoveAt(path.Count - 1);
@ -102,19 +133,23 @@ namespace LBPUnion.ProjectLighthouse.Controllers {
} }
} }
} }
// the way location on a user card works is stupid and will not save with the way below as-is, so we do the following: // the way location on a user card works is stupid and will not save with the way below as-is, so we do the following:
if(locationChanged) { // only modify the database if we modify here if (locationChanged)
Location l = await this.database.Locations.Where(l => l.Id == user.LocationId).FirstOrDefaultAsync(); // find the location in the database again {
// only modify the database if we modify here
Location l = await this.database.Locations.Where(l => l.Id == user.LocationId)
.FirstOrDefaultAsync(); // find the location in the database again
// set the location in the database to the one we modified above // set the location in the database to the one we modified above
l.X = user.Location.X; l.X = user.Location.X;
l.Y = user.Location.Y; l.Y = user.Location.Y;
// now both are in sync, and will update in the database. // now both are in sync, and will update in the database.
} }
if(this.database.ChangeTracker.HasChanges()) await this.database.SaveChangesAsync(); // save the user to the database if we changed anything if (this.database.ChangeTracker.HasChanges())
await this.database.SaveChangesAsync(); // save the user to the database if we changed anything
return this.Ok(); return this.Ok();
} }
} }

View file

@ -8,8 +8,10 @@ using LBPUnion.ProjectLighthouse.Types.Settings;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse { namespace LBPUnion.ProjectLighthouse
public class Database : DbContext { {
public class Database : DbContext
{
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<Location> Locations { get; set; } public DbSet<Location> Locations { get; set; }
public DbSet<Slot> Slots { get; set; } public DbSet<Slot> Slots { get; set; }
@ -26,16 +28,18 @@ namespace LBPUnion.ProjectLighthouse {
MySqlServerVersion.LatestSupportedServerVersion MySqlServerVersion.LatestSupportedServerVersion
); );
public async Task<User> CreateUser(string username) { public async Task<User> CreateUser(string username)
{
User user; User user;
if((user = await this.Users.Where(u => u.Username == username).FirstOrDefaultAsync()) != null) if ((user = await this.Users.Where(u => u.Username == username).FirstOrDefaultAsync()) != null)
return user; return user;
Location l = new(); // store to get id after submitting Location l = new(); // store to get id after submitting
this.Locations.Add(l); // add to table this.Locations.Add(l); // add to table
await this.SaveChangesAsync(); // saving to the database returns the id and sets it on this entity await this.SaveChangesAsync(); // saving to the database returns the id and sets it on this entity
user = new User { user = new User
{
Username = username, Username = username,
LocationId = l.Id, LocationId = l.Id,
Biography = username + " hasn't introduced themselves yet.", Biography = username + " hasn't introduced themselves yet.",
@ -46,14 +50,16 @@ namespace LBPUnion.ProjectLighthouse {
return user; return user;
} }
#nullable enable #nullable enable
public async Task<Token?> AuthenticateUser(LoginData loginData) { public async Task<Token?> AuthenticateUser(LoginData loginData)
{
// TODO: don't use psn name to authenticate // TODO: don't use psn name to authenticate
User user = await this.Users.FirstOrDefaultAsync(u => u.Username == loginData.Username) User user = await this.Users.FirstOrDefaultAsync(u => u.Username == loginData.Username)
?? await this.CreateUser(loginData.Username); ?? await this.CreateUser(loginData.Username);
Token token = new() { Token token = new()
{
UserToken = HashHelper.GenerateAuthToken(), UserToken = HashHelper.GenerateAuthToken(),
UserId = user.UserId, UserId = user.UserId,
}; };
@ -64,21 +70,24 @@ namespace LBPUnion.ProjectLighthouse {
return token; return token;
} }
public async Task<User?> UserFromAuthToken(string authToken) { public async Task<User?> UserFromAuthToken(string authToken)
{
Token? token = await this.Tokens.FirstOrDefaultAsync(t => t.UserToken == authToken); Token? token = await this.Tokens.FirstOrDefaultAsync(t => t.UserToken == authToken);
if(token == null) return null; if (token == null) return null;
return await this.Users return await this.Users
.Include(u => u.Location) .Include(u => u.Location)
.FirstOrDefaultAsync(u => u.UserId == token.UserId); .FirstOrDefaultAsync(u => u.UserId == token.UserId);
} }
public async Task<User?> UserFromRequest(HttpRequest request) { public async Task<User?> UserFromRequest(HttpRequest request)
if(!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth) || mmAuth == null) { {
if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth) || mmAuth == null)
{
return null; return null;
} }
return await this.UserFromAuthToken(mmAuth); return await this.UserFromAuthToken(mmAuth);
} }
#nullable disable #nullable disable
} }
} }

View file

@ -0,0 +1,39 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace LBPUnion.ProjectLighthouse
{
public static class DigestUtils
{
public static async Task<string> ComputeDigest(string path, string authCookie, Stream body,
string digestKey)
{
var memoryStream = new MemoryStream();
var pathBytes = Encoding.UTF8.GetBytes(path);
var cookieBytes = string.IsNullOrEmpty(authCookie)
? Array.Empty<byte>()
: Encoding.UTF8.GetBytes(authCookie);
var keyBytes = Encoding.UTF8.GetBytes(digestKey);
await body.CopyToAsync(memoryStream);
var bodyBytes = memoryStream.ToArray();
using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
sha1.AppendData(bodyBytes);
if (cookieBytes.Length > 0)
sha1.AppendData(cookieBytes);
sha1.AppendData(pathBytes);
sha1.AppendData(keyBytes);
var digestBytes = sha1.GetHashAndReset();
var digestString = Convert.ToHexString(digestBytes).ToLower();
return digestString;
}
}
}

View file

@ -7,7 +7,9 @@ namespace LBPUnion.ProjectLighthouse.Logging {
public class LighthouseFileLogger : LoggerBase { public class LighthouseFileLogger : LoggerBase {
private static readonly string logsDirectory = Path.Combine(Environment.CurrentDirectory, "logs"); private static readonly string logsDirectory = Path.Combine(Environment.CurrentDirectory, "logs");
public override void Send(LoggerLine line) { public override void Send(LoggerLine line)
{
return;
FileHelper.EnsureDirectoryCreated(logsDirectory); FileHelper.EnsureDirectoryCreated(logsDirectory);
File.AppendAllText(Path.Combine(logsDirectory, line.LoggerLevel + ".log"), line.LineData + "\n"); File.AppendAllText(Path.Combine(logsDirectory, line.LoggerLevel + ".log"), line.LineData + "\n");

View file

@ -10,51 +10,59 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LBPUnion.ProjectLighthouse { namespace LBPUnion.ProjectLighthouse
public static class Program { {
public static void Main(string[] args) { public static class Program
{
public static void Main(string[] args)
{
// Log startup time // Log startup time
Stopwatch startupStopwatch = new(); Stopwatch startupStopwatch = new();
startupStopwatch.Start(); startupStopwatch.Start();
// Setup logging // Setup logging
Logger.StartLogging(); Logger.StartLogging();
LoggerLine.LogFormat = "[{0}] {1}"; LoggerLine.LogFormat = "[{0}] {1}";
Logger.AddLogger(new ConsoleLogger()); Logger.AddLogger(new ConsoleLogger());
Logger.AddLogger(new LighthouseFileLogger()); Logger.AddLogger(new LighthouseFileLogger());
Logger.Log("Welcome to Project Lighthouse!", LoggerLevelStartup.Instance); Logger.Log("Welcome to Project Lighthouse!", LoggerLevelStartup.Instance);
Logger.Log("Determining if the database is available...", LoggerLevelStartup.Instance); Logger.Log("Determining if the database is available...", LoggerLevelStartup.Instance);
bool dbConnected = ServerSettings.DbConnected; bool dbConnected = ServerSettings.DbConnected;
Logger.Log(dbConnected ? "Connected to the database." : "Database unavailable! Exiting.", LoggerLevelStartup.Instance); Logger.Log(dbConnected ? "Connected to the database." : "Database unavailable! Exiting.",
LoggerLevelStartup.Instance);
if(dbConnected) { if (dbConnected)
{
Stopwatch migrationStopwatch = new(); Stopwatch migrationStopwatch = new();
migrationStopwatch.Start(); migrationStopwatch.Start();
Logger.Log("Migrating database...", LoggerLevelDatabase.Instance); Logger.Log("Migrating database...", LoggerLevelDatabase.Instance);
using Database database = new(); using Database database = new();
database.Database.Migrate(); database.Database.Migrate();
migrationStopwatch.Stop(); migrationStopwatch.Stop();
Logger.Log($"Migration took {migrationStopwatch.ElapsedMilliseconds}ms.", LoggerLevelDatabase.Instance); Logger.Log($"Migration took {migrationStopwatch.ElapsedMilliseconds}ms.", LoggerLevelDatabase.Instance);
} else Environment.Exit(1); }
else Environment.Exit(1);
startupStopwatch.Stop(); startupStopwatch.Stop();
Logger.Log($"Ready! Startup took {startupStopwatch.ElapsedMilliseconds}ms. Passing off control to ASP.NET...", LoggerLevelStartup.Instance); Logger.Log(
$"Ready! Startup took {startupStopwatch.ElapsedMilliseconds}ms. Passing off control to ASP.NET...",
LoggerLevelStartup.Instance);
CreateHostBuilder(args).Build().Run(); CreateHostBuilder(args).Build().Run();
} }
public static IHostBuilder CreateHostBuilder(string[] args) => public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args) Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); })
webBuilder.UseStartup<Startup>(); .ConfigureLogging(logging =>
}) {
.ConfigureLogging(logging => {
logging.ClearProviders(); logging.ClearProviders();
logging.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, AspNetToKettuLoggerProvider>()); logging.Services.TryAddEnumerable(ServiceDescriptor
.Singleton<ILoggerProvider, AspNetToKettuLoggerProvider>());
}); });
} }
} }

View file

@ -1,5 +1,8 @@
using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection.Metadata.Ecma335;
using System.Threading.Tasks;
using Kettu; using Kettu;
using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Serialization; using LBPUnion.ProjectLighthouse.Serialization;
@ -9,48 +12,115 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LBPUnion.ProjectLighthouse { namespace LBPUnion.ProjectLighthouse
public class Startup { {
public Startup(IConfiguration configuration) { public class Startup
{
public Startup(IConfiguration configuration)
{
this.Configuration = configuration; this.Configuration = configuration;
} }
public IConfiguration Configuration { get; } public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container. // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) { public void ConfigureServices(IServiceCollection services)
{
services.AddControllers(); services.AddControllers();
services.AddMvc(options => services.AddMvc(options =>
options.OutputFormatters.Add(new XmlOutputFormatter())); options.OutputFormatters.Add(new XmlOutputFormatter()));
services.AddDbContext<Database>(); services.AddDbContext<Database>();
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
if(env.IsDevelopment()) { {
var computeDigests = true;
var serverDigestKey = Environment.GetEnvironmentVariable("SERVER_DIGEST_KEY");
if (string.IsNullOrWhiteSpace(serverDigestKey))
{
Logger.Log(
"SERVER_DIGEST_KEY environment variable wasn't set, so server digest headers won't be set. This will break LBP 1 and LBP 3."
);
computeDigests = false;
}
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
} }
// Logs every request and the response to it // Logs every request and the response to it
// Example: "200, 13ms: GET /LITTLEBIGPLANETPS3_XML/news" // Example: "200, 13ms: GET /LITTLEBIGPLANETPS3_XML/news"
// Example: "404, 127ms: GET /asdasd?query=osucookiezi727ppbluezenithtopplayhdhr" // Example: "404, 127ms: GET /asdasd?query=osucookiezi727ppbluezenithtopplayhdhr"
app.Use(async (context, next) => { app.Use(async (context, next) =>
{
Stopwatch requestStopwatch = new(); Stopwatch requestStopwatch = new();
requestStopwatch.Start(); requestStopwatch.Start();
context.Request.EnableBuffering(); // Allows us to reset the position of Request.Body for later logging context.Request.EnableBuffering(); // Allows us to reset the position of Request.Body for later logging
// Client digest check.
var authCookie = null as string;
if (!context.Request.Cookies.TryGetValue("MM_AUTH", out authCookie))
authCookie = string.Empty;
if (context.Request.Headers.TryGetValue("X-Digest-A", out var clientDigest))
{
var digestPath = context.Request.Path;
var body = context.Request.Body;
var digest = await DigestUtils.ComputeDigest(digestPath, authCookie, body, serverDigestKey);
if (digest != clientDigest)
{
Logger.Log($"Client digest {clientDigest} does not match server digest {digest}.");
context.Abort();
return;
}
else
{
context.Response.Headers.Add("X-Digest-B", digest);
context.Request.Body.Position = 0;
}
}
// This does the same as above, but for the response stream.
using var responseBuffer = new MemoryStream();
var oldResponseStream = context.Response.Body;
context.Response.Body = responseBuffer;
await next(); // Handle the request so we can get the status code from it await next(); // Handle the request so we can get the status code from it
// Compute the server digest hash.
if (computeDigests && context.Request.Headers.TryGetValue("X-Digest-A", out var a))
{
responseBuffer.Position = 0;
// Compute the digest for the response.
var serverDigest = await DigestUtils.ComputeDigest(context.Request.Path, authCookie,
responseBuffer, serverDigestKey);
context.Response.Headers.Add("X-Digest-A", serverDigest);
}
// Copy the buffered response to the actual respose stream.
responseBuffer.Position = 0;
await responseBuffer.CopyToAsync(oldResponseStream);
context.Response.Body = oldResponseStream;
requestStopwatch.Stop(); requestStopwatch.Stop();
Logger.Log( Logger.Log(
$"{context.Response.StatusCode}, {requestStopwatch.ElapsedMilliseconds}ms: {context.Request.Method} {context.Request.Path}{context.Request.QueryString}", $"{context.Response.StatusCode}, {requestStopwatch.ElapsedMilliseconds}ms: {context.Request.Method} {context.Request.Path}{context.Request.QueryString}",
LoggerLevelHttp.Instance LoggerLevelHttp.Instance
); );
if(context.Request.Method == "POST") { if (context.Request.Method == "POST")
{
context.Request.Body.Position = 0; context.Request.Body.Position = 0;
Logger.Log(await new StreamReader(context.Request.Body).ReadToEndAsync(), LoggerLevelHttp.Instance); Logger.Log(await new StreamReader(context.Request.Body).ReadToEndAsync(), LoggerLevelHttp.Instance);
} }
@ -58,9 +128,7 @@ namespace LBPUnion.ProjectLighthouse {
app.UseRouting(); app.UseRouting();
app.UseEndpoints(endpoints => { app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
endpoints.MapControllers();
});
} }
} }
} }