Add limit options for saves, user storage, and download speed
Implements #85, #84, #100
This commit is contained in:
parent
8fdb6f74cb
commit
fa1559c367
31 changed files with 20568 additions and 31 deletions
3304
LANCommander.Server.Data.MySQL/Migrations/20260628172954_AddUserRoleLimits.Designer.cs
generated
Normal file
3304
LANCommander.Server.Data.MySQL/Migrations/20260628172954_AddUserRoleLimits.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,58 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.MySQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserRoleLimits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
3310
LANCommander.Server.Data.MySQL/Migrations/20260628181234_AddEnableSaves.Designer.cs
generated
Normal file
3310
LANCommander.Server.Data.MySQL/Migrations/20260628181234_AddEnableSaves.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.MySQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnableSaves : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Users",
|
||||
type: "tinyint(1)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Roles",
|
||||
type: "tinyint(1)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1242,6 +1242,12 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
|
@ -1250,6 +1256,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
.HasMaxLength(256)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("UpdatedById")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
|
|
@ -1733,6 +1742,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
|
@ -1740,6 +1752,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
|
@ -1772,6 +1787,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
|
|
|||
3304
LANCommander.Server.Data.PostgreSQL/Migrations/20260628172752_AddUserRoleLimits.Designer.cs
generated
Normal file
3304
LANCommander.Server.Data.PostgreSQL/Migrations/20260628172752_AddUserRoleLimits.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,58 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserRoleLimits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
3310
LANCommander.Server.Data.PostgreSQL/Migrations/20260628181115_AddEnableSaves.Designer.cs
generated
Normal file
3310
LANCommander.Server.Data.PostgreSQL/Migrations/20260628181115_AddEnableSaves.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnableSaves : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Users",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Roles",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1242,6 +1242,12 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
|
@ -1250,6 +1256,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("UpdatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
@ -1733,6 +1742,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
|
@ -1740,6 +1752,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
@ -1772,6 +1787,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
|
|||
3295
LANCommander.Server.Data.SQLite/Migrations/20260628172405_AddUserRoleLimits.Designer.cs
generated
Normal file
3295
LANCommander.Server.Data.SQLite/Migrations/20260628172405_AddUserRoleLimits.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,58 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserRoleLimits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DownloadSpeedKBps",
|
||||
table: "Roles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StorageQuotaMB",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
3301
LANCommander.Server.Data.SQLite/Migrations/20260628180740_AddEnableSaves.Designer.cs
generated
Normal file
3301
LANCommander.Server.Data.SQLite/Migrations/20260628180740_AddEnableSaves.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnableSaves : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableSaves",
|
||||
table: "Roles",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableSaves",
|
||||
table: "Roles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1237,6 +1237,12 @@ namespace LANCommander.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
|
@ -1245,6 +1251,9 @@ namespace LANCommander.Migrations
|
|||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("UpdatedById")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
|
@ -1726,6 +1735,9 @@ namespace LANCommander.Migrations
|
|||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("DownloadSpeedKBps")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
|
@ -1733,6 +1745,9 @@ namespace LANCommander.Migrations
|
|||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool?>("EnableSaves")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
@ -1765,6 +1780,9 @@ namespace LANCommander.Migrations
|
|||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("StorageQuotaMB")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ namespace LANCommander.Server.Data.Models
|
|||
{
|
||||
public ICollection<Collection> Collections { get; set; }
|
||||
public ICollection<UserRole> UserRoles { get; set; }
|
||||
|
||||
[Display(Name = "Storage Quota (MB)")]
|
||||
public int? StorageQuotaMB { get; set; }
|
||||
|
||||
[Display(Name = "Download Speed (KB/s)")]
|
||||
public int? DownloadSpeedKBps { get; set; }
|
||||
|
||||
[Display(Name = "Enable Saves")]
|
||||
public bool? EnableSaves { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public ICollection<User> Users
|
||||
{
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ namespace LANCommander.Server.Data.Models
|
|||
|
||||
public string? Alias { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Storage Quota (MB)")]
|
||||
public int? StorageQuotaMB { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Download Speed (KB/s)")]
|
||||
public int? DownloadSpeedKBps { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Enable Saves")]
|
||||
public bool? EnableSaves { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Display(Name = "Created On")]
|
||||
public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@
|
|||
<PackageReference Include="ZiggyCreatures.FusionCache" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="LANCommander.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
|
||||
<ProjectReference Include="..\LANCommander.Server.Data\LANCommander.Server.Data.csproj" />
|
||||
|
|
|
|||
10
LANCommander.Server.Services/Models/UserLimits.cs
Normal file
10
LANCommander.Server.Services/Models/UserLimits.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace LANCommander.Server.Services.Models
|
||||
{
|
||||
public readonly record struct UserLimits(int StorageQuotaMB, int DownloadSpeedKBps, bool SavesEnabled)
|
||||
{
|
||||
public long StorageQuotaBytes => StorageQuotaMB <= 0 ? 0 : (long)StorageQuotaMB * 1024 * 1024;
|
||||
public long DownloadSpeedBytesPerSecond => DownloadSpeedKBps <= 0 ? 0 : (long)DownloadSpeedKBps * 1024;
|
||||
public bool StorageUnlimited => StorageQuotaMB <= 0;
|
||||
public bool DownloadUnlimited => DownloadSpeedKBps <= 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -37,10 +37,15 @@ namespace LANCommander.Server.Services
|
|||
|
||||
public override async Task<Role> UpdateAsync(Role entity)
|
||||
{
|
||||
return await base.UpdateAsync(entity, async context =>
|
||||
var role = await base.UpdateAsync(entity, async context =>
|
||||
{
|
||||
await context.UpdateRelationshipAsync(r => r.Collections);
|
||||
});
|
||||
|
||||
// Role limit columns are cached as part of users' role snapshots, so invalidate them.
|
||||
await cache.RemoveByTagAsync(["User/Roles", "User/Security"]);
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
public async Task<Role> GetAsync(string roleName)
|
||||
|
|
|
|||
|
|
@ -100,6 +100,49 @@ namespace LANCommander.Server.Services
|
|||
return roles.Any(r => r.Name == roleName);
|
||||
}
|
||||
|
||||
public async Task<UserLimits> GetLimitsAsync(User user)
|
||||
{
|
||||
var roles = await GetRolesAsync(user);
|
||||
|
||||
var storageQuota = Resolve(user.StorageQuotaMB, roles.Select(r => r.StorageQuotaMB));
|
||||
var downloadSpeed = Resolve(user.DownloadSpeedKBps, roles.Select(r => r.DownloadSpeedKBps));
|
||||
var savesEnabled = Resolve(user.EnableSaves, roles.Select(r => r.EnableSaves));
|
||||
|
||||
return new UserLimits(storageQuota, downloadSpeed, savesEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an effective limit value. A per-user override (if set) takes precedence. Otherwise the most
|
||||
/// restrictive (lowest non-zero) value across the user's roles is used. A value of 0 means unlimited, and a
|
||||
/// null role value means the limit is not configured for that role and is ignored.
|
||||
/// </summary>
|
||||
internal static int Resolve(int? userOverride, IEnumerable<int?> roleValues)
|
||||
{
|
||||
if (userOverride.HasValue)
|
||||
return userOverride.Value;
|
||||
|
||||
var restrictive = roleValues
|
||||
.Where(v => v.HasValue && v.Value > 0)
|
||||
.Select(v => v!.Value)
|
||||
.ToList();
|
||||
|
||||
return restrictive.Count == 0 ? 0 : restrictive.Min();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an effective boolean permission (e.g. whether saves are enabled). A per-user override (if set)
|
||||
/// takes precedence. Otherwise the most restrictive value across the user's roles is used: if any role
|
||||
/// explicitly disables the permission it is disabled. A null role value means the permission is not configured
|
||||
/// for that role and is ignored; if no role configures it the permission defaults to enabled.
|
||||
/// </summary>
|
||||
internal static bool Resolve(bool? userOverride, IEnumerable<bool?> roleValues)
|
||||
{
|
||||
if (userOverride.HasValue)
|
||||
return userOverride.Value;
|
||||
|
||||
return !roleValues.Any(v => v == false);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Collection>> GetCollectionsAsync(User user)
|
||||
{
|
||||
try
|
||||
|
|
@ -523,9 +566,14 @@ namespace LANCommander.Server.Services
|
|||
user.Alias = entity.Alias;
|
||||
user.Approved = entity.Approved;
|
||||
user.ApprovedOn = entity.ApprovedOn;
|
||||
user.StorageQuotaMB = entity.StorageQuotaMB;
|
||||
user.DownloadSpeedKBps = entity.DownloadSpeedKBps;
|
||||
user.EnableSaves = entity.EnableSaves;
|
||||
|
||||
await identityContext.UserManager.UpdateAsync(user);
|
||||
|
||||
await Cache.RemoveByTagAsync($"User/{entity.Id}");
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ public static class ArchivesEndpoints
|
|||
|
||||
internal static async Task<Results<FileStreamHttpResult, NotFound, UnauthorizedHttpResult>> DownloadAsync(
|
||||
Guid id,
|
||||
System.Security.Claims.ClaimsPrincipal user,
|
||||
[FromServices] ArchiveService archiveService,
|
||||
[FromServices] DownloadThrottle downloadThrottle,
|
||||
[FromServices] ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ArchivesApi");
|
||||
|
|
@ -64,8 +66,11 @@ public static class ArchivesEndpoints
|
|||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var stream = await downloadThrottle.ApplyAsync(
|
||||
new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.Read), user);
|
||||
|
||||
return TypedResults.File(
|
||||
new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.Read),
|
||||
stream,
|
||||
"application/octet-stream",
|
||||
$"{archive.Game.Title.SanitizeFilename()}.zip");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,15 @@ public static class DownloadEndpoints
|
|||
|
||||
internal static async Task<Results<NotFound, FileStreamHttpResult>> DownloadArchiveAsync(
|
||||
Guid id,
|
||||
ClaimsPrincipal user,
|
||||
[FromServices] ArchiveService archiveService,
|
||||
[FromServices] DownloadThrottle downloadThrottle,
|
||||
[FromServices] ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download archive with ID: {ArchiveId}", id);
|
||||
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download archive with ID: {ArchiveId}", id);
|
||||
|
||||
var archive = await archiveService
|
||||
.Include(a => a.Game!)
|
||||
.Include(a => a.Redistributable!)
|
||||
|
|
@ -36,10 +38,10 @@ public static class DownloadEndpoints
|
|||
{
|
||||
logger.LogWarning("Archive with ID {ArchiveId} not found", id);
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var fileName = await archiveService.GetArchiveFileLocationAsync(archive);
|
||||
|
||||
}
|
||||
|
||||
var fileName = await archiveService.GetArchiveFileLocationAsync(archive);
|
||||
|
||||
if (!File.Exists(fileName))
|
||||
{
|
||||
logger.LogWarning("Archive file not found at path: {FilePath} for archive ID {ArchiveId}", fileName, id);
|
||||
|
|
@ -57,9 +59,12 @@ public static class DownloadEndpoints
|
|||
{
|
||||
name = $"{archive.Redistributable.Name.SanitizeFilename()}.zip";
|
||||
logger.LogInformation("Serving redistributable archive {RedistName} (ID: {ArchiveId})", archive.Redistributable.Name, id);
|
||||
}
|
||||
|
||||
return TypedResults.File(new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read), fileDownloadName: name, contentType: MediaTypeNames.Application.Octet);
|
||||
}
|
||||
|
||||
var stream = await downloadThrottle.ApplyAsync(
|
||||
new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read), user);
|
||||
|
||||
return TypedResults.File(stream, fileDownloadName: name, contentType: MediaTypeNames.Application.Octet);
|
||||
}
|
||||
|
||||
internal static async Task<Results<UnauthorizedHttpResult, NotFound, FileStreamHttpResult>> DownloadSaveAsync(
|
||||
|
|
@ -68,29 +73,29 @@ public static class DownloadEndpoints
|
|||
[FromServices] GameSaveService gameSaveService,
|
||||
[FromServices] ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download save with ID: {SaveId}", id);
|
||||
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download save with ID: {SaveId}", id);
|
||||
|
||||
var save = await gameSaveService
|
||||
.Include(s => s.Game!)
|
||||
.Include(s => s.User!)
|
||||
.GetAsync(id);
|
||||
|
||||
.GetAsync(id);
|
||||
|
||||
if (user == null || user.Identity?.Name != save.User?.UserName && !user.IsInRole(RoleService.AdministratorRoleName))
|
||||
{
|
||||
logger.LogWarning("Unauthorized access attempt for save {SaveId} by user {UserName}", id, user?.Identity?.Name);
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (save == null)
|
||||
{
|
||||
logger.LogWarning("Save with ID {SaveId} not found", id);
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var fileName = gameSaveService.GetSavePath(save);
|
||||
|
||||
var fileName = gameSaveService.GetSavePath(save);
|
||||
|
||||
if (!File.Exists(fileName))
|
||||
{
|
||||
logger.LogWarning("Save file not found at path: {FilePath}", fileName);
|
||||
|
|
@ -111,10 +116,10 @@ public static class DownloadEndpoints
|
|||
[FromServices] UpdateService updateService,
|
||||
[FromServices] ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download launcher file with object key: {ObjectKey}", objectKey);
|
||||
|
||||
var logger = loggerFactory.CreateLogger(nameof(DownloadEndpoints));
|
||||
|
||||
logger.LogInformation("Attempting to download launcher file with object key: {ObjectKey}", objectKey);
|
||||
|
||||
if (string.IsNullOrEmpty(objectKey))
|
||||
{
|
||||
logger.LogWarning("Empty object key provided for launcher download");
|
||||
|
|
|
|||
31
LANCommander.Server/Endpoints/DownloadThrottle.cs
Normal file
31
LANCommander.Server/Endpoints/DownloadThrottle.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Security.Claims;
|
||||
using LANCommander.Server.Services;
|
||||
|
||||
namespace LANCommander.Server.Endpoints;
|
||||
|
||||
internal sealed class DownloadThrottle(UserService userService)
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps the source stream in a <see cref="ThrottledStream"/> using the authenticated user's resolved download
|
||||
/// speed limit. Anonymous requests and users without a configured limit are returned unthrottled.
|
||||
/// </summary>
|
||||
public async Task<Stream> ApplyAsync(Stream source, ClaimsPrincipal? principal)
|
||||
{
|
||||
var userName = principal?.Identity?.Name;
|
||||
|
||||
if (principal?.Identity?.IsAuthenticated != true || string.IsNullOrEmpty(userName))
|
||||
return source;
|
||||
|
||||
var user = await userService.GetAsync(userName);
|
||||
|
||||
if (user == null)
|
||||
return source;
|
||||
|
||||
var limits = await userService.GetLimitsAsync(user);
|
||||
|
||||
if (limits.DownloadUnlimited)
|
||||
return source;
|
||||
|
||||
return new ThrottledStream(source, limits.DownloadSpeedBytesPerSecond);
|
||||
}
|
||||
}
|
||||
|
|
@ -412,6 +412,7 @@ public static class GameEndpoints
|
|||
internal static async Task<IResult> DownloadAsync(
|
||||
[FromServices] GameService gameService,
|
||||
[FromServices] ArchiveService archiveService,
|
||||
[FromServices] DownloadThrottle downloadThrottle,
|
||||
[FromServices] IOptions<Settings.Settings> settings,
|
||||
[FromServices] ILogger<Game> logger,
|
||||
ClaimsPrincipal userPrincipal,
|
||||
|
|
@ -463,8 +464,10 @@ public static class GameEndpoints
|
|||
|
||||
var contentType = MediaTypeNames.Application.Octet;
|
||||
var fileName = $"{game.Title.SanitizeFilename()}.zip";
|
||||
|
||||
return TypedResults.File(fs, contentType, fileName);
|
||||
|
||||
var stream = await downloadThrottle.ApplyAsync(fs, userPrincipal);
|
||||
|
||||
return TypedResults.File(stream, contentType, fileName);
|
||||
}
|
||||
|
||||
internal static async Task<IResult> ImportAsync(
|
||||
|
|
|
|||
|
|
@ -252,6 +252,35 @@ public static class SaveEndpoints
|
|||
|
||||
using (var stream = file.OpenReadStream())
|
||||
{
|
||||
var limits = await userService.GetLimitsAsync(user);
|
||||
|
||||
if (!limits.SavesEnabled)
|
||||
return TypedResults.BadRequest("Cloud saves are disabled for your account.");
|
||||
|
||||
if (!limits.StorageUnlimited)
|
||||
{
|
||||
if (stream.Length > limits.StorageQuotaBytes)
|
||||
return TypedResults.BadRequest("Save exceeds your storage quota.");
|
||||
|
||||
// Cull oldest saves (across all games) to make room for the incoming save.
|
||||
var existingSaves = await saveService
|
||||
.Query(q => q
|
||||
.Where(s => s.UserId == user.Id)
|
||||
.OrderBy(s => s.CreatedOn))
|
||||
.GetAsync();
|
||||
|
||||
var used = existingSaves.Sum(s => s.Size);
|
||||
|
||||
foreach (var saveToCull in existingSaves)
|
||||
{
|
||||
if (used + stream.Length <= limits.StorageQuotaBytes)
|
||||
break;
|
||||
|
||||
await saveService.DeleteAsync(saveToCull);
|
||||
used -= saveToCull.Size;
|
||||
}
|
||||
}
|
||||
|
||||
var save = new GameSave
|
||||
{
|
||||
Game = game,
|
||||
|
|
@ -259,9 +288,9 @@ public static class SaveEndpoints
|
|||
Size = stream.Length,
|
||||
StorageLocation = saveStorageLocation,
|
||||
};
|
||||
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
|
||||
save = await saveService.AddAsync(save);
|
||||
|
||||
try
|
||||
|
|
|
|||
48
LANCommander.Server/Endpoints/StreamFileResult.cs
Normal file
48
LANCommander.Server/Endpoints/StreamFileResult.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using System.Net.Mime;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace LANCommander.Server.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Streams a file to the response with a configurable buffer size for higher download throughput.
|
||||
/// </summary>
|
||||
internal sealed class StreamFileResult : IResult
|
||||
{
|
||||
private const int DefaultBufferSize = 1024 * 1024; // 1 MB
|
||||
|
||||
private readonly Stream _fileStream;
|
||||
private readonly string _contentType;
|
||||
private readonly string _fileDownloadName;
|
||||
private readonly int _bufferSize;
|
||||
|
||||
public StreamFileResult(
|
||||
Stream fileStream,
|
||||
string contentType,
|
||||
string fileDownloadName,
|
||||
int bufferSize = DefaultBufferSize)
|
||||
{
|
||||
_fileStream = fileStream;
|
||||
_contentType = contentType;
|
||||
_fileDownloadName = fileDownloadName;
|
||||
_bufferSize = bufferSize;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
var response = httpContext.Response;
|
||||
var cancellationToken = httpContext.RequestAborted;
|
||||
|
||||
response.ContentType = _contentType;
|
||||
response.Headers.ContentLength = _fileStream.Length;
|
||||
response.Headers.ContentDisposition = $"attachment; filename=\"{_fileDownloadName}\"";
|
||||
|
||||
try
|
||||
{
|
||||
await _fileStream.CopyToAsync(response.Body, _bufferSize, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _fileStream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
119
LANCommander.Server/Endpoints/ThrottledStream.cs
Normal file
119
LANCommander.Server/Endpoints/ThrottledStream.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace LANCommander.Server.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a source stream and limits how fast it can be read by sleeping when a per-second byte budget is exceeded.
|
||||
/// Seek and length members are delegated to the inner stream so HTTP range/resume and Content-Length keep working.
|
||||
/// A <see cref="_bytesPerSecond"/> of 0 (or less) disables throttling.
|
||||
/// </summary>
|
||||
internal sealed class ThrottledStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _bytesPerSecond;
|
||||
|
||||
private long _windowStartTimestamp;
|
||||
private long _bytesInWindow;
|
||||
|
||||
public ThrottledStream(Stream inner, long bytesPerSecond)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_bytesPerSecond = bytesPerSecond;
|
||||
_windowStartTimestamp = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_bytesPerSecond <= 0)
|
||||
return await _inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
await ThrottleAsync(cancellationToken);
|
||||
|
||||
var read = await _inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
Accumulate(read);
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_bytesPerSecond <= 0)
|
||||
return _inner.Read(buffer, offset, count);
|
||||
|
||||
ThrottleAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
var read = _inner.Read(buffer, offset, count);
|
||||
|
||||
Accumulate(read);
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
private async ValueTask ThrottleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_bytesInWindow < _bytesPerSecond)
|
||||
return;
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(_windowStartTimestamp);
|
||||
var remaining = TimeSpan.FromSeconds(1) - elapsed;
|
||||
|
||||
if (remaining > TimeSpan.Zero)
|
||||
await Task.Delay(remaining, cancellationToken);
|
||||
|
||||
_windowStartTimestamp = Stopwatch.GetTimestamp();
|
||||
_bytesInWindow = 0;
|
||||
}
|
||||
|
||||
private void Accumulate(int read)
|
||||
{
|
||||
if (read <= 0)
|
||||
return;
|
||||
|
||||
if (Stopwatch.GetElapsedTime(_windowStartTimestamp) >= TimeSpan.FromSeconds(1))
|
||||
{
|
||||
_windowStartTimestamp = Stopwatch.GetTimestamp();
|
||||
_bytesInWindow = 0;
|
||||
}
|
||||
|
||||
_bytesInWindow += read;
|
||||
}
|
||||
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => _inner.CanSeek;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _inner.Length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _inner.Position;
|
||||
set => _inner.Position = value;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
|
||||
|
||||
public override void Flush() => _inner.Flush();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
_inner.Dispose();
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await _inner.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ public static class Services
|
|||
builder.Services.AddSingleton<IServerAddressProvider, ServerAddressProvider>();
|
||||
builder.Services.AddSingleton<IVersionProvider, VersionProvider>();
|
||||
builder.Services.AddScoped<IChatClient, ServerChatClient>();
|
||||
builder.Services.AddScoped<LANCommander.Server.Endpoints.DownloadThrottle>();
|
||||
|
||||
builder.Services.AddLANCommanderClient<Settings.Settings>();
|
||||
builder.Services.AddLANCommanderServer();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,21 @@
|
|||
<FormItem Label="Collections">
|
||||
<TransferInput LeftTitle="Available" RightTitle="Allowed" DataSource="Collections" TitleSelector="c => c.Name" @bind-Values="SelectedCollections" />
|
||||
</FormItem>
|
||||
<FormItem Label="Storage Quota (MB)" Help="0 or blank = unlimited">
|
||||
<AntDesign.InputNumber TValue="int?" @bind-Value="Role.StorageQuotaMB" Min="0" />
|
||||
</FormItem>
|
||||
<FormItem Label="Download Speed (KB/s)" Help="0 or blank = unlimited">
|
||||
<AntDesign.InputNumber TValue="int?" @bind-Value="Role.DownloadSpeedKBps" Min="0" />
|
||||
</FormItem>
|
||||
<FormItem Label="Cloud Saves" Help="Disable cloud saves for users in this role">
|
||||
<Select TItemValue="bool?" TItem="bool?" @bind-Value="Role.EnableSaves">
|
||||
<SelectOptions>
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)null" Label="Not set" />
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)true" Label="Enabled" />
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)false" Label="Disabled" />
|
||||
</SelectOptions>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</PageContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,29 @@
|
|||
<Input @bind-Value="context.Email" />
|
||||
</FormItem>
|
||||
|
||||
@if (IsAdministrator)
|
||||
{
|
||||
<Divider>Limits</Divider>
|
||||
|
||||
<FormItem Label="Storage Quota (MB)" Help="0 or blank = unlimited; overrides role limits">
|
||||
<AntDesign.InputNumber TValue="int?" @bind-Value="context.StorageQuotaMB" Min="0" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem Label="Download Speed (KB/s)" Help="0 or blank = unlimited; overrides role limits">
|
||||
<AntDesign.InputNumber TValue="int?" @bind-Value="context.DownloadSpeedKBps" Min="0" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem Label="Cloud Saves" Help="Overrides role limits">
|
||||
<Select TItemValue="bool?" TItem="bool?" @bind-Value="context.EnableSaves">
|
||||
<SelectOptions>
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)null" Label="Not set" />
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)true" Label="Enabled" />
|
||||
<SelectOption TItemValue="bool?" TItem="bool?" Value="(bool?)false" Label="Disabled" />
|
||||
</SelectOptions>
|
||||
</Select>
|
||||
</FormItem>
|
||||
}
|
||||
|
||||
<Divider>Custom Fields</Divider>
|
||||
|
||||
@if (context.CustomFields == null || context.CustomFields?.Count == 0)
|
||||
|
|
@ -76,11 +99,14 @@
|
|||
Form<User> Form;
|
||||
|
||||
bool FieldsModified = false;
|
||||
bool IsAdministrator = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
|
||||
IsAdministrator = authState.User.IsInRole(RoleService.AdministratorRoleName);
|
||||
|
||||
if (authState.User.Identity.IsAuthenticated)
|
||||
{
|
||||
User = await UserService
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue