Backend support for game saves.

This commit is contained in:
Pat Hartl 2023-01-17 17:57:12 -06:00
parent 90b9d3bb75
commit 4f07c62247
16 changed files with 231 additions and 18 deletions

View file

@ -0,0 +1,96 @@
using LANCommander.Data;
using LANCommander.Data.Models;
using LANCommander.Extensions;
using LANCommander.Models;
using LANCommander.SDK;
using LANCommander.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace LANCommander.Controllers.Api
{
[Authorize(AuthenticationSchemes = "Bearer")]
[Route("api/[controller]")]
[ApiController]
public class GameSavesController : ControllerBase
{
private readonly GameSaveService GameSaveService;
private readonly GameService GameService;
private readonly UserManager<User> UserManager;
public GameSavesController(GameSaveService gameSaveService, GameService gameService, UserManager<User> userManager)
{
GameSaveService = gameSaveService;
GameService = gameService;
UserManager = userManager;
}
[HttpGet("{id}")]
public async Task<GameSave> Get(Guid id)
{
var gameSave = await GameSaveService.Get(id);
if (gameSave == null || gameSave.User == null)
throw new FileNotFoundException();
if (gameSave.User.UserName != HttpContext.User.Identity.Name)
throw new UnauthorizedAccessException();
return await GameSaveService.Get(id);
}
[HttpGet("{id}/Download")]
public async Task<IActionResult> Download(Guid id)
{
var game = await GameService.Get(id);
if (game == null)
return NotFound();
var user = await UserManager.GetUserAsync(User);
if (user == null)
return NotFound();
var path = GameSaveService.GetSavePath(game.Id, user.Id);
if (!System.IO.File.Exists(path))
return NotFound();
return File(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read), "application/octet-stream", $"{game.Id}.zip");
}
[HttpPost("{id}/Upload")]
public async Task<IActionResult> Upload(Guid id, [FromForm] SaveUpload save)
{
// Arbitrary file size limit of 25MB
if (save.File.Length > (ByteSizeLib.ByteSize.BytesInMebiByte * 25))
return BadRequest("Save file archive is too large");
var game = await GameService.Get(id);
if (game == null)
return NotFound();
var user = await UserManager.GetUserAsync(User);
if (user == null)
return NotFound();
var path = GameSaveService.GetSavePath(game.Id, user.Id);
var fileInfo = new FileInfo(path);
if (!Directory.Exists(fileInfo.Directory.FullName))
Directory.CreateDirectory(fileInfo.Directory.FullName);
using (var stream = System.IO.File.Create(path))
{
await save.File.CopyToAsync(stream);
}
return Ok();
}
}
}

View file

@ -30,6 +30,7 @@ namespace LANCommander.Controllers
model.TotalStorageSize = ByteSize.FromBytes(drives.Where(d => d.IsReady && d.Name == root).Sum(d => d.TotalSize));
model.TotalAvailableFreeSpace = ByteSize.FromBytes(drives.Where(d => d.IsReady && d.Name == root).Sum(d => d.AvailableFreeSpace));
model.TotalUploadDirectorySize = ByteSize.FromBytes(new DirectoryInfo("Upload").EnumerateFiles().Sum(f => f.Length));
model.TotalSaveDirectorySize = ByteSize.FromBytes(new DirectoryInfo("Save").EnumerateFiles().Sum(f => f.Length));
model.GameCount = GameService.Get().Count;

View file

@ -45,11 +45,18 @@ namespace LANCommander.Controllers
foreach (var user in UserManager.Users)
{
var savePath = Path.Combine("Save", user.Id.ToString());
long saveSize = 0;
if (Directory.Exists(savePath))
saveSize = new DirectoryInfo(savePath).EnumerateFiles().Sum(f => f.Length);
users.Add(new UserViewModel()
{
Id = user.Id,
UserName = user.UserName,
Roles = await UserManager.GetRolesAsync(user)
Roles = await UserManager.GetRolesAsync(user),
SavesSize = saveSize
});
}

View file

@ -79,6 +79,18 @@ namespace LANCommander.Data
g => g.HasOne<Company>().WithMany().HasForeignKey("PublisherId"),
g => g.HasOne<Game>().WithMany().HasForeignKey("GameId")
);
builder.Entity<User>()
.HasMany(u => u.GameSaves)
.WithOne(gs => gs.User)
.IsRequired(true)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<Game>()
.HasMany(g => g.GameSaves)
.WithOne(gs => gs.Game)
.IsRequired(true)
.OnDelete(DeleteBehavior.NoAction);
}
public DbSet<Game>? Games { get; set; }

View file

@ -5,6 +5,8 @@
Install,
Uninstall,
NameChange,
KeyChange
KeyChange,
SaveUpload,
SaveDownload
}
}

View file

@ -29,6 +29,7 @@ namespace LANCommander.Data.Models
public virtual ICollection<Company>? Developers { get; set; }
public virtual ICollection<Archive>? Archives { get; set; }
public virtual ICollection<Script>? Scripts { get; set; }
public virtual ICollection<GameSave>? GameSaves { get; set; }
public string? ValidKeyRegex { get; set; }
public virtual ICollection<Key>? Keys { get; set; }

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace LANCommander.Data.Models
{
public class GameSave : BaseModel
{
public Guid GameId { get; set; }
[JsonIgnore]
[ForeignKey(nameof(GameId))]
[InverseProperty("GameSaves")]
public virtual Game? Game { get; set; }
public Guid UserId { get; set; }
[ForeignKey(nameof(UserId))]
[InverseProperty("GameSaves")]
public virtual User? User { get; set; }
}
}

View file

@ -41,5 +41,8 @@ namespace LANCommander.Data.Models
public string? RefreshToken { get; set; }
[JsonIgnore]
public DateTime RefreshTokenExpiration { get; set; }
[JsonIgnore]
public virtual ICollection<GameSave>? GameSaves { get; set; }
}
}

View file

@ -29,7 +29,7 @@ namespace LANCommander.Migrations
b.HasIndex("GamesId");
b.ToTable("CategoryGame");
b.ToTable("CategoryGame", (string)null);
});
modelBuilder.Entity("GameDeveloper", b =>
@ -44,7 +44,7 @@ namespace LANCommander.Migrations
b.HasIndex("GameId");
b.ToTable("GameDeveloper");
b.ToTable("GameDeveloper", (string)null);
});
modelBuilder.Entity("GameGenre", b =>
@ -59,7 +59,7 @@ namespace LANCommander.Migrations
b.HasIndex("GenresId");
b.ToTable("GameGenre");
b.ToTable("GameGenre", (string)null);
});
modelBuilder.Entity("GamePublisher", b =>
@ -74,7 +74,7 @@ namespace LANCommander.Migrations
b.HasIndex("PublisherId");
b.ToTable("GamePublisher");
b.ToTable("GamePublisher", (string)null);
});
modelBuilder.Entity("GameTag", b =>
@ -89,7 +89,7 @@ namespace LANCommander.Migrations
b.HasIndex("TagsId");
b.ToTable("GameTag");
b.ToTable("GameTag", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Action", b =>
@ -140,7 +140,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Actions");
b.ToTable("Actions", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Archive", b =>
@ -194,7 +194,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Archive");
b.ToTable("Archive", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Category", b =>
@ -230,7 +230,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Categories");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Company", b =>
@ -261,7 +261,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Companies");
b.ToTable("Companies", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Game", b =>
@ -316,7 +316,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Games");
b.ToTable("Games", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Genre", b =>
@ -347,7 +347,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Genres");
b.ToTable("Genres", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Key", b =>
@ -407,7 +407,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Keys");
b.ToTable("Keys", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b =>
@ -458,7 +458,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("MultiplayerModes");
b.ToTable("MultiplayerModes", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Role", b =>
@ -535,7 +535,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Scripts");
b.ToTable("Scripts", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.Tag", b =>
@ -566,7 +566,7 @@ namespace LANCommander.Migrations
b.HasIndex("UpdatedById");
b.ToTable("Tags");
b.ToTable("Tags", (string)null);
});
modelBuilder.Entity("LANCommander.Data.Models.User", b =>

View file

@ -7,6 +7,7 @@ namespace LANCommander.Models
public ByteSize TotalAvailableFreeSpace { get; set; }
public ByteSize TotalStorageSize { get; set; }
public ByteSize TotalUploadDirectorySize { get; set; }
public ByteSize TotalSaveDirectorySize { get; set; }
public ByteSize TotalOtherSize {
get
{

View file

@ -0,0 +1,8 @@
namespace LANCommander.Models
{
public class SaveUpload
{
public Guid GameId { get; set; }
public IFormFile File { get; set; }
}
}

View file

@ -5,5 +5,6 @@
public Guid Id { get; set; }
public string UserName { get; set; }
public IEnumerable<string> Roles { get; set; }
public long SavesSize { get; set; }
}
}

View file

@ -110,4 +110,7 @@ if (!Directory.Exists("Upload"))
if (!Directory.Exists("Icon"))
Directory.CreateDirectory("Icon");
if (!Directory.Exists("Save"))
Directory.CreateDirectory("Save");
app.Run();

View file

@ -0,0 +1,49 @@
using LANCommander.Data;
using LANCommander.Data.Models;
using LANCommander.Helpers;
namespace LANCommander.Services
{
public class GameSaveService : BaseDatabaseService<GameSave>
{
private readonly SettingService SettingService;
public GameSaveService(DatabaseContext dbContext, IHttpContextAccessor httpContextAccessor, SettingService settingService) : base(dbContext, httpContextAccessor)
{
SettingService = settingService;
}
public override Task Delete(GameSave entity)
{
FileHelpers.DeleteIfExists(GetSavePath(entity.Id));
return base.Delete(entity);
}
public string GetSavePath(Guid gameId, Guid userId)
{
var save = Get(gs => gs.GameId == gameId && gs.UserId == userId).FirstOrDefault();
if (save == null)
return null;
return GetSavePath(save.Id);
}
public string GetSavePath(Guid id)
{
// Use get with predicate to avoid async
var save = Get(gs => gs.Id == id).FirstOrDefault();
if (save == null)
return null;;
return GetSavePath(save);
}
public string GetSavePath(GameSave save)
{
return Path.Combine("Save", save.UserId.ToString(), $"{save.Id}.zip");
}
}
}

View file

@ -32,15 +32,21 @@
<p class="mb-3">Storage Used: <strong>@(Model.TotalOtherSize + Model.TotalUploadDirectorySize) of @Model.TotalStorageSize</strong></p>
<div class="progress progress-separated mb-3">
<div class="progress-bar bg-primary" role="progressbar" style="width: @Math.Round((Model.TotalUploadDirectorySize.Bytes / Model.TotalStorageSize.Bytes) * 100)%;"></div>
<div class="progress-bar bg-dark" role="progressbar" style="width: @Math.Round((Model.TotalSaveDirectorySize.Bytes / Model.TotalStorageSize.Bytes) * 100)%;"></div>
<div class="progress-bar bg-info" role="progressbar" style="width: @Math.Round((Model.TotalOtherSize.Bytes / Model.TotalStorageSize.Bytes) * 100)%;"></div>
<div class="progress-bar bg-success" role="progressbar" style="width: @Math.Round((Model.TotalAvailableFreeSpace.Bytes / Model.TotalStorageSize.Bytes) * 100)%;"></div>
</div>
<div class="row">
<div class="col-auto d-flex align-items-center pe-2">
<span class="legend me-2 bg-primary"></span>
<span>Uploads</span>
<span>Games</span>
<span class="d-none d-md-inline d-lg-none d-xxl-inline ms-2 text-muted">@Model.TotalUploadDirectorySize</span>
</div>
<div class="col-auto d-flex align-items-center pe-2">
<span class="legend me-2 bg-dark"></span>
<span>Saves</span>
<span class="d-none d-md-inline d-lg-none d-xxl-inline ms-2 text-muted">@Model.TotalSaveDirectorySize</span>
</div>
<div class="col-auto d-flex align-items-center pe-2">
<span class="legend me-2 bg-info"></span>
<span>Other</span>

View file

@ -37,6 +37,7 @@
<tr>
<th>Username</th>
<th>Role</th>
<th>Saves</th>
<th></th>
</tr>
</thead>
@ -51,6 +52,9 @@
<td>
@String.Join(", ", item.Roles)
</td>
<td>
@ByteSizeLib.ByteSize.FromBytes(item.SavesSize)
</td>
<td>
<div class="btn-list flex-nowrap justify-content-end">
@if (!item.Roles.Any(r => r == "Administrator"))