Skip to content

Commit 941058e

Browse files
authored
Rework API level editing, refactor icon validation (#1040)
- The normal and the admin API endpoint for editing a level now use separate request body classes/interfaces and DB methods - These DB methods no longer use reflection. They now update levels similarly to how most other similar methods do it now (handle every wanted attribute separately). Closes #579 - All API endpoints which need to validate icons now use one helper method for this. Removes duplicate code and inconsistent validation processes this way.
2 parents d0cba58 + fca7ebb commit 941058e

12 files changed

Lines changed: 173 additions & 180 deletions

File tree

Refresh.Database/GameDatabaseContext.Levels.cs

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -212,30 +212,27 @@ public GameLevel UpdateLevel(ISerializedPublishLevel updateInfo, GameLevel level
212212
this.SaveChanges();
213213
return level;
214214
}
215+
216+
public GameLevel? UpdateLevel(IApiAdminEditLevelRequest body, GameLevel level, GameUser? updatingUser)
217+
{
218+
level.Title = body.Title ?? level.Title;
219+
level.IconHash = body.IconHash ?? level.IconHash;
220+
level.Description = body.Description ?? level.Description;
221+
level.GameVersion = body.GameVersion ?? level.GameVersion;
222+
level.IsReUpload = body.IsReUpload ?? level.IsReUpload;
223+
level.OriginalPublisher = body.OriginalPublisher ?? level.OriginalPublisher;
224+
225+
this.ApplyLevelMetadataFromAttributes(level);
226+
this.CreateRevisionForLevel(level, updatingUser);
227+
this.SaveChanges();
228+
return level;
229+
}
215230

216231
public GameLevel? UpdateLevel(IApiEditLevelRequest body, GameLevel level, GameUser? updatingUser)
217232
{
218-
if (body.Title is { Length: > UgcLimits.TitleLimit })
219-
body.Title = body.Title[..UgcLimits.TitleLimit];
220-
221-
if (body.Description is { Length: > UgcLimits.DescriptionLimit })
222-
body.Description = body.Description[..UgcLimits.DescriptionLimit];
223-
224-
PropertyInfo[] userProps = body.GetType().GetProperties();
225-
foreach (PropertyInfo prop in userProps)
226-
{
227-
if (!prop.CanWrite || !prop.CanRead) continue;
228-
229-
object? propValue = prop.GetValue(body);
230-
if(propValue == null) continue;
231-
232-
PropertyInfo? gameLevelProp = level.GetType().GetProperty(prop.Name);
233-
Debug.Assert(gameLevelProp != null, $"Invalid property {prop.Name} on {nameof(IApiEditLevelRequest)}");
234-
235-
gameLevelProp.SetValue(level, prop.GetValue(body));
236-
}
237-
238-
level.UpdateDate = this._time.Now;
233+
level.Title = body.Title ?? level.Title;
234+
level.IconHash = body.IconHash ?? level.IconHash;
235+
level.Description = body.Description ?? level.Description;
239236

240237
this.ApplyLevelMetadataFromAttributes(level);
241238
this.CreateRevisionForLevel(level, updatingUser);

Refresh.Database/Models/Levels/GameLevel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ public partial class GameLevel : ISequentialId
2626
public string RootResource { get; set; } = string.Empty;
2727

2828
/// <summary>
29-
/// When the level was first published in unix milliseconds
29+
/// When the level was first published
3030
/// </summary>
3131
public DateTimeOffset PublishDate { get; set; }
3232
/// <summary>
33-
/// When the level was last updated in unix milliseconds
33+
/// When the level's root resource was last updated
3434
/// </summary>
3535
public DateTimeOffset UpdateDate { get; set; }
3636

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Refresh.Database.Models.Authentication;
2+
using Refresh.Database.Models.Users;
3+
4+
namespace Refresh.Database.Query;
5+
6+
public interface IApiAdminEditLevelRequest
7+
{
8+
string? Title { get; set; }
9+
string? Description { get; set; }
10+
string? IconHash { get; set; }
11+
public TokenGame? GameVersion { get; set; }
12+
public bool? IsReUpload { get; set; }
13+
public string? OriginalPublisher { get; set; }
14+
}

Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Bunkum.Core.Endpoints;
44
using Bunkum.Protocols.Http;
55
using MongoDB.Bson;
6+
using Refresh.Common.Constants;
67
using Refresh.Core.Authentication.Permission;
78
using Refresh.Core.Types.Data;
89
using Refresh.Database;
@@ -12,6 +13,7 @@
1213
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
1314
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
1415
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
16+
using Refresh.Interfaces.APIv3.Extensions;
1517

1618
namespace Refresh.Interfaces.APIv3.Endpoints.Admin;
1719

@@ -53,9 +55,15 @@ public ApiResponse<ApiGameLevelResponse> EditLevelById(RequestContext context, G
5355
GameLevel? level = database.GetLevelById(id);
5456
if (level == null) return ApiNotFoundError.LevelMissingError;
5557

56-
if (body.IconHash != null && body.IconHash.StartsWith('g') &&
57-
!dataContext.GuidChecker.IsTextureGuid(level.GameVersion, long.Parse(body.IconHash)))
58-
return ApiValidationError.InvalidTextureGuidError;
58+
(body.IconHash, ApiError? iconError) = body.IconHash.ValidateIcon(dataContext);
59+
if (iconError != null) return iconError;
60+
61+
// Trim title and description
62+
if (body.Title != null && body.Title.Length > UgcLimits.TitleLimit)
63+
body.Title = body.Title[..UgcLimits.TitleLimit];
64+
65+
if (body.Description != null && body.Description.Length > UgcLimits.DescriptionLimit)
66+
body.Description = body.Description[..UgcLimits.DescriptionLimit];
5967

6068
level = database.UpdateLevel(body, level, user);
6169

Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -157,29 +157,14 @@ public ApiResponse<ApiExtendedGameUserResponse> UpdateUser(RequestContext contex
157157
return ApiValidationError.WrongRoleUpdateMethodError;
158158
}
159159

160-
if (body.IconHash != null)
161-
{
162-
if (body.IconHash.IsBlankHash())
163-
body.IconHash = "0";
164-
else if (database.GetAssetFromHash(body.IconHash) == null)
165-
return ApiNotFoundError.IconMissingError;
166-
}
160+
(body.IconHash, ApiError? mainIconError) = body.IconHash.ValidateIcon(dataContext);
161+
if (mainIconError != null) return mainIconError;
167162

168-
if (body.VitaIconHash != null)
169-
{
170-
if (body.VitaIconHash.IsBlankHash())
171-
body.VitaIconHash = "0";
172-
else if (database.GetAssetFromHash(body.VitaIconHash) == null)
173-
return ApiNotFoundError.IconMissingError;
174-
}
163+
(body.VitaIconHash, ApiError? vitaIconError) = body.VitaIconHash.ValidateIcon(dataContext);
164+
if (vitaIconError != null) return vitaIconError;
175165

176-
if (body.BetaIconHash != null)
177-
{
178-
if (body.BetaIconHash.IsBlankHash())
179-
body.BetaIconHash = "0";
180-
else if (database.GetAssetFromHash(body.BetaIconHash) == null)
181-
return ApiNotFoundError.IconMissingError;
182-
}
166+
(body.BetaIconHash, ApiError? betaIconError) = body.BetaIconHash.ValidateIcon(dataContext);
167+
if (betaIconError != null) return betaIconError;
183168

184169
// Do nothing if the username entered is actually the same as the one already set
185170
if (body.Username != null && body.Username != targetUser.Username)

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Request/ApiAdminEditLevelRequest.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using Refresh.Database.Models.Authentication;
2+
using Refresh.Database.Query;
23

34
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
45

56
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
6-
public class ApiAdminEditLevelRequest : ApiEditLevelRequest
7+
public class ApiAdminEditLevelRequest : IApiAdminEditLevelRequest
78
{
9+
public string? Title { get; set; }
10+
public string? Description { get; set; }
11+
public string? IconHash { get; set; }
812
public TokenGame? GameVersion { get; set; }
913
public bool? IsReUpload { get; set; }
1014
public string? OriginalPublisher { get; set; }

Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
1818
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
1919
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
20+
using Refresh.Interfaces.APIv3.Extensions;
2021

2122
namespace Refresh.Interfaces.APIv3.Endpoints;
2223

@@ -61,9 +62,8 @@ public ApiResponse<ApiGameLevelResponse> EditLevelById(RequestContext context,
6162
if (level.Publisher?.UserId != dataContext.User!.UserId)
6263
return ApiAuthenticationError.NoPermissionsForObject;
6364

64-
if (body.IconHash != null && body.IconHash.StartsWith('g') &&
65-
!dataContext.GuidChecker.IsTextureGuid(level.GameVersion, long.Parse(body.IconHash)))
66-
return ApiValidationError.InvalidTextureGuidError;
65+
(body.IconHash, ApiError? iconError) = body.IconHash.ValidateIcon(dataContext);
66+
if (iconError != null) return iconError;
6767

6868
// Trim title and description
6969
if (body.Title != null && body.Title.Length > UgcLimits.TitleLimit)

Refresh.Interfaces.APIv3/Endpoints/PlaylistApiEndpoints.cs

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,25 @@
44
using Bunkum.Core.RateLimit;
55
using Bunkum.Protocols.Http;
66
using Refresh.Common.Constants;
7-
using Refresh.Core.Services;
87
using Refresh.Core.Types.Data;
9-
using Refresh.Database;
10-
using Refresh.Database.Models.Assets;
11-
using Refresh.Database.Models.Authentication;
128
using Refresh.Database.Models.Levels;
139
using Refresh.Database.Models.Playlists;
1410
using Refresh.Database.Models.Users;
1511
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
1612
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
1713
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
1814
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Playlists;
15+
using Refresh.Interfaces.APIv3.Extensions;
1916
using static Refresh.Core.RateLimits.PlaylistEndpointLimits;
2017

2118
namespace Refresh.Interfaces.APIv3.Endpoints;
2219

2320
public class PlaylistApiEndpoints : EndpointGroup
2421
{
25-
private ApiError? ValidatePlaylist(ApiPlaylistCreationRequest body, GuidCheckerService guidChecker, GameDatabaseContext database)
22+
private ApiError? ValidatePlaylist(ApiPlaylistCreationRequest body, DataContext dataContext)
2623
{
27-
if (body.Icon != null)
28-
{
29-
if (body.Icon.IsBlankHash())
30-
body.Icon = "0";
31-
32-
else if (body.Icon!.StartsWith('g') && body.Icon.Length > 1)
33-
{
34-
bool isGuid = long.TryParse(body.Icon[1..], out long guid);
35-
if (!isGuid || (isGuid && !guidChecker.IsTextureGuid(TokenGame.LittleBigPlanet1, guid)))
36-
return ApiValidationError.InvalidTextureGuidError;
37-
}
38-
else
39-
{
40-
GameAsset? icon = database.GetAssetFromHash(body.Icon);
41-
42-
if (icon == null)
43-
return ApiValidationError.IconMissingError;
44-
45-
if (icon.AssetType is not GameAssetType.Jpeg and not GameAssetType.Png)
46-
return ApiValidationError.IconMustBeImageError;
47-
}
48-
}
24+
(body.Icon, ApiError? iconError) = body.Icon.ValidateIcon(dataContext);
25+
if (iconError != null) return iconError;
4926

5027
// Trim name and description
5128
if (body.Name != null && body.Name.Length > UgcLimits.TitleLimit)
@@ -78,7 +55,7 @@ public class PlaylistApiEndpoints : EndpointGroup
7855
public ApiResponse<ApiGamePlaylistResponse> CreatePlaylist(RequestContext context, DataContext dataContext,
7956
GameUser user, ApiPlaylistCreationRequest body)
8057
{
81-
ApiError? error = this.ValidatePlaylist(body, dataContext.GuidChecker, dataContext.Database);
58+
ApiError? error = this.ValidatePlaylist(body, dataContext);
8259
if (error != null) return error;
8360

8461
string? parentIdStr = context.QueryString.Get("parentId");
@@ -125,7 +102,7 @@ public ApiResponse<ApiGamePlaylistResponse> UpdatePlaylist(RequestContext contex
125102
if (user.UserId != playlist.PublisherId)
126103
return ApiValidationError.NoPlaylistEditPermissionError;
127104

128-
ApiError? error = this.ValidatePlaylist(body, dataContext.GuidChecker, dataContext.Database);
105+
ApiError? error = this.ValidatePlaylist(body, dataContext);
129106
if (error != null) return error;
130107

131108
playlist = dataContext.Database.UpdatePlaylist(playlist, body);

Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
1818
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
1919
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
20+
using Refresh.Interfaces.APIv3.Extensions;
2021

2122
namespace Refresh.Interfaces.APIv3.Endpoints;
2223

@@ -86,43 +87,14 @@ public ApiResponse<ApiExtendedGameUserResponse> UpdateUser(RequestContext contex
8687
GameUser user, ApiUpdateUserRequest body, IDataStore dataStore, DataContext dataContext, IntegrationConfig integrationConfig,
8788
SmtpService smtpService)
8889
{
89-
// If any icon is requested to be reset, force its hash to be a specific value,
90-
// to not allow uncontrolled values which would still count as blank/empty hash (e.g. unlimited whitespaces)
91-
if (body.IconHash != null)
92-
{
93-
if (body.IconHash.IsBlankHash())
94-
{
95-
body.IconHash = "0";
96-
}
97-
else if (database.GetAssetFromHash(body.IconHash) == null)
98-
{
99-
return ApiNotFoundError.Instance;
100-
}
101-
}
90+
(body.IconHash, ApiError? mainIconError) = body.IconHash.ValidateIcon(dataContext);
91+
if (mainIconError != null) return mainIconError;
10292

103-
if (body.VitaIconHash != null)
104-
{
105-
if (body.VitaIconHash.IsBlankHash())
106-
{
107-
body.VitaIconHash = "0";
108-
}
109-
else if (database.GetAssetFromHash(body.VitaIconHash) == null)
110-
{
111-
return ApiNotFoundError.Instance;
112-
}
113-
}
93+
(body.VitaIconHash, ApiError? vitaIconError) = body.VitaIconHash.ValidateIcon(dataContext);
94+
if (vitaIconError != null) return vitaIconError;
11495

115-
if (body.BetaIconHash != null)
116-
{
117-
if (body.BetaIconHash.IsBlankHash())
118-
{
119-
body.BetaIconHash = "0";
120-
}
121-
else if (database.GetAssetFromHash(body.BetaIconHash) == null)
122-
{
123-
return ApiNotFoundError.Instance;
124-
}
125-
}
96+
(body.BetaIconHash, ApiError? betaIconError) = body.BetaIconHash.ValidateIcon(dataContext);
97+
if (betaIconError != null) return betaIconError;
12698

12799
if (body.EmailAddress != null && !smtpService.CheckEmailDomainValidity(body.EmailAddress))
128100
return ApiValidationError.EmailDoesNotActuallyExistError;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Refresh.Core.Types.Data;
2+
using Refresh.Database.Models.Assets;
3+
using Refresh.Database.Models.Authentication;
4+
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
5+
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
6+
7+
namespace Refresh.Interfaces.APIv3.Extensions;
8+
9+
public static class StringExtensions
10+
{
11+
/// <summary>
12+
/// Validates the given icon reference (may be null, blank, a GUID or a hash).
13+
/// Returns the icon reference to use, and an ApiError if validation failed.
14+
/// </summary>
15+
public static (string?, ApiError?) ValidateIcon(this string? iconReference, DataContext dataContext)
16+
{
17+
if (iconReference == null)
18+
return (null, null);
19+
20+
else if (iconReference.IsBlankHash())
21+
return ("0", null);
22+
23+
else if (iconReference.StartsWith('g') && iconReference.Length > 1)
24+
{
25+
bool isGuid = long.TryParse(iconReference[1..], out long guid);
26+
if (!isGuid || (isGuid && !dataContext.GuidChecker.IsTextureGuid(TokenGame.LittleBigPlanet1, guid)))
27+
return (null, ApiValidationError.InvalidTextureGuidError);
28+
}
29+
else
30+
{
31+
GameAsset? asset = dataContext.Database.GetAssetFromHash(iconReference);
32+
33+
if (asset == null)
34+
return (null, ApiValidationError.IconMissingError);
35+
36+
if (asset.AssetType is not GameAssetType.Jpeg and not GameAssetType.Png)
37+
return (null, ApiValidationError.IconMustBeImageError);
38+
}
39+
40+
return (iconReference, null);
41+
}
42+
}

0 commit comments

Comments
 (0)