Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ public class ApiAuthenticationError : ApiError

public const string NoPermissionsForCreationWhen = "You lack the permissions to create this type of resource.";
public static readonly ApiAuthenticationError NoPermissionsForCreation = new(NoPermissionsForCreationWhen);


public const string NotAuthenticatedWhen = "You are not authenticated.";
public static readonly ApiAuthenticationError NotAuthenticated = new(NotAuthenticatedWhen);

public bool Warning { get; init; }

public ApiAuthenticationError(string message, bool warning = false) : base(message, Forbidden)
Expand Down
134 changes: 134 additions & 0 deletions Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using AttribDoc.Attributes;
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Responses;
using Bunkum.Listener.Protocol;
using Refresh.Core.Configuration;
using Refresh.Core.Types.Categories;
using Refresh.Core.Types.Data;
using Refresh.Database;
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Levels;
using Refresh.Database.Models.Users;
using Refresh.Database.Query;
using Refresh.Interfaces.APIv3.Documentation.Attributes;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
using Refresh.Interfaces.APIv3.Extensions;

namespace Refresh.Interfaces.APIv3.Endpoints;

public class CategoryApiEndpoints : EndpointGroup
{
[ApiV3Endpoint("levels"), Authentication(false)]
[ClientCacheResponse(1800)] // cache for half an hour
[DocSummary("Retrieves a list of categories you can use to search levels")]
[DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")]
[DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")]
public ApiListResponse<ApiLevelCategoryResponse> GetLevelCategories(RequestContext context, CategoryService categories,
DataContext dataContext)
{
bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews);
if (!result) return ApiValidationError.BooleanParseError;

IEnumerable<ApiLevelCategoryResponse> resp;

// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext);
else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext);

return new ApiListResponse<ApiLevelCategoryResponse>(resp);
}

[ApiV3Endpoint("levels/{route}"), Authentication(false)]
[DocSummary("Retrieves a list of levels from a category")]
[DocError(typeof(ApiNotFoundError), "The level category cannot be found")]
[DocUsesPageData]
[DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")]
[DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")]
[DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")]
[DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " +
"the user with this username instead of your own. Optional.")]
public ApiListResponse<ApiGameLevelResponse> GetLevels(RequestContext context, CategoryService categories, GameUser? user,
[DocSummary("The name of the category you'd like to retrieve levels from. " +
"Make a request to /levels to see a list of available categories")]
string route, DataContext dataContext)
{
if (string.IsNullOrWhiteSpace(route))
{
return new ApiError("You didn't specify a route. " +
"You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound);
}

(int skip, int count) = context.GetPageData();

DatabaseList<GameLevel>? list = categories.LevelCategories
.FirstOrDefault(c => c.ApiRoute.StartsWith(route))?
.Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user);

if (list == null) return ApiNotFoundError.Instance;

DatabaseList<ApiGameLevelResponse> levels = DatabaseListExtensions.FromOldList<ApiGameLevelResponse, GameLevel>(list, dataContext);
return levels;
}

[ApiV3Endpoint("users"), Authentication(false)]
[ClientCacheResponse(1800)] // cache for half an hour
[DocSummary("Retrieves a list of categories you can use to search users. Returns an empty list if the instance doesn't allow showing online users.")]
[DocQueryParam("includePreviews", "If true, a single user will be added to each category representing a user from that category. False by default.")]
[DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")]
public ApiListResponse<ApiUserCategoryResponse> GetUserCategories(RequestContext context, CategoryService categories,
DataContext dataContext, GameServerConfig config)
{
bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews);
if (!result) return ApiValidationError.BooleanParseError;

if (!config.PermitShowingOnlineUsers) return new ApiListResponse<ApiUserCategoryResponse>([]);
IEnumerable<ApiUserCategoryResponse> resp;

if (includePreviews) resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, context, dataContext);
else resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, dataContext);

return new ApiListResponse<ApiUserCategoryResponse>(resp);
}

[ApiV3Endpoint("users/{route}"), Authentication(false)]
[DocSummary("Retrieves a list of users from a category.")]
[DocError(typeof(ApiNotFoundError), "The user category cannot be found, or the instance does not allow showing online users.")]
[DocUsesPageData]
[DocQueryParam("username", "If set, certain categories like 'hearted' will return the related users of " +
"the user with this username instead of your own. Optional.")]
public Response GetUsers(RequestContext context, CategoryService categories, GameUser? user,
[DocSummary("The name of the category you'd like to retrieve users from. " +
"Make a request to /users to see a list of available categories")]
string route, DataContext dataContext, GameServerConfig config)
{
// Bunkum usually routes users/me requests to here aswell, so use this hack to serve those requests properly.
if (route == "me")
{
if (user == null) return ApiAuthenticationError.NotAuthenticated; // Error documented in UserApiEndpoints.GetMyUser()
return new Response(new ApiResponse<ApiExtendedGameUserResponse>(ApiExtendedGameUserResponse.FromOld(user, dataContext)!), ContentType.Json);
}

if (string.IsNullOrWhiteSpace(route))
{
return new ApiError("You didn't specify a route. " +
"You probably meant to use the `/users` endpoint and left a trailing slash in the URL.", NotFound);
}

if (!config.PermitShowingOnlineUsers) return ApiNotFoundError.Instance;
(int skip, int count) = context.GetPageData();

DatabaseList<GameUser>? list = categories.UserCategories
.FirstOrDefault(c => c.ApiRoute.StartsWith(route))?
.Fetch(context, skip, count, dataContext, user);

if (list == null) return ApiNotFoundError.Instance;

ApiListResponse<ApiGameUserResponse> users = DatabaseListExtensions.FromOldList<ApiGameUserResponse, GameUser>(list, dataContext);
return new Response(users, ContentType.Json);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Refresh.Core.Types.Categories;
using Refresh.Core.Types.Data;

namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiCategoryResponse : IApiResponse, IDataConvertableFrom<ApiCategoryResponse, GameCategory>
{
public required string Name { get; set; }
public required string Description { get; set; }
public required string IconHash { get; set; }
public required string FontAwesomeIcon { get; set; }
public required string ApiRoute { get; set; }
public required bool RequiresUser { get; set; }
public required bool Hidden { get; set; } = false;

public static ApiCategoryResponse? FromOld(GameCategory? old, DataContext dataContext)
{
if (old == null) return null;

return new ApiCategoryResponse
{
Name = old.Name,
Description = old.Description,
IconHash = old.IconHash,
FontAwesomeIcon = old.FontAwesomeIcon,
ApiRoute = old.ApiRoute,
RequiresUser = old.RequiresUser,
Hidden = old.Hidden,
};
}

public static IEnumerable<ApiCategoryResponse> FromOldList(IEnumerable<GameCategory> oldList, DataContext dataContext)
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,14 @@
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Levels;
using Refresh.Database.Query;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;

namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiLevelCategoryResponse : IApiResponse, IDataConvertableFrom<ApiLevelCategoryResponse, GameLevelCategory>
public class ApiLevelCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom<ApiLevelCategoryResponse, GameLevelCategory>
{
public required string Name { get; set; }
public required string Description { get; set; }
public required string IconHash { get; set; }
public required string FontAwesomeIcon { get; set; }
public required string ApiRoute { get; set; }
public required bool RequiresUser { get; set; }
public required ApiGameLevelResponse? PreviewLevel { get; set; }
public required bool Hidden { get; set; } = false;

public static ApiLevelCategoryResponse? FromOld(GameLevelCategory? old, GameLevel? previewLevel,
DataContext dataContext)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Bunkum.Core;
using Refresh.Core.Types.Categories.Users;
using Refresh.Core.Types.Data;
using Refresh.Database;
using Refresh.Database.Models.Users;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;

namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiUserCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom<ApiUserCategoryResponse, GameUserCategory>
{
public required ApiGameUserResponse? PreviewItem { get; set; }

public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, GameUser? PreviewItem,
DataContext dataContext)
{
if (old == null) return null;

return new ApiUserCategoryResponse
{
Name = old.Name,
Description = old.Description,
IconHash = old.IconHash,
FontAwesomeIcon = old.FontAwesomeIcon,
ApiRoute = old.ApiRoute,
RequiresUser = old.RequiresUser,
PreviewItem = ApiGameUserResponse.FromOld(PreviewItem, dataContext),
Hidden = old.Hidden,
};
}

public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext)
=> FromOld(old, null, dataContext);

public static IEnumerable<ApiUserCategoryResponse> FromOldList(IEnumerable<GameUserCategory> oldList, DataContext dataContext)
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;

public static IEnumerable<ApiUserCategoryResponse> FromOldList(IEnumerable<GameUserCategory> oldList,
RequestContext context,
DataContext dataContext)
{
return oldList.Select(category =>
{
DatabaseList<GameUser>? list = category.Fetch(context, 0, 1, dataContext, dataContext.User);
GameUser? item = list?.Items.FirstOrDefault();

return FromOld(category, item, dataContext);
}).ToList()!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,8 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
/// A user with full information, like current role, ban status, etc.
/// </summary>
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<ApiExtendedGameUserResponse, GameUser>
public class ApiExtendedGameUserResponse : ApiGameUserResponse, IApiResponse, IDataConvertableFrom<ApiExtendedGameUserResponse, GameUser>
{
public required string UserId { get; set; }
public required string Username { get; set; }
public required string IconHash { get; set; }
public required string VitaIconHash { get; set; }
public required string BetaIconHash { get; set; }
public required string Description { get; set; }
public required ApiGameLocationResponse Location { get; set; }
public required DateTimeOffset JoinDate { get; set; }
public required DateTimeOffset LastLoginDate { get; set; }
public required GameUserRole Role { get; set; }

public required string? BanReason { get; set; }
public required DateTimeOffset? BanExpiryDate { get; set; }

Expand All @@ -45,13 +34,10 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
public required Visibility ProfileVisibility { get; set; }

public required int FilesizeQuotaUsage { get; set; }

public required ApiGameUserStatisticsResponse Statistics { get; set; }
public required ApiGameRoomResponse? ActiveRoom { get; set; }
public required bool ConnectedToPresenceServer { get; set; }

[ContractAnnotation("user:null => null; user:notnull => notnull")]
public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
public static new ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
{
if (user == null) return null;

Expand All @@ -62,6 +48,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
IconHash = dataContext.GetIconFromHash(user.IconHash),
VitaIconHash = dataContext.GetIconFromHash(user.VitaIconHash),
BetaIconHash = dataContext.GetIconFromHash(user.BetaIconHash),
YayFaceHash = dataContext.GetIconFromHash(user.YayFaceHash),
BooFaceHash = dataContext.GetIconFromHash(user.BooFaceHash),
MehFaceHash = dataContext.GetIconFromHash(user.MehFaceHash),
Description = user.Description,
Location = ApiGameLocationResponse.FromLocation(user.LocationX, user.LocationY)!,
JoinDate = user.JoinDate,
Expand All @@ -88,6 +77,6 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
};
}

public static IEnumerable<ApiExtendedGameUserResponse> FromOldList(IEnumerable<GameUser> oldList,
public static new IEnumerable<ApiExtendedGameUserResponse> FromOldList(IEnumerable<GameUser> oldList,
DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom<ApiGameUserResponse, GameUser>
{
// HEY! When adding fields here, remember to propagate them in ApiExtendedGameUser too!
// Otherwise, they won't show up in the admin panel endpoints or /users/me. Thank you!

public required string UserId { get; set; }
public required string Username { get; set; }
public required string IconHash { get; set; }
Expand Down
Loading