Add limit options for saves, user storage, and download speed

Implements #85, #84, #100
This commit is contained in:
Pat Hartl 2026-06-28 14:09:02 -05:00
parent 8fdb6f74cb
commit fa1559c367
31 changed files with 20568 additions and 31 deletions

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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)");

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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");

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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
{

View file

@ -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;

View file

@ -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" />

View 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;
}
}

View file

@ -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)

View file

@ -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;
}

View file

@ -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");
}

View file

@ -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");

View 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);
}
}

View file

@ -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(

View file

@ -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

View 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();
}
}
}

View 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();
}
}

View file

@ -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();

View file

@ -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>

View file

@ -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