Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add various user- and beatmapset-related sanity checks to submission operations #4

Merged
merged 12 commits into from
Nov 28, 2024
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/_ReSharper.Caches/
osu-server-beatmap-submission.sln.DotSettings.user
601 changes: 595 additions & 6 deletions osu.Server.BeatmapSubmission.Tests/BeatmapSubmissionControllerTest.cs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions osu.Server.BeatmapSubmission.Tests/IntegrationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ private void reinitialiseDatabase()
if (db.QueryFirstOrDefault<int?>("SELECT `count` FROM `osu_counts` WHERE name = 'is_production'") != null)
throw new InvalidOperationException("You have just attempted to run tests on production and wipe data. Rethink your life decisions.");

db.Execute("TRUNCATE TABLE `osu_user_banhistory`");
db.Execute("TRUNCATE TABLE `osu_user_month_playcount`");
db.Execute("TRUNCATE TABLE `phpbb_users`");
db.Execute("TRUNCATE TABLE `osu_beatmaps`");

Expand Down
177 changes: 135 additions & 42 deletions osu.Server.BeatmapSubmission/BeatmapSubmissionController.cs

Large diffs are not rendered by default.

99 changes: 96 additions & 3 deletions osu.Server.BeatmapSubmission/DatabaseOperationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,102 @@ public static Task<string> GetUsernameAsync(this MySqlConnection db, uint userId
transaction);
}

/// <seealso href="https://github.com/ppy/osu-web/blob/1fbc73baa8e8be6759b1cc4f4ab509d8ae53a165/app/Models/User.php#L1099-L1102"><c>isBanned()</c> in osu-web</seealso>
/// <seealso href="https://github.com/ppy/osu-web/blob/1fbc73baa8e8be6759b1cc4f4ab509d8ae53a165/app/Models/User.php#L1109-L1112"><c>isRestricted()</c> in osu-web</seealso>
public static async Task<bool> IsUserRestrictedAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
var standing = await db.QuerySingleAsync<(short user_type, short user_warnings)>(
@"SELECT `user_type`, `user_warnings` FROM `phpbb_users` WHERE `user_id` = @user_id",
new
{
user_id = userId,
},
transaction);

return standing.user_type == 1 || standing.user_warnings > 0;
}

/// <remarks>
/// Contrary to the osu-web implementation, this does not check for restriction status too.
/// Use <see cref="IsUserRestrictedAsync"/> separately instead.
/// </remarks>
/// <seealso href="https://github.com/ppy/osu-web/blob/65ca10d9b137009c5a33876b4caef3453dfb0bc2/app/Models/User.php#L1114-L1127"><c>isSilenced()</c> in osu-web</seealso>
public static async Task<bool> IsUserSilencedAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
var ban = await db.QueryFirstOrDefaultAsync<osu_user_banhistory>(
@"SELECT * FROM `osu_user_banhistory` WHERE `user_id` = @user_id AND `ban_status` IN (1, 2) ORDER BY `timestamp` DESC",
new
{
user_id = userId,
},
transaction);

return ban != null && ban.period != 0 && ban.EndTime > DateTimeOffset.Now;
}

public static async Task<ulong> GetUserMonthlyPlaycountAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
return await db.QuerySingleAsync<ulong?>(
@"SELECT SUM(`playcount`) FROM `osu_user_month_playcount` WHERE `user_id` = @user_id",
new
{
user_id = userId,
},
transaction) ?? 0;
}

public static async Task PurgeInactiveBeatmapSetsForUserAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
uint[] beatmapSetIds = (await db.QueryAsync<uint>(@"SELECT `beatmapset_id` FROM `osu_beatmapsets` WHERE `user_id` = @user_id AND `active` = -1 AND `deleted_at` IS NULL",
new
{
user_id = userId,
},
transaction)).ToArray();

await db.ExecuteAsync(@"DELETE FROM `osu_beatmaps` WHERE `beatmapset_id` IN @beatmapset_ids AND `user_id` = @user_id AND `deleted_at` IS NULL",
new
{
beatmapset_ids = beatmapSetIds,
user_id = userId,
}, transaction);
await db.ExecuteAsync(@"DELETE FROM `osu_beatmapsets` WHERE `user_id` = @user_id AND `active` = -1",
new
{
user_id = userId,
},
transaction);
}

public static Task<(uint unranked, uint ranked)> GetUserBeatmapSetCountAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
return db.QuerySingleAsync<(uint unranked, uint ranked)>(
"""
SELECT
SUM(CASE WHEN `approved` IN (-1, 0) THEN 1 ELSE 0 END) AS `unranked`,
SUM(CASE WHEN `approved` > 0 THEN 1 ELSE 0 END) AS `ranked`
FROM `osu_beatmapsets` WHERE `active` > 0 AND `deleted_at` IS NULL AND `user_id` = @user_id
""",
new
{
user_id = userId,
},
transaction);
}

public static Task<bool> IsUserSupporterAsync(this MySqlConnection db, uint userId, MySqlTransaction? transaction = null)
{
return db.QuerySingleAsync<bool>(@"SELECT `osu_subscriber` FROM `phpbb_users` WHERE `user_id` = @user_id",
new
{
user_id = userId,
},
transaction);
}

public static Task<osu_beatmapset?> GetBeatmapSetAsync(this MySqlConnection db, uint beatmapSetId, MySqlTransaction? transaction = null)
{
return db.QuerySingleOrDefaultAsync<osu_beatmapset?>(@"SELECT * FROM `osu_beatmapsets` WHERE `beatmapset_id` = @beatmapSetId",
return db.QuerySingleOrDefaultAsync<osu_beatmapset?>(@"SELECT * FROM `osu_beatmapsets` WHERE `beatmapset_id` = @beatmapSetId AND `deleted_at` IS NULL",
new
{
beatmapSetId = beatmapSetId,
Expand All @@ -32,7 +125,7 @@ public static Task<string> GetUsernameAsync(this MySqlConnection db, uint userId

public static Task<IEnumerable<uint>> GetBeatmapIdsInSetAsync(this MySqlConnection db, uint beatmapSetId, MySqlTransaction? transaction = null)
{
return db.QueryAsync<uint>(@"SELECT `beatmap_id` FROM `osu_beatmaps` WHERE `beatmapset_id` = @beatmapSetId",
return db.QueryAsync<uint>(@"SELECT `beatmap_id` FROM `osu_beatmaps` WHERE `beatmapset_id` = @beatmapSetId AND `deleted_at` IS NULL",
new
{
beatmapSetId = beatmapSetId,
Expand All @@ -42,7 +135,7 @@ public static Task<IEnumerable<uint>> GetBeatmapIdsInSetAsync(this MySqlConnecti

public static Task<osu_beatmap?> GetBeatmapAsync(this MySqlConnection db, uint beatmapSetId, uint beatmapId, MySqlTransaction? transaction = null)
{
return db.QuerySingleOrDefaultAsync<osu_beatmap?>(@"SELECT * FROM `osu_beatmaps` WHERE `beatmap_id` = @beatmapId AND `beatmapset_id` = @beatmapSetId",
return db.QuerySingleOrDefaultAsync<osu_beatmap?>(@"SELECT * FROM `osu_beatmaps` WHERE `beatmap_id` = @beatmapId AND `beatmapset_id` = @beatmapSetId AND `deleted_at` IS NULL",
new
{
beatmapSetId = beatmapSetId,
Expand Down
17 changes: 17 additions & 0 deletions osu.Server.BeatmapSubmission/InvariantExceptionFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Microsoft.AspNetCore.Mvc.Filters;
using osu.Server.BeatmapSubmission.Models;

namespace osu.Server.BeatmapSubmission
{
public class InvariantExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is InvariantException invariantException)
context.Result = invariantException.ToResponseObject().ToActionResult();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace osu.Server.BeatmapSubmission.Models.API.Requests
Expand All @@ -22,7 +21,6 @@ public class PutBeatmapSetRequest
/// </summary>
/// <example>12</example>
[JsonPropertyName("beatmaps_to_create")]
[Range(1, 128)]
public uint BeatmapsToCreate { get; set; }

/// <summary>
Expand Down
24 changes: 24 additions & 0 deletions osu.Server.BeatmapSubmission/Models/API/Responses/ErrorResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

namespace osu.Server.BeatmapSubmission.Models.API.Responses
{
/// <summary>
/// Response type issued in case of an error that is directly presentable to the user.
/// </summary>
public class ErrorResponse
{
[JsonPropertyName("error")]
public string Error { get; }

public ErrorResponse(string error)
{
Error = error;
}

public IActionResult ToActionResult() => new UnprocessableEntityObjectResult(this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

// ReSharper disable InconsistentNaming

namespace osu.Server.BeatmapSubmission.Models.Database
{
public class osu_user_banhistory
{
public uint ban_id { get; set; }
public uint user_id { get; set; }
public uint period { get; set; }
public DateTimeOffset timestamp { get; set; }

/// <seealso href="https://github.com/ppy/osu-web/blob/65ca10d9b137009c5a33876b4caef3453dfb0bc2/app/Models/UserAccountHistory.php#L113-L116"/>
public DateTimeOffset EndTime => timestamp.AddSeconds(period);
}
}
21 changes: 21 additions & 0 deletions osu.Server.BeatmapSubmission/Models/InvariantException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using osu.Server.BeatmapSubmission.Models.API.Responses;

namespace osu.Server.BeatmapSubmission.Models
{
/// <summary>
/// Exceptions of this type are violations of various invariants enforced by this server,
/// and their messages are assumed to be directly presentable to the user.
/// </summary>
public class InvariantException : Exception
{
public InvariantException(string message)
: base(message)
{
}

public ErrorResponse ToResponseObject() => new ErrorResponse(Message);
}
}
5 changes: 4 additions & 1 deletion osu.Server.BeatmapSubmission/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ public static void Main(string[] args)

// Add services to the container.
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddControllers(options =>
{
options.Filters.Add<InvariantExceptionFilter>();
});
builder.Services.AddLogging(logging =>
{
logging.ClearProviders();
Expand Down
6 changes: 2 additions & 4 deletions osu.Server.BeatmapSubmission/Services/BeatmapPackageParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,8 @@ private static osu_beatmap constructDatabaseRowForBeatmap(BeatmapContent beatmap

private static osu_beatmapset constructDatabaseRowForBeatmapset(uint beatmapSetId, ArchiveReader archiveReader, BeatmapContent[] beatmaps)
{
// TODO: currently all exceptions thrown here will be 500s, they should be 429s

if (beatmaps.Length == 0)
throw new InvalidOperationException("The uploaded beatmap set must have at least one difficulty.");
throw new InvariantException("The uploaded beatmap set must have at least one difficulty.");

float firstBeatLength = (float)beatmaps.First().Beatmap.GetMostCommonBeatLength();

Expand Down Expand Up @@ -143,7 +141,7 @@ private static T getSingleValueFrom<T>(IEnumerable<BeatmapContent> beatmapConten
{
T[] distinctValues = beatmapContents.Select(accessor).Distinct().ToArray();
if (distinctValues.Length != 1)
throw new InvalidOperationException($"The uploaded beatmap set's individual difficulties have inconsistent {valueName}. Please unify {valueName} before re-submitting.");
throw new InvariantException($"The uploaded beatmap set's individual difficulties have inconsistent {valueName}. Please unify {valueName} before re-submitting.");

return distinctValues.Single();
}
Expand Down