Skip to content

Commit a676bd9

Browse files
committed
feat(user-metadata): correct user metadata management
1 parent b17f69f commit a676bd9

12 files changed

Lines changed: 147 additions & 42 deletions

File tree

src/Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@
7171
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
7272
<PackageVersion Include="ZstdSharp.Port" Version="0.8.6" />
7373
</ItemGroup>
74-
</Project>
74+
</Project>

src/GZCTF.Test/UnitTests/Models/UserInfoTests.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,23 @@ namespace GZCTF.Test.UnitTests.Models;
1010
public class UserInfoTests
1111
{
1212
[Fact]
13-
public void UpdateUserInfo_ProfileUpdateModel_UpdatesMetadata()
13+
public void UpdateUserInfo_ProfileUpdateModel_UpdatesBioAndPhone()
1414
{
1515
var user = new UserInfo();
16-
var metadata = new SortedDictionary<string, JsonDocument?>
16+
var model = new ProfileUpdateModel
1717
{
18-
["key1"] = JsonSerializer.Deserialize<JsonDocument>("\"value1\"")
18+
Bio = "new bio",
19+
Phone = "new phone",
20+
Metadata = new SortedDictionary<string, JsonDocument?>
21+
{
22+
["key1"] = JsonSerializer.Deserialize<JsonDocument>("\"value1\"")
23+
}
1924
};
20-
var model = new ProfileUpdateModel { Metadata = metadata };
2125

2226
user.UpdateUserInfo(model);
2327

24-
Assert.NotNull(user.Metadata);
25-
Assert.Equal("value1", user.Metadata["key1"]?.RootElement.GetString());
28+
Assert.Equal("new bio", user.Bio);
29+
Assert.Equal("new phone", user.PhoneNumber);
2630
}
2731

2832
[Fact]

src/GZCTF/Controllers/AccountController.cs

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Net.Mime;
2-
using System.Text.Json;
32
using GZCTF.Extensions;
43
using GZCTF.Middlewares;
54
using GZCTF.Models.Internal;
@@ -356,7 +355,7 @@ public async Task<IActionResult> LogOut()
356355
[ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
357356
public async Task<IActionResult> Update([FromBody] ProfileUpdateModel model, CancellationToken token = default)
358357
{
359-
var user = await userManager.GetUserAsync(User);
358+
var user = (await userManager.GetUserAsync(User))!;
360359

361360
if (model.UserName is not null && model.UserName != user!.UserName)
362361
{
@@ -371,25 +370,12 @@ public async Task<IActionResult> Update([FromBody] ProfileUpdateModel model, Can
371370
user, TaskStatus.Success);
372371
}
373372

374-
if (model.Metadata is not null)
373+
if (model.Metadata is { Count: > 0 })
375374
{
376-
var fields = await metadataRepository.GetAllAsync(token);
377-
var fieldMap = fields.ToDictionary(f => f.Key);
378-
379-
foreach ((string key, JsonDocument? value) in model.Metadata)
380-
{
381-
if (!fieldMap.TryGetValue(key, out var field))
382-
return BadRequest(
383-
new RequestResponse(localizer[nameof(Resources.Program.Model_UnknownMetadataField), key]));
384-
385-
if (field.Locked)
386-
return BadRequest(new RequestResponse(localizer[nameof(Resources.Program.Model_FieldIsLocked),
387-
field.DisplayName]));
388-
389-
if (!field.Validate(value, localizer, out var error))
390-
return BadRequest(new RequestResponse(
391-
localizer[nameof(Resources.Program.Model_FieldValidationFailed), field.DisplayName, error]));
392-
}
375+
var fieldDefs = await metadataRepository.GetAllAsync(token);
376+
if (fieldDefs.Count > 0)
377+
if (!user.UpdateUserMetadataByUser(model.Metadata, localizer, fieldDefs, out var errorResponse))
378+
return BadRequest(new RequestResponse(errorResponse));
393379
}
394380

395381
user!.UpdateUserInfo(model);

src/GZCTF/Controllers/AdminController.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ public class AdminController(
3737
IConfigService configService,
3838
IGameRepository gameRepository,
3939
ITeamRepository teamRepository,
40-
IContainerRepository containerRepository,
4140
IServiceProvider serviceProvider,
41+
IContainerRepository containerRepository,
42+
IUserMetadataFieldRepository metadataRepository,
4243
IParticipationRepository participationRepository,
4344
IStringLocalizer<Program> localizer) : ControllerBase
4445
{
@@ -261,7 +262,7 @@ public async Task<IActionResult> AddUsers([FromBody] UserCreateModel[] model, Ca
261262
}
262263

263264
var teams = new List<Team>();
264-
foreach (var (user, teamName) in users)
265+
foreach ((UserInfo user, var teamName) in users)
265266
{
266267
if (teamName is null)
267268
continue;
@@ -389,7 +390,8 @@ public async Task<IActionResult> UpdateTeam([FromRoute] int id, [FromBody] Admin
389390
[HttpPut("Users/{userid}")]
390391
[ProducesResponseType(StatusCodes.Status200OK)]
391392
[ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)]
392-
public async Task<IActionResult> UpdateUserInfo(string userid, [FromBody] AdminUserInfoModel model)
393+
public async Task<IActionResult> UpdateUserInfo(string userid,
394+
[FromBody] AdminUserInfoModel model, CancellationToken token)
393395
{
394396
var user = await userManager.FindByIdAsync(userid);
395397

@@ -413,6 +415,14 @@ public async Task<IActionResult> UpdateUserInfo(string userid, [FromBody] AdminU
413415
return HandleIdentityError(result.Errors);
414416
}
415417

418+
if (model.Metadata is { Count: > 0 })
419+
{
420+
var fieldDefs = await metadataRepository.GetAllAsync(token);
421+
if (fieldDefs.Count > 0)
422+
if (!user.UpdateUserMetadataByAdmin(model.Metadata, localizer, fieldDefs, out var errorResponse))
423+
return BadRequest(new RequestResponse(errorResponse));
424+
}
425+
416426
user.UpdateUserInfo(model);
417427
await userManager.UpdateAsync(user);
418428

src/GZCTF/Controllers/GameController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ public async Task<IActionResult> ScoreboardSheet([FromRoute] int id,
844844
try
845845
{
846846
var scoreboard = await gameRepository.GetScoreboardWithMembers(game, token);
847-
var stream = excelHelper.GetScoreboardExcel(scoreboard, metadataFields);
847+
var stream = excelHelper.GetScoreboardExcel(scoreboard, metadataFields.Values);
848848
stream.Seek(0, SeekOrigin.Begin);
849849

850850
return File(stream,

src/GZCTF/Controllers/InfoController.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ public async Task<IActionResult> PowChallenge(CancellationToken token = default)
194194
/// <response code="200">Successfully retrieved metadata fields</response>
195195
[HttpGet("Metadata")]
196196
[ProducesResponseType(typeof(UserMetadataField[]), StatusCodes.Status200OK)]
197-
public Task<UserMetadataField[]> GetMetadataFields(CancellationToken token) =>
198-
metadataRepository.GetAllAsync(token);
197+
public async Task<IEnumerable<UserMetadataField>> GetMetadataFields(CancellationToken token)
198+
{
199+
var metadataFields = await metadataRepository.GetAllAsync(token);
200+
return metadataFields.Values;
201+
}
199202
}

src/GZCTF/Controllers/MetadataController.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ public class MetadataController(IUserMetadataFieldRepository metadataRepository,
2121
/// <returns></returns>
2222
[HttpGet]
2323
[ProducesResponseType(typeof(UserMetadataField[]), StatusCodes.Status200OK)]
24-
public Task<UserMetadataField[]> Get(CancellationToken token) => metadataRepository.GetAllAsync(token);
24+
public async Task<IEnumerable<UserMetadataField>> Get(CancellationToken token)
25+
{
26+
var fields = await metadataRepository.GetAllAsync(token);
27+
return fields.Values;
28+
}
2529

2630
/// <summary>
2731
/// Create a new metadata field

src/GZCTF/Extensions/UserMetadataExtension.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,103 @@ public bool Validate(JsonDocument? value, IStringLocalizer<Program> localizer,
173173
return true;
174174
}
175175
}
176+
177+
extension(UserInfo user)
178+
{
179+
/// <summary>
180+
/// Update user metadata by user input
181+
/// </summary>
182+
/// <param name="metadata">The new metadata from user</param>
183+
/// <param name="localizer">String localizer for error messages</param>
184+
/// <param name="fieldDefs">Field definitions</param>
185+
/// <param name="errorResponse">Error response if validation fails</param>
186+
/// <returns>>True if update is successful, false otherwise</returns>
187+
internal bool UpdateUserMetadataByUser(MetadataStore metadata,
188+
IStringLocalizer<Program> localizer,
189+
Dictionary<string, UserMetadataField> fieldDefs,
190+
[MaybeNullWhen(true)] out string errorResponse)
191+
{
192+
var newMetadata = new MetadataStore();
193+
194+
foreach ((var key, JsonDocument? value) in metadata)
195+
{
196+
// Skip fields that are locked or not defined
197+
if (!fieldDefs.TryGetValue(key, out var field) || field.Locked)
198+
continue;
199+
200+
// Validate field value
201+
if (!field.Validate(value, localizer, out var error))
202+
{
203+
errorResponse = localizer[nameof(Resources.Program.Model_FieldValidationFailed),
204+
field.DisplayName, error];
205+
return false;
206+
}
207+
208+
newMetadata[key] = value;
209+
}
210+
211+
if (user.Metadata is not null)
212+
{
213+
foreach ((var key, JsonDocument? value) in user.Metadata)
214+
{
215+
// Preserve locked fields
216+
if (fieldDefs.TryGetValue(key, out var field) && field.Locked)
217+
newMetadata[key] = value;
218+
}
219+
}
220+
221+
user.Metadata = newMetadata;
222+
errorResponse = null;
223+
return true;
224+
}
225+
226+
/// <summary>
227+
/// Update user metadata by admin input
228+
/// </summary>
229+
/// <param name="metadata">The new metadata from user</param>
230+
/// <param name="localizer">String localizer for error messages</param>
231+
/// <param name="fieldDefs">Field definitions</param>
232+
/// <param name="errorResponse">Error response if validation fails</param>
233+
/// <returns>>True if update is successful, false otherwise</returns>
234+
internal bool UpdateUserMetadataByAdmin(MetadataStore metadata,
235+
IStringLocalizer<Program> localizer,
236+
Dictionary<string, UserMetadataField> fieldDefs,
237+
[MaybeNullWhen(true)] out string errorResponse)
238+
{
239+
var newMetadata = new MetadataStore();
240+
241+
foreach ((var key, JsonDocument? value) in metadata)
242+
{
243+
// Skip fields that are locked or not defined
244+
if (!fieldDefs.TryGetValue(key, out var field))
245+
continue;
246+
247+
// Validate field value
248+
if (!field.Validate(value, localizer, out var error))
249+
{
250+
errorResponse = localizer[nameof(Resources.Program.Model_FieldValidationFailed),
251+
field.DisplayName, error];
252+
return false;
253+
}
254+
255+
newMetadata[key] = value;
256+
}
257+
258+
if (user.Metadata is not null)
259+
{
260+
foreach ((var key, JsonDocument? value) in user.Metadata)
261+
{
262+
// Preserve old fields that are not being updated
263+
if (!newMetadata.ContainsKey(key) && fieldDefs.ContainsKey(key))
264+
newMetadata[key] = value;
265+
}
266+
}
267+
268+
user.Metadata = newMetadata;
269+
errorResponse = null;
270+
return true;
271+
}
272+
}
176273
}
177274

178275
public sealed record UserMetadataFieldValue(UserMetadataFieldValueType Type, JsonDocument? Value) : IDisposable

src/GZCTF/Models/Data/UserInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ public void UpdateByHttpContext(HttpContext context)
8484
internal void UpdateUserInfo(AdminUserInfoModel model)
8585
{
8686
// use SetUserNameAsync and SetEmailAsync to update UserName and Email
87+
// use UpdateUserMetadataByAdmin to update Metadata
8788
Bio = model.Bio ?? Bio;
8889
Role = model.Role ?? Role;
8990
PhoneNumber = model.Phone ?? PhoneNumber;
9091
EmailConfirmed = model.EmailConfirmed ?? EmailConfirmed;
91-
Metadata = model.Metadata ?? Metadata;
9292
}
9393

9494
/// <summary>
@@ -107,9 +107,9 @@ internal void UpdateUserInfo(UserCreateModel model)
107107
internal void UpdateUserInfo(ProfileUpdateModel model)
108108
{
109109
// use SetUserNameAsync to update UserName
110+
// use UpdateUserMetadataByUser to update Metadata
110111
Bio = model.Bio ?? Bio;
111112
PhoneNumber = model.Phone ?? PhoneNumber;
112-
Metadata = model.Metadata ?? Metadata;
113113
}
114114

115115
#region Db Relationship

src/GZCTF/Repositories/Interface/IUserMetadataFieldRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public interface IUserMetadataFieldRepository : IRepository
1515
/// </summary>
1616
/// <param name="token">Cancellation token</param>
1717
/// <returns>Array of fields</returns>
18-
Task<UserMetadataField[]> GetAllAsync(CancellationToken token = default);
18+
Task<Dictionary<string, UserMetadataField>> GetAllAsync(CancellationToken token = default);
1919

2020
/// <summary>
2121
/// Create a new user metadata field

0 commit comments

Comments
 (0)