diff --git a/Common/VersionHelpers.cs b/Common/VersionHelpers.cs index f6d3bb40..bbaa12a4 100644 --- a/Common/VersionHelpers.cs +++ b/Common/VersionHelpers.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Reflection; using System.Runtime.InteropServices; using System.Text; diff --git a/DatabaseTools/Services/CallbackTextWriter.cs b/DatabaseTools/Services/CallbackTextWriter.cs index 6a56c930..3e7a577a 100644 --- a/DatabaseTools/Services/CallbackTextWriter.cs +++ b/DatabaseTools/Services/CallbackTextWriter.cs @@ -62,7 +62,7 @@ public override void Flush() } var line = _buffer.ToString(); - _buffer.Clear(); + _ = _buffer.Clear(); _onLine(line); } } diff --git a/Definitions/Database/Base/DbCoreObject.cs b/Definitions/Database/Base/DbCoreObject.cs index e48d6348..ce9864d4 100644 --- a/Definitions/Database/Base/DbCoreObject.cs +++ b/Definitions/Database/Base/DbCoreObject.cs @@ -39,4 +39,20 @@ public abstract class DbCoreObject : DbIdObject, IDbName, IDbDescription, IDbMet public DateOnly UploadedDate { get; set; } #endregion + + #region Ownership + + /// + /// The user who owns (uploaded) this object. + /// for objects not owned by any user (e.g. imported/vanilla). + /// + public UniqueObjectId? OwnerUserId { get; set; } + + /// + /// Navigation property to the owning user. + /// + [ForeignKey(nameof(OwnerUserId))] + public TblUser? OwnerUser { get; set; } + + #endregion } diff --git a/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.Designer.cs b/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.Designer.cs new file mode 100644 index 00000000..df4640f2 --- /dev/null +++ b/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.Designer.cs @@ -0,0 +1,2722 @@ +// +using System; +using Definitions.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Definitions.Database.Migrations +{ + [DbContext(typeof(LocoDbContext))] + [Migration("20260902133456_UnknownBulkUpgrades")] + partial class UnknownBulkUpgrades + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Definitions.Database.TblAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Definitions.Database.TblDatObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatChecksum") + .HasColumnType("INTEGER"); + + b.Property("DatName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectId") + .HasColumnType("INTEGER"); + + b.Property("xxHash3") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ObjectId"); + + b.HasIndex("xxHash3") + .IsUnique(); + + b.HasIndex("DatName", "DatChecksum") + .IsUnique() + .IsDescending(true, false); + + b.ToTable("DatObjects"); + }); + + modelBuilder.Entity("Definitions.Database.TblLicence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Licences"); + }); + + modelBuilder.Entity("Definitions.Database.TblObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Availability") + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LicenceId") + .HasColumnType("INTEGER"); + + b.Property("ModifiedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectSource") + .HasColumnType("INTEGER"); + + b.Property("ObjectType") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("SubObjectId") + .HasColumnType("INTEGER"); + + b.Property("UploadedDate") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("TEXT") + .HasDefaultValueSql("date('now')"); + + b.Property("VehicleType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Objects"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectAirport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("LargeTiles") + .HasColumnType("INTEGER"); + + b.Property("MaxX") + .HasColumnType("INTEGER"); + + b.Property("MaxY") + .HasColumnType("INTEGER"); + + b.Property("MinX") + .HasColumnType("INTEGER"); + + b.Property("MinY") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjAirport"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectBridge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaseCostFactor") + .HasColumnType("INTEGER"); + + b.Property("ClearHeight") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DeckDepth") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("DisabledTrackFlags") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("HeightCostFactor") + .HasColumnType("INTEGER"); + + b.Property("MaxHeight") + .HasColumnType("INTEGER"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("PillarSpacing") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("SpanLength") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjBridge"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectBuilding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageNumberOnMap") + .HasColumnType("INTEGER"); + + b.Property("Colours") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DemolishRatingReduction") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("GeneratorFunction") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("ScaffoldingColour") + .HasColumnType("INTEGER"); + + b.Property("ScaffoldingSegmentType") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjBuilding"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCargo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CargoCategory") + .HasColumnType("INTEGER"); + + b.Property("CargoTransferTime") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("MaxNonPremiumDays") + .HasColumnType("INTEGER"); + + b.Property("NonPremiumRate") + .HasColumnType("INTEGER"); + + b.Property("NumPlatformVariations") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("PaymentFactor") + .HasColumnType("INTEGER"); + + b.Property("PaymentIndex") + .HasColumnType("INTEGER"); + + b.Property("PenaltyRate") + .HasColumnType("INTEGER"); + + b.Property("PremiumDays") + .HasColumnType("INTEGER"); + + b.Property("StationCargoDensity") + .HasColumnType("INTEGER"); + + b.Property("UnitSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjCargo"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCliffEdge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjCliffEdge"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectClimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstSeason") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SeasonLength1") + .HasColumnType("INTEGER"); + + b.Property("SeasonLength2") + .HasColumnType("INTEGER"); + + b.Property("SeasonLength3") + .HasColumnType("INTEGER"); + + b.Property("SeasonLength4") + .HasColumnType("INTEGER"); + + b.Property("SummerSnowLine") + .HasColumnType("INTEGER"); + + b.Property("WinterSnowLine") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjClimate"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCompetitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aggressiveness") + .HasColumnType("INTEGER"); + + b.Property("AvailableNamePrefixes") + .HasColumnType("INTEGER"); + + b.Property("AvailableNameSuffixes") + .HasColumnType("INTEGER"); + + b.Property("Competitiveness") + .HasColumnType("INTEGER"); + + b.Property("Emotions") + .HasColumnType("INTEGER"); + + b.Property("Intelligence") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjCompetitor"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCurrency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Factor") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Separator") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjCurrency"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectDock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BoatPositionX") + .HasColumnType("INTEGER"); + + b.Property("BoatPositionY") + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("NumBuildingPartAnimations") + .HasColumnType("INTEGER"); + + b.Property("NumBuildingVariationParts") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjDock"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectHillShapes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HillHeightMapCount") + .HasColumnType("INTEGER"); + + b.Property("IsHeightMap") + .HasColumnType("INTEGER"); + + b.Property("MountainHeightMapCount") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjHillShapes"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectIndustry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("BuildingSizeFlags") + .HasColumnType("INTEGER"); + + b.Property("Colours") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("FarmGrowthStageWithNoProduction") + .HasColumnType("INTEGER"); + + b.Property("FarmIdealSize") + .HasColumnType("INTEGER"); + + b.Property("FarmImagesPerGrowthStage") + .HasColumnType("INTEGER"); + + b.Property("FarmNumStagesOfGrowth") + .HasColumnType("INTEGER"); + + b.Property("FarmTileNumImageAngles") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("MapColour") + .HasColumnType("INTEGER"); + + b.Property("MaxNumBuildings") + .HasColumnType("INTEGER"); + + b.Property("MinNumBuildings") + .HasColumnType("INTEGER"); + + b.Property("MonthlyClosureChance") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("ScaffoldingColour") + .HasColumnType("INTEGER"); + + b.Property("ScaffoldingSegmentType") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("TotalOfTypeInScenario") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjIndustry"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectInterface", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Colour_11") + .HasColumnType("INTEGER"); + + b.Property("CompanyInfoToolbarColour") + .HasColumnType("INTEGER"); + + b.Property("ErrorColour") + .HasColumnType("INTEGER"); + + b.Property("MapTooltipCargoColour") + .HasColumnType("INTEGER"); + + b.Property("MapTooltipObjectColour") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("TimeToolbarColour") + .HasColumnType("INTEGER"); + + b.Property("TooltipColour") + .HasColumnType("INTEGER"); + + b.Property("TopToolbarPrimaryColour") + .HasColumnType("INTEGER"); + + b.Property("TopToolbarQuaternaryColour") + .HasColumnType("INTEGER"); + + b.Property("TopToolbarSecondaryColour") + .HasColumnType("INTEGER"); + + b.Property("TopToolbarTertiaryColour") + .HasColumnType("INTEGER"); + + b.Property("WindowColour") + .HasColumnType("INTEGER"); + + b.Property("WindowConstructionColour") + .HasColumnType("INTEGER"); + + b.Property("WindowMapColour") + .HasColumnType("INTEGER"); + + b.Property("WindowOptionsColour") + .HasColumnType("INTEGER"); + + b.Property("WindowPlayerColour") + .HasColumnType("INTEGER"); + + b.Property("WindowTerraFormColour") + .HasColumnType("INTEGER"); + + b.Property("WindowTitlebarColour") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjInterface"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectLand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DistributionPattern") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("NumGrowthStages") + .HasColumnType("INTEGER"); + + b.Property("NumImageAngles") + .HasColumnType("INTEGER"); + + b.Property("NumImagesPerGrowthStage") + .HasColumnType("INTEGER"); + + b.Property("NumVariations") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("VariationLikelihood") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjLand"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectLevelCrossing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClosedAnimationFrameCount") + .HasColumnType("INTEGER"); + + b.Property("ClosedAnimationFrameInterval") + .HasColumnType("INTEGER"); + + b.Property("CostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("TransitionAnimationDelayBitmask") + .HasColumnType("INTEGER"); + + b.Property("TransitionAnimationFrameCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjLevelCrossing"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectMissing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatChecksum") + .HasColumnType("INTEGER"); + + b.Property("DatName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DatName", "DatChecksum") + .IsUnique(); + + b.ToTable("ObjectsMissing"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectPack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LicenceId") + .HasColumnType("INTEGER"); + + b.Property("ModifiedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("UploadedDate") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("TEXT") + .HasDefaultValueSql("date('now')"); + + b.HasKey("Id"); + + b.HasIndex("LicenceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OwnerUserId"); + + b.ToTable("ObjectPacks"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRegion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjRegion"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoad", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("PaintStyle") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("RoadPieces") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("TargetTownSize") + .HasColumnType("INTEGER"); + + b.Property("TunnelCostFactor") + .HasColumnType("INTEGER"); + + b.Property("VehicleDisplayListVerticalOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjRoad"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoadExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("PaintStyle") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("RoadPieces") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjRoadExtra"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoadStation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CompatibleRoadObjectCount") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("PaintStyle") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("RoadPieces") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjRoadStation"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectScaffolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjScaffolding"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectScenarioText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjScenarioText"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSnow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjSnow"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("ShouldLoop") + .HasColumnType("INTEGER"); + + b.Property("Volume") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjSound"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSteam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("NumStationaryTicks") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SpriteHeightNegative") + .HasColumnType("INTEGER"); + + b.Property("SpriteHeightPositive") + .HasColumnType("INTEGER"); + + b.Property("SpriteWidth") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjSteam"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectStreetLight", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjStreetLight"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTownNames", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTownNames"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("CurveSpeed") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("StationTrackPieces") + .HasColumnType("INTEGER"); + + b.Property("TrackPieces") + .HasColumnType("INTEGER"); + + b.Property("TunnelCostFactor") + .HasColumnType("INTEGER"); + + b.Property("VehicleDisplayListVerticalOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTrack"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("PaintStyle") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("TrackPieces") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTrackExtra"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackSignal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnimationSpeed") + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("NumFrames") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTrackSignal"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackStation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("PaintStyle") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SellCostFactor") + .HasColumnType("INTEGER"); + + b.Property("TrackPieces") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTrackStation"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTree", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildCostFactor") + .HasColumnType("INTEGER"); + + b.Property("ClearCostFactor") + .HasColumnType("INTEGER"); + + b.Property("Colours") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DemolishRatingReduction") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("InitialHeight") + .HasColumnType("INTEGER"); + + b.Property("NumGrowthStages") + .HasColumnType("INTEGER"); + + b.Property("NumRotations") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SeasonState") + .HasColumnType("INTEGER"); + + b.Property("SeasonalVariants") + .HasColumnType("INTEGER"); + + b.Property("ShadowImageOffset") + .HasColumnType("INTEGER"); + + b.Property("var_04") + .HasColumnType("INTEGER"); + + b.Property("var_05") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTree"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTunnel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjTunnel"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectVehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("DesignedYear") + .HasColumnType("INTEGER"); + + b.Property("DrivingSoundType") + .HasColumnType("INTEGER"); + + b.Property("Flags") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("INTEGER"); + + b.Property("NumCarComponents") + .HasColumnType("INTEGER"); + + b.Property("NumCompatibleVehicles") + .HasColumnType("INTEGER"); + + b.Property("NumRequiredTrackExtras") + .HasColumnType("INTEGER"); + + b.Property("ObsoleteYear") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Power") + .HasColumnType("INTEGER"); + + b.Property("RackSpeed") + .HasColumnType("INTEGER"); + + b.Property("Reliability") + .HasColumnType("INTEGER"); + + b.Property("RunCostFactor") + .HasColumnType("INTEGER"); + + b.Property("RunCostIndex") + .HasColumnType("INTEGER"); + + b.Property("ShipWakeSpacing") + .HasColumnType("INTEGER"); + + b.Property("Speed") + .HasColumnType("INTEGER"); + + b.Property("TrackTypeId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjVehicle"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectWall", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Flags1") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjWall"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectWater", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CostFactor") + .HasColumnType("INTEGER"); + + b.Property("CostIndex") + .HasColumnType("INTEGER"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ObjWater"); + }); + + modelBuilder.Entity("Definitions.Database.TblSC5File", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LicenceId") + .HasColumnType("INTEGER"); + + b.Property("ModifiedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectSource") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("UploadedDate") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("TEXT") + .HasDefaultValueSql("date('now')"); + + b.HasKey("Id"); + + b.HasIndex("LicenceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OwnerUserId"); + + b.ToTable("SC5Files"); + }); + + modelBuilder.Entity("Definitions.Database.TblSC5FilePack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LicenceId") + .HasColumnType("INTEGER"); + + b.Property("ModifiedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("UploadedDate") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("TEXT") + .HasDefaultValueSql("date('now')"); + + b.HasKey("Id"); + + b.HasIndex("LicenceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("OwnerUserId"); + + b.ToTable("SC5FilePacks"); + }); + + modelBuilder.Entity("Definitions.Database.TblStringTableRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectId") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ObjectId"); + + b.HasIndex("Text"); + + b.ToTable("StringTable"); + }); + + modelBuilder.Entity("Definitions.Database.TblTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Definitions.Database.TblUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AssociatedAuthorId") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AssociatedAuthorId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Definitions.Database.TblUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TblAuthorTblObject", b => + { + b.Property("AuthorsId") + .HasColumnType("INTEGER"); + + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.HasKey("AuthorsId", "ObjectsId"); + + b.HasIndex("ObjectsId"); + + b.ToTable("TblAuthorTblObject"); + }); + + modelBuilder.Entity("TblAuthorTblObjectPack", b => + { + b.Property("AuthorsId") + .HasColumnType("INTEGER"); + + b.Property("ObjectPacksId") + .HasColumnType("INTEGER"); + + b.HasKey("AuthorsId", "ObjectPacksId"); + + b.HasIndex("ObjectPacksId"); + + b.ToTable("TblAuthorTblObjectPack"); + }); + + modelBuilder.Entity("TblAuthorTblSC5File", b => + { + b.Property("AuthorsId") + .HasColumnType("INTEGER"); + + b.Property("SC5FilesId") + .HasColumnType("INTEGER"); + + b.HasKey("AuthorsId", "SC5FilesId"); + + b.HasIndex("SC5FilesId"); + + b.ToTable("TblAuthorTblSC5File"); + }); + + modelBuilder.Entity("TblAuthorTblSC5FilePack", b => + { + b.Property("AuthorsId") + .HasColumnType("INTEGER"); + + b.Property("SC5FilePacksId") + .HasColumnType("INTEGER"); + + b.HasKey("AuthorsId", "SC5FilePacksId"); + + b.HasIndex("SC5FilePacksId"); + + b.ToTable("TblAuthorTblSC5FilePack"); + }); + + modelBuilder.Entity("TblObjectPackTblTag", b => + { + b.Property("ObjectPacksId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ObjectPacksId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("TblObjectPackTblTag"); + }); + + modelBuilder.Entity("TblObjectTblObjectPack", b => + { + b.Property("ObjectPacksId") + .HasColumnType("INTEGER"); + + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.HasKey("ObjectPacksId", "ObjectsId"); + + b.HasIndex("ObjectsId"); + + b.ToTable("TblObjectTblObjectPack"); + }); + + modelBuilder.Entity("TblObjectTblTag", b => + { + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ObjectsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("TblObjectTblTag"); + }); + + modelBuilder.Entity("TblSC5FilePackTblTag", b => + { + b.Property("SC5FilePacksId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SC5FilePacksId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("TblSC5FilePackTblTag"); + }); + + modelBuilder.Entity("TblSC5FileTblSC5FilePack", b => + { + b.Property("SC5FilePacksId") + .HasColumnType("INTEGER"); + + b.Property("SC5FilesId") + .HasColumnType("INTEGER"); + + b.HasKey("SC5FilePacksId", "SC5FilesId"); + + b.HasIndex("SC5FilesId"); + + b.ToTable("TblSC5FileTblSC5FilePack"); + }); + + modelBuilder.Entity("TblSC5FileTblTag", b => + { + b.Property("SC5FilesId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SC5FilesId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("TblSC5FileTblTag"); + }); + + modelBuilder.Entity("Definitions.Database.TblDatObject", b => + { + b.HasOne("Definitions.Database.TblObject", "Object") + .WithMany("DatObjects") + .HasForeignKey("ObjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Object"); + }); + + modelBuilder.Entity("Definitions.Database.TblObject", b => + { + b.HasOne("Definitions.Database.TblLicence", "Licence") + .WithMany() + .HasForeignKey("LicenceId"); + + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectAirport", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectBridge", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectBuilding", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCargo", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCliffEdge", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectClimate", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCompetitor", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectCurrency", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectDock", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectHillShapes", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectIndustry", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectInterface", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectLand", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectLevelCrossing", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectPack", b => + { + b.HasOne("Definitions.Database.TblLicence", "Licence") + .WithMany() + .HasForeignKey("LicenceId"); + + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRegion", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoad", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoadExtra", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectRoadStation", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectScaffolding", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectScenarioText", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSnow", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSound", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectSteam", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectStreetLight", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTownNames", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrack", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackExtra", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackSignal", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTrackStation", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTree", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectTunnel", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectVehicle", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectWall", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblObjectWater", b => + { + b.HasOne("Definitions.Database.TblObject", "Parent") + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Definitions.Database.TblSC5File", b => + { + b.HasOne("Definitions.Database.TblLicence", "Licence") + .WithMany() + .HasForeignKey("LicenceId"); + + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Definitions.Database.TblSC5FilePack", b => + { + b.HasOne("Definitions.Database.TblLicence", "Licence") + .WithMany() + .HasForeignKey("LicenceId"); + + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("Definitions.Database.TblStringTableRow", b => + { + b.HasOne("Definitions.Database.TblObject", "Object") + .WithMany("StringTable") + .HasForeignKey("ObjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Object"); + }); + + modelBuilder.Entity("Definitions.Database.TblUser", b => + { + b.HasOne("Definitions.Database.TblAuthor", "AssociatedAuthor") + .WithMany() + .HasForeignKey("AssociatedAuthorId"); + + b.Navigation("AssociatedAuthor"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Definitions.Database.TblUserRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Definitions.Database.TblUserRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblAuthorTblObject", b => + { + b.HasOne("Definitions.Database.TblAuthor", null) + .WithMany() + .HasForeignKey("AuthorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblAuthorTblObjectPack", b => + { + b.HasOne("Definitions.Database.TblAuthor", null) + .WithMany() + .HasForeignKey("AuthorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblObjectPack", null) + .WithMany() + .HasForeignKey("ObjectPacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblAuthorTblSC5File", b => + { + b.HasOne("Definitions.Database.TblAuthor", null) + .WithMany() + .HasForeignKey("AuthorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblSC5File", null) + .WithMany() + .HasForeignKey("SC5FilesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblAuthorTblSC5FilePack", b => + { + b.HasOne("Definitions.Database.TblAuthor", null) + .WithMany() + .HasForeignKey("AuthorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblSC5FilePack", null) + .WithMany() + .HasForeignKey("SC5FilePacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblObjectPackTblTag", b => + { + b.HasOne("Definitions.Database.TblObjectPack", null) + .WithMany() + .HasForeignKey("ObjectPacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblObjectTblObjectPack", b => + { + b.HasOne("Definitions.Database.TblObjectPack", null) + .WithMany() + .HasForeignKey("ObjectPacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblObjectTblTag", b => + { + b.HasOne("Definitions.Database.TblObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblSC5FilePackTblTag", b => + { + b.HasOne("Definitions.Database.TblSC5FilePack", null) + .WithMany() + .HasForeignKey("SC5FilePacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblSC5FileTblSC5FilePack", b => + { + b.HasOne("Definitions.Database.TblSC5FilePack", null) + .WithMany() + .HasForeignKey("SC5FilePacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblSC5File", null) + .WithMany() + .HasForeignKey("SC5FilesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblSC5FileTblTag", b => + { + b.HasOne("Definitions.Database.TblSC5File", null) + .WithMany() + .HasForeignKey("SC5FilesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Definitions.Database.TblTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Definitions.Database.TblObject", b => + { + b.Navigation("DatObjects"); + + b.Navigation("StringTable"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.cs b/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.cs new file mode 100644 index 00000000..139c8404 --- /dev/null +++ b/Definitions/Migrations/20260902133456_UnknownBulkUpgrades.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Definitions.Database.Migrations +{ + /// + public partial class UnknownBulkUpgrades : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "ClosingFrames", + table: "ObjLevelCrossing", + newName: "TransitionAnimationFrameCount"); + + migrationBuilder.RenameColumn( + name: "ClosedFrames", + table: "ObjLevelCrossing", + newName: "TransitionAnimationDelayBitmask"); + + migrationBuilder.RenameColumn( + name: "AnimationSpeed", + table: "ObjLevelCrossing", + newName: "ClosedAnimationFrameInterval"); + + migrationBuilder.RenameColumn( + name: "PlayerInfoToolbarColour", + table: "ObjInterface", + newName: "CompanyInfoToolbarColour"); + + migrationBuilder.RenameColumn( + name: "AvailablePlaystyles", + table: "ObjCompetitor", + newName: "AvailableNameSuffixes"); + + migrationBuilder.AddColumn( + name: "ClosedAnimationFrameCount", + table: "ObjLevelCrossing", + type: "INTEGER", + nullable: false, + defaultValue: (byte)0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClosedAnimationFrameCount", + table: "ObjLevelCrossing"); + + migrationBuilder.RenameColumn( + name: "TransitionAnimationFrameCount", + table: "ObjLevelCrossing", + newName: "ClosingFrames"); + + migrationBuilder.RenameColumn( + name: "TransitionAnimationDelayBitmask", + table: "ObjLevelCrossing", + newName: "ClosedFrames"); + + migrationBuilder.RenameColumn( + name: "ClosedAnimationFrameInterval", + table: "ObjLevelCrossing", + newName: "AnimationSpeed"); + + migrationBuilder.RenameColumn( + name: "CompanyInfoToolbarColour", + table: "ObjInterface", + newName: "PlayerInfoToolbarColour"); + + migrationBuilder.RenameColumn( + name: "AvailableNameSuffixes", + table: "ObjCompetitor", + newName: "AvailablePlaystyles"); + } + } +} diff --git a/Definitions/Migrations/LocoDbModelSnapshot.cs b/Definitions/Migrations/LocoDbModelSnapshot.cs index db9ae8f8..c1e0dfad 100644 --- a/Definitions/Migrations/LocoDbModelSnapshot.cs +++ b/Definitions/Migrations/LocoDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Definitions.Database; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ partial class LocoDbModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("Definitions.Database.TblAuthor", b => { @@ -121,6 +121,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ObjectType") .HasColumnType("INTEGER"); + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + b.Property("SubObjectId") .HasColumnType("INTEGER"); @@ -139,6 +142,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); + b.HasIndex("OwnerUserId"); + b.ToTable("Objects"); }); @@ -430,7 +435,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AvailableNamePrefixes") .HasColumnType("INTEGER"); - b.Property("AvailablePlaystyles") + b.Property("AvailableNameSuffixes") .HasColumnType("INTEGER"); b.Property("Competitiveness") @@ -645,6 +650,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Colour_11") .HasColumnType("INTEGER"); + b.Property("CompanyInfoToolbarColour") + .HasColumnType("INTEGER"); + b.Property("ErrorColour") .HasColumnType("INTEGER"); @@ -657,9 +665,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ParentId") .HasColumnType("INTEGER"); - b.Property("PlayerInfoToolbarColour") - .HasColumnType("INTEGER"); - b.Property("TimeToolbarColour") .HasColumnType("INTEGER"); @@ -761,13 +766,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("AnimationSpeed") - .HasColumnType("INTEGER"); - - b.Property("ClosedFrames") + b.Property("ClosedAnimationFrameCount") .HasColumnType("INTEGER"); - b.Property("ClosingFrames") + b.Property("ClosedAnimationFrameInterval") .HasColumnType("INTEGER"); b.Property("CostFactor") @@ -785,6 +787,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SellCostFactor") .HasColumnType("INTEGER"); + b.Property("TransitionAnimationDelayBitmask") + .HasColumnType("INTEGER"); + + b.Property("TransitionAnimationFrameCount") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("Id") @@ -841,6 +849,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + b.Property("UploadedDate") .ValueGeneratedOnAddOrUpdate() .HasColumnType("TEXT") @@ -853,6 +864,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); + b.HasIndex("OwnerUserId"); + b.ToTable("ObjectPacks"); }); @@ -1573,6 +1586,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ObjectSource") .HasColumnType("INTEGER"); + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + b.Property("UploadedDate") .ValueGeneratedOnAddOrUpdate() .HasColumnType("TEXT") @@ -1585,6 +1601,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); + b.HasIndex("OwnerUserId"); + b.ToTable("SC5Files"); }); @@ -1610,6 +1628,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + b.Property("UploadedDate") .ValueGeneratedOnAddOrUpdate() .HasColumnType("TEXT") @@ -1622,6 +1643,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); + b.HasIndex("OwnerUserId"); + b.ToTable("SC5FilePacks"); }); @@ -2035,7 +2058,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany() .HasForeignKey("LicenceId"); + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); }); modelBuilder.Entity("Definitions.Database.TblObjectAirport", b => @@ -2198,7 +2227,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany() .HasForeignKey("LicenceId"); + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); }); modelBuilder.Entity("Definitions.Database.TblObjectRegion", b => @@ -2427,7 +2462,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany() .HasForeignKey("LicenceId"); + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); }); modelBuilder.Entity("Definitions.Database.TblSC5FilePack", b => @@ -2436,7 +2477,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany() .HasForeignKey("LicenceId"); + b.HasOne("Definitions.Database.TblUser", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId"); + b.Navigation("Licence"); + + b.Navigation("OwnerUser"); }); modelBuilder.Entity("Definitions.Database.TblStringTableRow", b => diff --git a/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs b/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs index 43cf6092..f6d38a6a 100644 --- a/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs +++ b/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs @@ -4,7 +4,6 @@ using Definitions.ObjectModels.Types; using Microsoft.Extensions.Logging; using NuGet.Versioning; -using System.Reflection; using System.Text.Json; using GroupConfigDict = System.Collections.Generic.IReadOnlyDictionary< diff --git a/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs index 728be27d..99a4e58c 100644 --- a/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs +++ b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs @@ -1,6 +1,5 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using System.Reflection; namespace Definitions.ObjectModels.Graphics; diff --git a/Definitions/Web/ClientHelpers.cs b/Definitions/Web/ClientHelpers.cs index 0de9b54d..a1f7fe70 100644 --- a/Definitions/Web/ClientHelpers.cs +++ b/Definitions/Web/ClientHelpers.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System.Net.Http.Json; using System.Text.Json; diff --git a/Definitions/Web/RoutesV1.cs b/Definitions/Web/RoutesV1.cs deleted file mode 100644 index fa80d31b..00000000 --- a/Definitions/Web/RoutesV1.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Definitions.Web; - -public static class RoutesV1 -{ - public const string Prefix = "/v1"; - - // GET - public const string ListObjects = "/objects/list"; - - public const string GetDat = "/objects/getdat"; - public const string GetDatFile = "/objects/getdatfile"; - public const string GetObject = "/objects/getobject"; - public const string GetObjectFile = "/objects/getobjectfile"; - public const string GetObjectImages = "/objects/getobjectimages"; - - public const string ListObjectPacks = "/objectpacks/list"; - public const string GetObjectPack = "/objectpacks/getpack"; - - public const string ListScenarios = "/scenarios/list"; - public const string GetScenario = "/scenarios/getscenario"; - - public const string ListSC5FilePacks = "/sc5filepacks/list"; - public const string GetSC5FilePack = "/sc5filepacks/getpack"; - - public const string ListAuthors = "/authors/list"; - public const string ListLicences = "/licences/list"; - public const string ListTags = "/tags/list"; - - // POST - public const string UploadDat = "/objects/uploaddat"; - public const string UploadObject = "/objects/uploadobject"; - - // PATCH - public const string UpdateDat = "/objects/updatedat"; - public const string UpdateObject = "/objects/updateobject"; -} diff --git a/Definitions/Web/RoutesV2.cs b/Definitions/Web/RoutesV2.cs index bff8c09f..983788fa 100644 --- a/Definitions/Web/RoutesV2.cs +++ b/Definitions/Web/RoutesV2.cs @@ -15,6 +15,7 @@ public static class RoutesV2 // extra Objects routes public const string File = "/file"; public const string Images = "/images"; + public const string FirstImage = "/images/0"; public const string Missing = "/missing"; public const string ResourceRoute = "/{id}"; diff --git a/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs b/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs index e3d8bc27..4ebc3905 100644 --- a/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs +++ b/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs @@ -51,15 +51,15 @@ public string DisplayName public BuildingComponentsViewModel() { - this.WhenAnyValue(x => x.BuildingVariations) + _ = this.WhenAnyValue(x => x.BuildingVariations) .Subscribe(_ => this.RaisePropertyChanged(nameof(BuildingVariationViewModels))) .DisposeWith(_subscriptions); - this.WhenAnyValue(x => x.VerticalLayerSpacing) + _ = this.WhenAnyValue(x => x.VerticalLayerSpacing) .Subscribe(ApplyOffsetToAllLayers) .DisposeWith(_subscriptions); - MessageBus.Current.Listen() + _ = MessageBus.Current.Listen() .Subscribe(UpdateBuildingComponents) .DisposeWith(_subscriptions); } diff --git a/Gui/ViewModels/RequiredObjectsListViewModel.cs b/Gui/ViewModels/RequiredObjectsListViewModel.cs index edecf9ad..01496cc9 100644 --- a/Gui/ViewModels/RequiredObjectsListViewModel.cs +++ b/Gui/ViewModels/RequiredObjectsListViewModel.cs @@ -115,7 +115,7 @@ void RemoveSelectedItem() { if (SelectedItem != null) { - sourceList.Remove(SelectedItem); + _ = sourceList.Remove(SelectedItem); } } diff --git a/Gui/Views/ObjectSelectionWindow.axaml.cs b/Gui/Views/ObjectSelectionWindow.axaml.cs index 2699ab5e..8dd4f2c8 100644 --- a/Gui/Views/ObjectSelectionWindow.axaml.cs +++ b/Gui/Views/ObjectSelectionWindow.axaml.cs @@ -10,7 +10,7 @@ public partial class ObjectSelectionWindow : ReactiveWindow + _ = this.WhenActivated(d => { d(ViewModel!.ConfirmCommand.Subscribe(Observer.Create(_ => Close(ViewModel)))); d(ViewModel!.CancelCommand.Subscribe(Observer.Create(_ => Close(null)))); diff --git a/Index/ObjectIndex.cs b/Index/ObjectIndex.cs index 7d20b1ac..1f6c8f88 100644 --- a/Index/ObjectIndex.cs +++ b/Index/ObjectIndex.cs @@ -82,7 +82,7 @@ private void RemoveFromLookups(ObjectIndexEntry entry) var key = entry.xxHash3.Value; if (_byXxHash3.TryGetValue(key, out var existing) && ReferenceEquals(existing, entry)) { - _byXxHash3.TryRemove(key, out _); + _ = _byXxHash3.TryRemove(key, out _); } } @@ -91,7 +91,7 @@ private void RemoveFromLookups(ObjectIndexEntry entry) var key = (entry.DisplayName, entry.DatChecksum.Value); if (_byNameChecksum.TryGetValue(key, out var existing) && ReferenceEquals(existing, entry)) { - _byNameChecksum.TryRemove(key, out _); + _ = _byNameChecksum.TryRemove(key, out _); } } } @@ -122,7 +122,7 @@ public bool TryFind(ulong xxHash3, out ObjectIndexEntry? entry) private void EnsureLookupsBuilt() { - if ((_byXxHash3.IsEmpty && _byNameChecksum.IsEmpty) && Objects.Count > 0) + if (_byXxHash3.IsEmpty && _byNameChecksum.IsEmpty && Objects.Count > 0) { RebuildLookups(); } diff --git a/ObjectService/Frontend/ObjectExplorerModels.cs b/ObjectService/Frontend/ObjectExplorerModels.cs index 99deee7d..0590f592 100644 --- a/ObjectService/Frontend/ObjectExplorerModels.cs +++ b/ObjectService/Frontend/ObjectExplorerModels.cs @@ -10,6 +10,7 @@ public sealed record ObjectBrowseQuery( ObjectType? ObjectType, ObjectSource? ObjectSource, ObjectAvailability? Availability, + VehicleType? VehicleType = null, int Page = 1, int PageSize = 48); @@ -40,13 +41,16 @@ public sealed record ObjectListItemViewModel( DateOnly UploadedDate, DateOnly? CreatedDate, DateOnly? ModifiedDate, - bool CanDownload) + bool CanDownload, + string? Description) { public string ResolvedTitle => string.IsNullOrWhiteSpace(DisplayName) ? DatName ?? InternalName : DisplayName; public string ApiUrl => $"{RoutesV2.Prefix}{RoutesV2.Objects}/{Id}"; public string DownloadUrl => $"{ApiUrl}{RoutesV2.File}"; + + public string PreviewImageUrl => $"{ApiUrl}{RoutesV2.FirstImage}"; } public sealed record ObjectDetailViewModel( @@ -62,10 +66,9 @@ public sealed record ObjectDetailViewModel( DateOnly? CreatedDate, DateOnly? ModifiedDate, DateOnly UploadedDate, - string? LicenceName, - string? LicenceText, - IReadOnlyList Authors, - IReadOnlyList Tags, + LicenceRefViewModel? Licence, + IReadOnlyList Authors, + IReadOnlyList Tags, IReadOnlyList ObjectPacks, IReadOnlyList Files, IReadOnlyList StringTableGroups, @@ -101,3 +104,7 @@ public sealed record StringTableGroupViewModel( public sealed record StringTableTranslationViewModel( LanguageId Language, string Text); + +public sealed record NamedRefViewModel(UniqueObjectId Id, string Name); + +public sealed record LicenceRefViewModel(UniqueObjectId Id, string Name, string? Text); diff --git a/ObjectService/Frontend/ObjectExplorerService.cs b/ObjectService/Frontend/ObjectExplorerService.cs index afc3f44a..5afb7271 100644 --- a/ObjectService/Frontend/ObjectExplorerService.cs +++ b/ObjectService/Frontend/ObjectExplorerService.cs @@ -44,6 +44,11 @@ public async Task GetObjectsAsync(ObjectBrowseQuery r query = query.Where(x => x.Availability == request.Availability.Value); } + if (request.VehicleType.HasValue) + { + query = query.Where(x => x.VehicleType == request.VehicleType.Value); + } + if (search != null) { query = query.Where(x => @@ -121,10 +126,9 @@ public async Task GetObjectsAsync(ObjectBrowseQuery r obj.CreatedDate, obj.ModifiedDate, obj.UploadedDate, - obj.Licence?.Name, - obj.Licence?.Text, - [.. obj.Authors.Select(x => x.Name).OrderBy(x => x)], - [.. obj.Tags.Select(x => x.Name).OrderBy(x => x)], + obj.Licence is not null ? new LicenceRefViewModel(obj.Licence.Id, obj.Licence.Name, obj.Licence.Text) : null, + [.. obj.Authors.Select(x => new NamedRefViewModel(x.Id, x.Name)).OrderBy(x => x.Name)], + [.. obj.Tags.Select(x => new NamedRefViewModel(x.Id, x.Name)).OrderBy(x => x.Name)], [.. obj.ObjectPacks.Select(x => x.Name).OrderBy(x => x)], files, stringTableGroups, @@ -149,7 +153,8 @@ ObjectListItemViewModel MapBrowseItem(DtoObjectEntry row) row.UploadedDate, row.CreatedDate, row.ModifiedDate, - canDownload); + canDownload, + row.Description); } ObjectFileEntryViewModel BuildFileEntry(DtoObjectPostResponse obj, DtoDatObjectEntry dat) @@ -205,6 +210,14 @@ HttpClient CreateApiClient() var httpContext = _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("An active HTTP request is required to create the ObjectService API client."); var client = _httpClientFactory.CreateClient(); client.BaseAddress = new Uri($"{httpContext.Request.Scheme}://{httpContext.Request.Host}{httpContext.Request.PathBase}/"); + + // Forward the bearer token from the access_token cookie to API calls + var accessToken = httpContext.Request.Cookies["access_token"]; + if (!string.IsNullOrEmpty(accessToken)) + { + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + } + return client; } diff --git a/ObjectService/Identity/DatabaseInitializer.cs b/ObjectService/Identity/DatabaseInitializer.cs new file mode 100644 index 00000000..52567d64 --- /dev/null +++ b/ObjectService/Identity/DatabaseInitializer.cs @@ -0,0 +1,190 @@ +using Definitions.Database; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace ObjectService.Identity; + +/// +/// Runs on application startup to ensure the database schema is migrated and +/// seed data (system admin user, ownership of legacy objects) is in place. +/// +public static class DatabaseInitializer +{ + private const string DefaultAdminEmail = "leftofzen@openloco.io"; + private const string DefaultAdminUsername = "LeftofZen"; + private const string DefaultAdminPassword = "3!D:Gy681%&y(HCg"; + + public static async Task InitializeAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var config = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("DatabaseInitializer"); + + // Recreate DB from scratch when the dev flag is on + var deleteDbOnStartup = config.GetValue("ObjectService:DeleteDatabaseOnStartup") ?? false; + if (deleteDbOnStartup) + { + logger.LogWarning("ObjectService:DeleteDatabaseOnStartup is true — dropping and recreating database"); + await db.Database.EnsureDeletedAsync(); + } + + // Ensure the database file and core schema exist + await db.Database.EnsureCreatedAsync(); + + // Add OwnerUserId column to existing databases that were created before + // DbCoreObject gained the OwnerUserId property. We omit the REFERENCES + // clause because SQLite ALTER TABLE ADD COLUMN has limited FK support; + // EF Core tracks the FK at the model level instead. + foreach (var table in new[] { "Objects", "ObjectPacks", "SC5Files", "SC5FilePacks" }) + { + try + { +#pragma warning disable EF1002 // table names are from a hard-coded array, no injection risk + await db.Database.ExecuteSqlRawAsync( + $"ALTER TABLE \"{table}\" ADD COLUMN \"OwnerUserId\" INTEGER NULL"); +#pragma warning restore EF1002 + logger.LogInformation("Added OwnerUserId column to {Table} table", table); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not add OwnerUserId column to {Table} table (may already exist)", table); + } + } + + // Ensure Admin role + if (!await roleManager.RoleExistsAsync("Admin")) + { + var rr = await roleManager.CreateAsync(new TblUserRole { Name = "Admin" }); + logger.LogInformation(rr.Succeeded + ? "Created Admin role" + : "Failed to create Admin role: {Errors}", string.Join(", ", rr.Errors.Select(e => e.Description))); + } + // Ensure Curator role with permission claims + var curatorRole = await roleManager.FindByNameAsync("Curator"); + if (curatorRole == null) + { + curatorRole = new TblUserRole { Name = "Curator" }; + var cr = await roleManager.CreateAsync(curatorRole); + if (cr.Succeeded) + { + logger.LogInformation("Created Curator role"); + + // Assign curator permissions as role claims + await roleManager.AddClaimAsync(curatorRole, new System.Security.Claims.Claim(LocoPermissions.ClaimType, LocoPermissions.ObjectPacksCreate)); + await roleManager.AddClaimAsync(curatorRole, new System.Security.Claims.Claim(LocoPermissions.ClaimType, LocoPermissions.TagsManage)); + await roleManager.AddClaimAsync(curatorRole, new System.Security.Claims.Claim(LocoPermissions.ClaimType, LocoPermissions.LicenceManage)); + await roleManager.AddClaimAsync(curatorRole, new System.Security.Claims.Claim(LocoPermissions.ClaimType, LocoPermissions.AuthorManage)); + logger.LogInformation("Assigned curator permissions to Curator role"); + } + else + { + logger.LogError("Failed to create Curator role: {Errors}", string.Join(", ", cr.Errors.Select(e => e.Description))); + } + } + else + { + logger.LogInformation("Curator role already exists (Id={Id})", curatorRole.Id); + + // Ensure curator claims exist (idempotent) + var existingClaims = await roleManager.GetClaimsAsync(curatorRole); + var existingPermissionValues = existingClaims.Where(c => c.Type == LocoPermissions.ClaimType).Select(c => c.Value).ToHashSet(); + + foreach (var perm in new[] { LocoPermissions.ObjectPacksCreate, LocoPermissions.TagsManage, LocoPermissions.LicenceManage, LocoPermissions.AuthorManage }) + { + if (!existingPermissionValues.Contains(perm)) + { + var result = await roleManager.AddClaimAsync(curatorRole, new System.Security.Claims.Claim(LocoPermissions.ClaimType, perm)); + if (!result.Succeeded) + { + logger.LogWarning("Could not add claim {Permission} to Curator: {Errors}", perm, string.Join(", ", result.Errors.Select(e => e.Description))); + } + else + { + logger.LogInformation("Added claim {Permission} to Curator role", perm); + } + } + } + } + + // Ensure system admin user + var adminEmail = config["AdminUser:Email"] ?? DefaultAdminEmail; + var adminUsername = config["AdminUser:Username"] ?? DefaultAdminUsername; + var adminPassword = config["AdminUser:Password"] ?? DefaultAdminPassword; + + logger.LogInformation("Ensuring admin user: {Username} / {Email}", adminUsername, adminEmail); + + var adminUser = await userManager.FindByEmailAsync(adminEmail); + if (adminUser == null) + { + adminUser = new TblUser + { + UserName = adminUsername, + Email = adminEmail, + EmailConfirmed = true, + }; + + var cr = await userManager.CreateAsync(adminUser, adminPassword); + if (!cr.Succeeded) + { + logger.LogError("Failed to create admin user: {Errors}", string.Join(", ", cr.Errors.Select(e => e.Description))); + logger.LogError("Password rules — Digit:{RD} Lower:{RL} Upper:{RU} NonAlpha:{RNA} MinLen:{MinLen}", + userManager.Options.Password.RequireDigit, + userManager.Options.Password.RequireLowercase, + userManager.Options.Password.RequireUppercase, + userManager.Options.Password.RequireNonAlphanumeric, + userManager.Options.Password.RequiredLength); + return; // let app start; admin features won't work + } + + logger.LogInformation("Created system admin user {Username}", adminUsername); + } + else + { + logger.LogInformation("Admin user {Username} already exists (Id={Id})", adminUsername, adminUser.Id); + } + + // Ensure admin role assignment + if (!await userManager.IsInRoleAsync(adminUser, "Admin")) + { + await userManager.AddToRoleAsync(adminUser, "Admin"); + logger.LogInformation("Assigned Admin role to {Username}", adminUsername); + } + + // Ensure every user has the DisplayNameChange user claim (idempotent) + var allUsers = await userManager.Users.ToListAsync(); + foreach (var u in allUsers) + { + var existingUserClaims = await userManager.GetClaimsAsync(u); + if (!existingUserClaims.Any(c => c.Type == LocoPermissions.ClaimType && c.Value == LocoPermissions.DisplayNameChange)) + { + var claimResult = await userManager.AddClaimAsync(u, + new System.Security.Claims.Claim(LocoPermissions.ClaimType, LocoPermissions.DisplayNameChange)); + if (claimResult.Succeeded) + { + logger.LogInformation("Granted {Permission} user claim to {Username}", LocoPermissions.DisplayNameChange, u.UserName); + } + else + { + logger.LogWarning("Failed to grant {Permission} to {Username}: {Errors}", + LocoPermissions.DisplayNameChange, u.UserName, + string.Join(", ", claimResult.Errors.Select(e => e.Description))); + } + } + } + + // Assign unowned objects to admin + var unowned = await db.Objects.Where(o => o.OwnerUserId == null).ToListAsync(); + if (unowned.Count > 0) + { + foreach (var obj in unowned) + obj.OwnerUserId = adminUser.Id; + await db.SaveChangesAsync(); + logger.LogInformation("Assigned {Count} unowned objects to admin", unowned.Count); + } + + logger.LogInformation("Database initialization complete (Admin exists={Exists})", adminUser != null); + } +} diff --git a/ObjectService/Identity/LocoPermissions.cs b/ObjectService/Identity/LocoPermissions.cs new file mode 100644 index 00000000..5937ffe6 --- /dev/null +++ b/ObjectService/Identity/LocoPermissions.cs @@ -0,0 +1,33 @@ +namespace ObjectService.Identity; + +/// +/// Well-known permission values stored as claims of type "permission" +/// on ASP.NET Identity roles. Admin users automatically have every +/// permission without needing an explicit claim. +/// +public static class LocoPermissions +{ + /// Claim type used for permission claims. + public const string ClaimType = "permission"; + + /// Allowed to create object packs (and scenario packs). + public const string ObjectPacksCreate = "objectpacks:create"; + + /// Allowed to modify/delete object packs. + public const string ObjectPacksModify = "objectpacks:modify"; + + /// Allowed to modify/delete scenario packs. + public const string SC5FilePacksModify = "sc5filepacks:modify"; + + /// Allowed to add/remove tags on any entity. + public const string TagsManage = "tags:manage"; + + /// Allowed to set the licence on any entity. + public const string LicenceManage = "licence:manage"; + + /// Allowed to set the author on any entity. + public const string AuthorManage = "author:manage"; + + /// Allowed to change own display name. + public const string DisplayNameChange = "displayname:change"; +} \ No newline at end of file diff --git a/ObjectService/Identity/ObjectOwnershipHandler.cs b/ObjectService/Identity/ObjectOwnershipHandler.cs new file mode 100644 index 00000000..5c456642 --- /dev/null +++ b/ObjectService/Identity/ObjectOwnershipHandler.cs @@ -0,0 +1,76 @@ +using Definitions.Database; +using Microsoft.AspNetCore.Authorization; +using Definitions.ObjectModels.Types; +using Microsoft.EntityFrameworkCore; + +namespace ObjectService.Identity; + +/// +/// Requirement that the authenticated user must own the target object (or be an admin). +/// +public class ObjectOwnershipRequirement : IAuthorizationRequirement +{ +} + +/// +/// Handles by checking if the current user +/// is the owner of the object identified by the {id} route value, or is in the "Admin" role. +/// Vanilla game objects (LocomotionSteam / LocomotionGoG) are never editable by anyone. +/// +public class ObjectOwnershipHandler : AuthorizationHandler +{ + private readonly LocoDbContext _db; + + public ObjectOwnershipHandler(LocoDbContext db) + { + _db = db; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ObjectOwnershipRequirement requirement) + { + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + return; + } + + if (context.Resource is HttpContext httpContext) + { + var routeId = httpContext.Request.RouteValues["id"]?.ToString(); + if (string.IsNullOrEmpty(routeId) || !ulong.TryParse(routeId, out var objectId)) + { + // Cannot determine the object — fall through to default (deny). + return; + } + + var userIdClaim = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !ulong.TryParse(userIdClaim, out var userId)) + { + return; + } + + // Check if this object is owned by the current user + var obj = await _db.Objects + .AsNoTracking() + .Where(x => x.Id == objectId) + .Select(x => new { x.OwnerUserId, x.ObjectSource }) + .SingleOrDefaultAsync(); + + if (obj == null) + { + return; + } + + // Vanilla game objects (original Locomotion assets) can not be edited + if (obj.ObjectSource is ObjectSource.LocomotionSteam or ObjectSource.LocomotionGoG) + { + return; + } + + if (obj.OwnerUserId == userId) + { + context.Succeed(requirement); + } + } + } +} \ No newline at end of file diff --git a/ObjectService/Identity/PermissionHandler.cs b/ObjectService/Identity/PermissionHandler.cs new file mode 100644 index 00000000..b2156512 --- /dev/null +++ b/ObjectService/Identity/PermissionHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ObjectService.Identity; + +/// +/// Checks against the current user. +/// Admin users automatically satisfy every requirement; other users must +/// hold a role claim of type "permission" with the required value. +/// +public class PermissionHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + } + else if (context.User.HasClaim(LocoPermissions.ClaimType, requirement.Permission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ObjectService/Identity/PermissionRequirement.cs b/ObjectService/Identity/PermissionRequirement.cs new file mode 100644 index 00000000..8e27cd6e --- /dev/null +++ b/ObjectService/Identity/PermissionRequirement.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ObjectService.Identity; + +/// +/// Requires the authenticated user to hold a specific permission claim +/// (or be a member of the Admin role). +/// +public class PermissionRequirement(string permission) : IAuthorizationRequirement +{ + public string Permission { get; } = permission; +} \ No newline at end of file diff --git a/ObjectService/Pages/Account/Login.cshtml b/ObjectService/Pages/Account/Login.cshtml new file mode 100644 index 00000000..9b0132cb --- /dev/null +++ b/ObjectService/Pages/Account/Login.cshtml @@ -0,0 +1,40 @@ +@page "/account/login" +@model ObjectService.Pages.Account.LoginModel +@{ + ViewData["Title"] = "Log in"; +} + +
+

Log in

+

Sign in to manage your objects.

+ + @if (!ViewData.ModelState.IsValid) + { +
+
    + @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
  • @error.ErrorMessage
  • + } +
+
+ } + +
+ + +
+ +
+
+ +

+ Don't have an account? Register +

+
\ No newline at end of file diff --git a/ObjectService/Pages/Account/Login.cshtml.cs b/ObjectService/Pages/Account/Login.cshtml.cs new file mode 100644 index 00000000..c73f80ef --- /dev/null +++ b/ObjectService/Pages/Account/Login.cshtml.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; +using Definitions.Database; + +namespace ObjectService.Pages.Account; + +public sealed class LoginModel : PageModel +{ + private readonly SignInManager _signInManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public LoginModel( + SignInManager signInManager, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _signInManager = signInManager; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [BindProperty] + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Please enter a valid email address")] + public string Email { get; set; } = string.Empty; + + [BindProperty] + [Required(ErrorMessage = "Password is required")] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + // Find user by email first + var user = await _signInManager.UserManager.FindByEmailAsync(Email.Trim()); + if (user == null) + { + ModelState.AddModelError(string.Empty, "Invalid email or password."); + return Page(); + } + + var result = await _signInManager.PasswordSignInAsync(user, Password, isPersistent: false, lockoutOnFailure: true); + + if (!result.Succeeded) + { + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, "This account has been locked out due to too many failed login attempts. Please try again later."); + return Page(); + } + + ModelState.AddModelError(string.Empty, "Invalid email or password."); + return Page(); + } + + _logger.LogInformation("User {Email} logged in", Email); + + // Also obtain a bearer token from the Identity API for API calls + await StoreBearerTokenAsync(); + + if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl)) + { + return Redirect(ReturnUrl); + } + + return RedirectToPage("/Account/Manage"); + } + + private async Task StoreBearerTokenAsync() + { + try + { + var client = _httpClientFactory.CreateClient(); + client.BaseAddress = new Uri($"{Request.Scheme}://{Request.Host}"); + + var loginPayload = new { Email = Email.Trim(), Password }; + var response = await client.PostAsJsonAsync("/login?useCookies=false", loginPayload); + + if (response.IsSuccessStatusCode) + { + var tokenResponse = await response.Content.ReadFromJsonAsync(); + if (tokenResponse?.AccessToken != null) + { + Response.Cookies.Append("access_token", tokenResponse.AccessToken, new CookieOptions + { + HttpOnly = true, + Secure = Request.IsHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromHours(1), + }); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to obtain bearer token for user {Email}", Email); + } + } + + private sealed record TokenResponse(string TokenType, string AccessToken, long ExpiresIn, string RefreshToken); +} diff --git a/ObjectService/Pages/Account/Manage.cshtml b/ObjectService/Pages/Account/Manage.cshtml new file mode 100644 index 00000000..57d31a6c --- /dev/null +++ b/ObjectService/Pages/Account/Manage.cshtml @@ -0,0 +1,83 @@ +@page "/account/manage" +@model ObjectService.Pages.Account.ManageModel +@{ + ViewData["Title"] = "My Account"; +} + +
+

My Account

+ + @if (Model.AccountDeleted) + { +
+

Your account has been deleted. Thank you for using OpenLoco Object Service.

+ Return to explorer +
+ } + else + { +
+
+
Username
+
@Model.Username
+
+
+
Email
+
@Model.Email
+
+
+
Objects owned
+
@Model.OwnedObjects.Count
+
+
+ } +
+ +@if (!Model.AccountDeleted && Model.OwnedObjects.Count > 0) +{ +
+

Your Objects

+
+ + + + + + + + + + + + @foreach (var obj in Model.OwnedObjects) + { + + + + + + + + } + +
NameTypeSourceUploaded
@obj.DisplayName@obj.ObjectType@obj.ObjectSource@obj.UploadedDate.ToString("yyyy-MM-dd")View
+
+
+} + +@if (!Model.AccountDeleted) +{ +
+

Delete Account

+

This action cannot be undone. All your uploaded objects will remain but will no longer be associated with your account.

+
+ +
+
+ +
+
+ +
+
+} \ No newline at end of file diff --git a/ObjectService/Pages/Account/Manage.cshtml.cs b/ObjectService/Pages/Account/Manage.cshtml.cs new file mode 100644 index 00000000..ab38ca62 --- /dev/null +++ b/ObjectService/Pages/Account/Manage.cshtml.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Definitions.Database; +using Definitions.ObjectModels.Types; + +namespace ObjectService.Pages.Account; + +[Authorize] +public sealed class ManageModel : PageModel +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly LocoDbContext _db; + + public ManageModel( + UserManager userManager, + SignInManager signInManager, + LocoDbContext db) + { + _userManager = userManager; + _signInManager = signInManager; + _db = db; + } + + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool AccountDeleted { get; set; } + public List OwnedObjects { get; set; } = []; + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return RedirectToPage("/Account/Login"); + } + + Username = user.UserName ?? string.Empty; + Email = user.Email ?? string.Empty; + + OwnedObjects = await _db.Objects + .Where(x => x.OwnerUserId == user.Id) + .Include(x => x.DatObjects) + .OrderByDescending(x => x.UploadedDate) + .Select(x => new OwnedObjectViewModel( + x.Id, + x.Name, + x.ObjectType, + x.ObjectSource, + x.UploadedDate, + x.DatObjects.OrderBy(d => d.DatName).Select(d => d.DatName).FirstOrDefault() ?? x.Name)) + .ToListAsync(); + + return Page(); + } + + public async Task OnPostDeleteAccountAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return RedirectToPage("/Index"); + } + + // Clear ownership references so objects remain but are no longer associated + var ownedObjects = await _db.Objects.Where(x => x.OwnerUserId == user.Id).ToListAsync(); + foreach (var obj in ownedObjects) + { + obj.OwnerUserId = null; + } + + await _db.SaveChangesAsync(); + + // Delete the user + await _userManager.DeleteAsync(user); + await _signInManager.SignOutAsync(); + Response.Cookies.Delete("access_token"); + + AccountDeleted = true; + return Page(); + } + + public async Task OnPostLogoutAsync() + { + await _signInManager.SignOutAsync(); + Response.Cookies.Delete("access_token"); + return RedirectToPage("/Index"); + } + + public sealed record OwnedObjectViewModel( + UniqueObjectId Id, + string InternalName, + ObjectType ObjectType, + ObjectSource ObjectSource, + DateOnly UploadedDate, + string DisplayName); +} \ No newline at end of file diff --git a/ObjectService/Pages/Account/Register.cshtml b/ObjectService/Pages/Account/Register.cshtml new file mode 100644 index 00000000..c14b913b --- /dev/null +++ b/ObjectService/Pages/Account/Register.cshtml @@ -0,0 +1,58 @@ +@page "/account/register" +@model ObjectService.Pages.Account.RegisterModel +@{ + ViewData["Title"] = "Register"; +} + +
+

Create an account

+

Register to upload and manage your own objects.

+ + @if (!ViewData.ModelState.IsValid) + { +
+
    + @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
  • @error.ErrorMessage
  • + } +
+
+ } + + @if (Model.RegistrationSuccess) + { +
+

Registration successful! You are now logged in.

+ Go to your account +
+ } + else + { +
+ + + + +
+ +
+
+ +

+ Already have an account? Log in +

+ } +
\ No newline at end of file diff --git a/ObjectService/Pages/Account/Register.cshtml.cs b/ObjectService/Pages/Account/Register.cshtml.cs new file mode 100644 index 00000000..9ea2391f --- /dev/null +++ b/ObjectService/Pages/Account/Register.cshtml.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; +using Definitions.Database; + +namespace ObjectService.Pages.Account; + +public sealed class RegisterModel : PageModel +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public RegisterModel( + UserManager userManager, + SignInManager signInManager, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [BindProperty] + [Required(ErrorMessage = "Username is required")] + [MinLength(3, ErrorMessage = "Username must be at least 3 characters")] + public string UserName { get; set; } = string.Empty; + + [BindProperty] + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Please enter a valid email address")] + public string Email { get; set; } = string.Empty; + + [BindProperty] + [Required(ErrorMessage = "Password is required")] + [MinLength(6, ErrorMessage = "Password must be at least 6 characters")] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + [BindProperty] + [Required(ErrorMessage = "Please confirm your password")] + [Compare(nameof(Password), ErrorMessage = "Passwords do not match")] + [DataType(DataType.Password)] + public string ConfirmPassword { get; set; } = string.Empty; + + public bool RegistrationSuccess { get; set; } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = new TblUser + { + UserName = UserName.Trim(), + Email = Email.Trim(), + }; + + var result = await _userManager.CreateAsync(user, Password); + + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + return Page(); + } + + _logger.LogInformation("User {UserName} registered successfully", UserName); + + // Sign the user in after registration + await _signInManager.SignInAsync(user, isPersistent: false); + + // Obtain a bearer token for API calls + await StoreBearerTokenAsync(); + + RegistrationSuccess = true; + return Page(); + } + + private async Task StoreBearerTokenAsync() + { + try + { + var client = _httpClientFactory.CreateClient(); + client.BaseAddress = new Uri($"{Request.Scheme}://{Request.Host}"); + + var loginPayload = new { Email = Email.Trim(), Password }; + var response = await client.PostAsJsonAsync("/login?useCookies=false", loginPayload); + + if (response.IsSuccessStatusCode) + { + var tokenResponse = await response.Content.ReadFromJsonAsync(); + if (tokenResponse?.AccessToken != null) + { + Response.Cookies.Append("access_token", tokenResponse.AccessToken, new CookieOptions + { + HttpOnly = true, + Secure = Request.IsHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromHours(1), + }); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to obtain bearer token after registration for user {Email}", Email); + } + } + + private sealed record TokenResponse(string TokenType, string AccessToken, long ExpiresIn, string RefreshToken); +} \ No newline at end of file diff --git a/ObjectService/Pages/Authors/Details.cshtml b/ObjectService/Pages/Authors/Details.cshtml new file mode 100644 index 00000000..04807f9a --- /dev/null +++ b/ObjectService/Pages/Authors/Details.cshtml @@ -0,0 +1,171 @@ +@page "/Authors/{id}" +@model ObjectService.Pages.Authors.DetailsModel +@{ + ViewData["Title"] = Model.Author?.Name ?? "Author"; + var isAdmin = User.IsInRole("Admin"); +} + +@if (Model.Author is null) +{ +
+

Author not found

+

The author you are looking for does not exist.

+ Back to author list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to authors +
+

@Model.Author.Name

+ @if (isAdmin) + { +
+ +
+ +
+
+ } +
+ @if (isAdmin) + { + + } +
+
+ +
+
+

Objects

+ @if (Model.Objects.Count == 0) + { +

No objects are associated with this author.

+ } + else + { +
+ + + + + + + + @foreach (var obj in Model.Objects) + { + + + + } + +
Name
@obj.Name
+
+ } +
+ +
+

Object Packs

+ @if (Model.ObjectPacks.Count == 0) + { +

No object packs are associated with this author.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.ObjectPacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+ +
+

SC5 Files

+ @if (Model.SC5Files.Count == 0) + { +

No SC5 files are associated with this author.

+ } + else + { +
+ + + + + + + + @foreach (var file in Model.SC5Files) + { + + + + } + +
Name
@file.Name
+
+ } +
+ +
+

SC5 File Packs

+ @if (Model.SC5FilePacks.Count == 0) + { +

No SC5 file packs are associated with this author.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.SC5FilePacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+
\ No newline at end of file diff --git a/ObjectService/Pages/Authors/Details.cshtml.cs b/ObjectService/Pages/Authors/Details.cshtml.cs new file mode 100644 index 00000000..a26dc06f --- /dev/null +++ b/ObjectService/Pages/Authors/Details.cshtml.cs @@ -0,0 +1,152 @@ +using Definitions.Database; +using Definitions.DTO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using ObjectService.Services; + +namespace ObjectService.Pages.Authors; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + readonly ICrudService _authorService; + + public DetailsModel(LocoDbContext db, ICrudService authorService) + { + _db = db; + _authorService = authorService; + } + + public TblAuthor? Author { get; private set; } + + public List Objects { get; private set; } = []; + public List ObjectPacks { get; private set; } = []; + public List SC5Files { get; private set; } = []; + public List SC5FilePacks { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + [BindProperty] + public UniqueObjectId Id { get; set; } + + [BindProperty] + public string Name { get; set; } = string.Empty; + + public bool IsAdmin => User.IsInRole("Admin"); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + Author = await _db.Authors + .Include(a => a.Objects) + .Include(a => a.ObjectPacks) + .Include(a => a.SC5Files) + .Include(a => a.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(a => a.Id == id, ct); + + if (Author is null) + { + return NotFound(); + } + + Objects = Author.Objects + .OrderBy(o => o.Name) + .Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)) + .ToList(); + + ObjectPacks = Author.ObjectPacks + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)) + .ToList(); + + SC5Files = Author.SC5Files + .OrderBy(f => f.Name) + .Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)) + .ToList(); + + SC5FilePacks = Author.SC5FilePacks + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)) + .ToList(); + + return Page(); + } + + public async Task OnPostEditAsync() + { + if (!IsAdmin) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Author name is required."; + await ReloadAsync(Id); + return Page(); + } + + try + { + var entry = new DtoAuthorEntry(Id, Name.Trim()); + var updated = await _authorService.UpdateAsync(Id, entry, CancellationToken.None); + if (updated != null) + { + SuccessMessage = $"Author '{Name.Trim()}' updated."; + } + else + { + ErrorMessage = "Author not found."; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error updating author: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!IsAdmin) + return Forbid(); + + var deleted = await _authorService.DeleteAsync(id, CancellationToken.None); + if (deleted) + { + SuccessMessage = "Author deleted."; + return RedirectToPage("/Index", new { category = "authors" }); + } + + await ReloadAsync(id); + ErrorMessage = "Failed to delete author."; + return Page(); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + Author = await _db.Authors + .Include(a => a.Objects) + .Include(a => a.ObjectPacks) + .Include(a => a.SC5Files) + .Include(a => a.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(a => a.Id == id); + + if (Author != null) + { + Objects = Author.Objects.OrderBy(o => o.Name).Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)).ToList(); + ObjectPacks = Author.ObjectPacks.OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)).ToList(); + SC5Files = Author.SC5Files.OrderBy(f => f.Name).Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)).ToList(); + SC5FilePacks = Author.SC5FilePacks.OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)).ToList(); + } + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Pages/Dev/QuickLogin.cshtml b/ObjectService/Pages/Dev/QuickLogin.cshtml new file mode 100644 index 00000000..3ac4f383 --- /dev/null +++ b/ObjectService/Pages/Dev/QuickLogin.cshtml @@ -0,0 +1,2 @@ +@page "/dev/quick-login" +@model ObjectService.Pages.Dev.QuickLoginModel diff --git a/ObjectService/Pages/Dev/QuickLogin.cshtml.cs b/ObjectService/Pages/Dev/QuickLogin.cshtml.cs new file mode 100644 index 00000000..182836fd --- /dev/null +++ b/ObjectService/Pages/Dev/QuickLogin.cshtml.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Definitions.Database; + +namespace ObjectService.Pages.Dev; + +[AllowAnonymous] +public class QuickLoginModel : PageModel +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly IWebHostEnvironment _environment; + + public QuickLoginModel( + SignInManager signInManager, + UserManager userManager, + RoleManager roleManager, + IWebHostEnvironment environment) + { + _signInManager = signInManager; + _userManager = userManager; + _roleManager = roleManager; + _environment = environment; + } + + public async Task OnPostAsync() + { + if (!_environment.IsDevelopment()) + { + return Forbid(); + } + + const string devUserEmail = "dev@localhost"; + const string devUserName = "DevAdmin"; + const string devPassword = "DevPassword123!@#"; + + var user = await _userManager.FindByEmailAsync(devUserEmail); + if (user == null) + { + user = new TblUser + { + UserName = devUserName, + Email = devUserEmail, + EmailConfirmed = true, + }; + + var result = await _userManager.CreateAsync(user, devPassword); + if (!result.Succeeded) + { + return BadRequest("Failed to create dev user"); + } + } + + // Ensure Admin role exists and user has it + if (!await _roleManager.RoleExistsAsync("Admin")) + { + await _roleManager.CreateAsync(new TblUserRole { Name = "Admin" }); + } + + if (!await _userManager.IsInRoleAsync(user, "Admin")) + { + await _userManager.AddToRoleAsync(user, "Admin"); + } + + // Sign in the user + await _signInManager.SignInAsync(user, isPersistent: true); + + // Redirect back to the referring page, or to the admin dashboard + var returnUrl = Request.Headers.Referer.ToString(); + if (string.IsNullOrEmpty(returnUrl) || !Url.IsLocalUrl(returnUrl)) + { + returnUrl = "/manage"; + } + + return Redirect(returnUrl); + } +} diff --git a/ObjectService/Pages/Index.cshtml b/ObjectService/Pages/Index.cshtml index 4b6eb053..ce85d465 100644 --- a/ObjectService/Pages/Index.cshtml +++ b/ObjectService/Pages/Index.cshtml @@ -27,6 +27,7 @@ +
@@ -88,63 +96,146 @@ -
-
- +@* ── Master tab bar (primary navigation) ── *@ + -
- - -
+ + -
- - -
+ @* ── Detail area ── *@ +
+
+ + + -
- - -
+
+ + +
-
- - Reset -
- +
+ + +
-
-
- Matching filters - @Model.Results.FilteredCount -
-
- Current page - @Model.Results.Page / @Model.Results.TotalPages -
-
-
+
+ + Reset +
+ + +
+
+ Matching filters + @Model.Results.FilteredCount +
+
+ Current page + @Model.Results.Page / @Model.Results.TotalPages +
+
+
@if (Model.Results.Items.Count == 0) { @@ -158,49 +249,34 @@ else
@foreach (var item in Model.Results.Items) { -
+ +
+ Preview of @item.ResolvedTitle +

@item.ObjectType@if (item.VehicleType != null){ / @item.VehicleType}

@item.ResolvedTitle

-

@item.InternalName

+

@(item.Description ?? item.InternalName)

- @item.Availability
-
+ }
@@ -210,12 +286,14 @@ else @if (Model.Results.HasPreviousPage) { Previous + asp-page="/Index" + asp-route-category="objects" + asp-route-Search="@Model.Search" + asp-route-ObjectType="@Model.ObjectType" + asp-route-ObjectSource="@Model.ObjectSource" + asp-route-Availability="@Model.Availability" + asp-route-VehicleType="@Model.VehicleType" + asp-route-p="@(Model.Results.Page - 1)">Previous } else { @@ -228,11 +306,13 @@ else { Next + asp-route-VehicleType="@Model.VehicleType" + asp-route-p="@(Model.Results.Page + 1)">Next } else { @@ -240,4 +320,577 @@ else } } +} + + +} +else if (Model.Category == "scenarios") +{ +
+

Scenarios

+

Scenario browsing is coming soon. Scenarios are available through the API.

+
+} +else if (Model.Category == "music") +{ +
+

Music

+

Music browsing is coming soon.

+
+} +else if (Model.Category == "sfx") +{ +
+

Sound Effects

+

Sound effect browsing is coming soon.

+
+} +else if (Model.Category == "objectpacks") +{ +
+
+

Object Packs

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.ObjectPackList.Count > 0) + { +
+ + + + + + + + + + + @if (Model.IsAdmin) { } + + + + @foreach (var pack in Model.ObjectPackList) + { + + + + + + + + + @if (Model.IsAdmin) + { + + } + + } + +
NameDescriptionUploadedObjectsAuthorsTagsLicenceActions
@pack.Name@(pack.Description.Length > 80 ? pack.Description[..80] + "…" : pack.Description)@pack.UploadedDate.ToString("yyyy-MM-dd")@pack.ObjectCount@pack.AuthorCount@pack.TagCount@pack.Licence +
+ + + +
+
+
+ } + else + { +

No object packs found.

+ } +
+} +else if (Model.Category == "sc5files") +{ +
+
+

SC5 Files

+
+ @if (Model.SC5FileList.Count > 0) + { +
+ + + + + + + + + + + + + + + @foreach (var file in Model.SC5FileList) + { + + + + + + + + + + + } + +
NameDescriptionUploadedSourceAuthorsTagsLicencePacks
@file.Name@(file.Description.Length > 80 ? file.Description[..80] + "…" : file.Description)@file.UploadedDate.ToString("yyyy-MM-dd")@file.ObjectSource@file.AuthorCount@file.TagCount@file.Licence@file.PackCount
+
+ } + else + { +

No SC5 files found.

+ } +
+} +else if (Model.Category == "sc5filepacks") +{ +
+
+

SC5 File Packs

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.SC5FilePackList.Count > 0) + { +
+ + + + + + + + + + + @if (Model.IsAdmin) { } + + + + @foreach (var pack in Model.SC5FilePackList) + { + + + + + + + + + @if (Model.IsAdmin) + { + + } + + } + +
NameDescriptionUploadedAuthorsTagsLicenceFilesActions
@pack.Name@(pack.Description.Length > 80 ? pack.Description[..80] + "…" : pack.Description)@pack.UploadedDate.ToString("yyyy-MM-dd")@pack.AuthorCount@pack.TagCount@pack.Licence@pack.FileCount +
+ + + +
+
+
+ } + else + { +

No SC5 file packs found.

+ } +
+} +else if (Model.Category == "authors") +{ +
+
+

Authors

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.AuthorList.Count > 0) + { +
+ + + + + @if (Model.IsAdmin) { } + + + + @foreach (var author in Model.AuthorList) + { + + + @if (Model.IsAdmin) + { + + } + + } + +
NameActions
@author.Name + +
+ + + +
+ +
+
+ } + else + { +

No authors found.

+ } +
+} +else if (Model.Category == "tags") +{ +
+
+

Tags

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.TagList.Count > 0) + { +
+ + + + + @if (Model.IsAdmin) { } + + + + @foreach (var tag in Model.TagList) + { + + + @if (Model.IsAdmin) + { + + } + + } + +
NameActions
@tag.Name + +
+ + + +
+ +
+
+ } + else + { +

No tags found.

+ } +
+} +else if (Model.Category == "licences") +{ +
+
+

Licences

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.LicenceList.Count > 0) + { +
+ + + + + + @if (Model.IsAdmin) { } + + + + @foreach (var licence in Model.LicenceList) + { + + + + @if (Model.IsAdmin) + { + + } + + } + +
NameTextActions
@licence.Name@(licence.Text.Length > 120 ? licence.Text[..120] + "…" : licence.Text) + +
+ + + +
+ +
+
+ } + else + { +

No licences found.

+ } +
+} +else if (Model.Category == "objectsmissing") +{ +
+
+

Missing Objects

+ @if (Model.IsAdmin) + { + + } +
+ @if (Model.IsAdmin) + { + + } + @if (!string.IsNullOrEmpty(Model.SuccessMessage) || !string.IsNullOrEmpty(Model.ErrorMessage)) + { + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + {
@Model.SuccessMessage
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + {
@Model.ErrorMessage
} + } + @if (Model.ObjectsMissingList.Count > 0) + { +
+ + + + + + + @if (Model.IsAdmin) { } + + + + @foreach (var missing in Model.ObjectsMissingList) + { + + + + + @if (Model.IsAdmin) + { + + } + + } + +
Dat NameDat ChecksumObject TypeActions
@missing.DatName@missing.DatChecksum@missing.ObjectType + +
+ + + +
+ +
+
+ } + else + { +

No missing objects found.

+ } +
} diff --git a/ObjectService/Pages/Index.cshtml.cs b/ObjectService/Pages/Index.cshtml.cs index da6c1b7c..b38f2e1a 100644 --- a/ObjectService/Pages/Index.cshtml.cs +++ b/ObjectService/Pages/Index.cshtml.cs @@ -1,18 +1,39 @@ using Definitions; +using Definitions.Database; +using Definitions.DTO; +using Definitions.ObjectModels.Objects.Vehicle; using Definitions.ObjectModels.Types; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; using ObjectService.Frontend; +using ObjectService.Services; namespace ObjectService.Pages; public sealed class IndexModel : PageModel { readonly ObjectExplorerService _explorerService; + readonly LocoDbContext _db; + readonly ICrudService _authorService; + readonly ICrudService _tagService; + readonly ICrudService _licenceService; + readonly ICrudService _objectsMissingService; - public IndexModel(ObjectExplorerService explorerService) + public IndexModel( + ObjectExplorerService explorerService, + LocoDbContext db, + ICrudService authorService, + ICrudService tagService, + ICrudService licenceService, + ICrudService objectsMissingService) { _explorerService = explorerService; + _db = db; + _authorService = authorService; + _tagService = tagService; + _licenceService = licenceService; + _objectsMissingService = objectsMissingService; } [BindProperty(SupportsGet = true)] @@ -27,23 +48,482 @@ public IndexModel(ObjectExplorerService explorerService) [BindProperty(SupportsGet = true)] public ObjectAvailability? Availability { get; set; } - [BindProperty(Name = "page", SupportsGet = true)] + [BindProperty(SupportsGet = true)] + public VehicleType? VehicleType { get; set; } + + [BindProperty(Name = "p", SupportsGet = true)] public int PageNumber { get; set; } = 1; + [BindProperty(SupportsGet = true)] + public string Category { get; set; } = "objects"; + public ObjectBrowsePageViewModel Results { get; private set; } = new(0, 0, 1, 48, []); - public IReadOnlyList ObjectTypes { get; } = [.. Enum.GetValues()]; + public IReadOnlyList ObjectTypes { get; } = [.. Enum.GetValues().OrderBy(t => t.ToString())]; public IReadOnlyList ObjectSources { get; } = [.. Enum.GetValues()]; public IReadOnlyList AvailabilityStates { get; } = [.. Enum.GetValues()]; + public IReadOnlyList VehicleTypes { get; } = [.. Enum.GetValues()]; + + public sealed record TabGroup(string Name, IReadOnlyDictionary Items); + + public IReadOnlyList TabGroups { get; } = + [ + new("Objects", new Dictionary + { + ["objects"] = "Objects", + ["objectpacks"] = "Object\u00A0Packs", + ["objectsmissing"] = "Missing Objects", + }), + new("Audio", new Dictionary + { + ["music"] = "Music", + ["sfx"] = "SFX", + }), + new("Scenarios", new Dictionary + { + ["sc5files"] = "Scenarios", + ["sc5filepacks"] = "Scenario\u00A0Packs", + }), + new("Data", new Dictionary + { + ["authors"] = "Authors", + ["tags"] = "Tags", + ["licences"] = "Licences", + }), + ]; + + public bool IsAdmin => User.IsInRole("Admin"); + + // ── Data lists for database-view categories ── + public List AuthorList { get; private set; } = []; + public List TagList { get; private set; } = []; + public List LicenceList { get; private set; } = []; + public List ObjectPackList { get; private set; } = []; + public List SC5FileList { get; private set; } = []; + public List SC5FilePackList { get; private set; } = []; + public List ObjectsMissingList { get; private set; } = []; + public async Task OnGetAsync(CancellationToken cancellationToken) { - Results = await _explorerService.GetObjectsAsync( - new ObjectBrowseQuery(Search, ObjectType, ObjectSource, Availability, PageNumber), - cancellationToken); + switch (Category) + { + case "objects": + Results = await _explorerService.GetObjectsAsync( + new ObjectBrowseQuery(Search, ObjectType, ObjectSource, Availability, VehicleType, PageNumber), + cancellationToken); + PageNumber = Results.Page; + break; + + case "scenarios": + case "music": + case "sfx": + Results = new(0, 0, 1, 48, []); + break; + + case "objectpacks": + await LoadObjectPacksAsync(cancellationToken); + break; + + case "sc5files": + await LoadSC5FilesAsync(cancellationToken); + break; + + case "sc5filepacks": + await LoadSC5FilePacksAsync(cancellationToken); + break; + + case "authors": + await LoadAuthorsAsync(cancellationToken); + break; + + case "tags": + await LoadTagsAsync(cancellationToken); + break; + + case "licences": + await LoadLicencesAsync(cancellationToken); + break; + + case "objectsmissing": + await LoadObjectsMissingAsync(cancellationToken); + break; + + default: + Results = new(0, 0, 1, 48, []); + break; + } + } +async Task LoadObjectPacksAsync(CancellationToken ct) + { + var packs = await _db.ObjectPacks + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.Licence) + .Include(p => p.Objects) + .OrderByDescending(p => p.UploadedDate) + .ToListAsync(ct); + + ObjectPackList = packs.Select(p => new ObjectPackListViewModel( + p.Id, + p.Name, + p.Description ?? "", + p.UploadedDate, + p.Authors.Count, + p.Tags.Count, + p.Licence?.Name ?? "None", + p.Objects.Count)).ToList(); + } + + async Task LoadSC5FilesAsync(CancellationToken ct) + { + var files = await _db.SC5Files + .Include(f => f.Authors) + .Include(f => f.Tags) + .Include(f => f.Licence) + .Include(f => f.SC5FilePacks) + .OrderByDescending(f => f.UploadedDate) + .ToListAsync(ct); + + SC5FileList = files.Select(f => new SC5FileListViewModel( + f.Id, + f.Name, + f.Description ?? "", + f.UploadedDate, + f.ObjectSource, + f.Authors.Count, + f.Tags.Count, + f.Licence?.Name ?? "None", + f.SC5FilePacks.Count)).ToList(); + } + + async Task LoadSC5FilePacksAsync(CancellationToken ct) + { + var packs = await _db.SC5FilePacks + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.Licence) + .Include(p => p.SC5Files) + .OrderByDescending(p => p.UploadedDate) + .ToListAsync(ct); + + SC5FilePackList = packs.Select(p => new SC5FilePackListViewModel( + p.Id, + p.Name, + p.Description ?? "", + p.UploadedDate, + p.Authors.Count, + p.Tags.Count, + p.Licence?.Name ?? "None", + p.SC5Files.Count)).ToList(); + } + + async Task LoadAuthorsAsync(CancellationToken ct) + { + var authors = await _db.Authors + .OrderBy(a => a.Name) + .ToListAsync(ct); + + AuthorList = authors.Select(a => new AuthorListViewModel(a.Id, a.Name)).ToList(); + } + + async Task LoadTagsAsync(CancellationToken ct) + { + var tags = await _db.Tags + .OrderBy(t => t.Name) + .ToListAsync(ct); + + TagList = tags.Select(t => new TagListViewModel(t.Id, t.Name)).ToList(); + } + + async Task LoadLicencesAsync(CancellationToken ct) + { + var licences = await _db.Licences + .OrderBy(l => l.Name) + .ToListAsync(ct); + + LicenceList = licences.Select(l => new LicenceListViewModel(l.Id, l.Name, l.Text)).ToList(); + } + + async Task LoadObjectsMissingAsync(CancellationToken ct) + { + var missing = await _db.ObjectsMissing + .OrderBy(m => m.DatName) + .ThenBy(m => m.DatChecksum) + .ToListAsync(ct); - PageNumber = Results.Page; + ObjectsMissingList = missing.Select(m => new ObjectsMissingListViewModel(m.Id, m.DatName, m.DatChecksum, m.ObjectType)).ToList(); } + + // ── CRUD form bindings ── + + [BindProperty] + public UniqueObjectId CrudId { get; set; } + + [BindProperty] + public string CrudName { get; set; } = string.Empty; + + [BindProperty] + public string CrudText { get; set; } = string.Empty; + + [BindProperty] + public string CrudDescription { get; set; } = string.Empty; + + [BindProperty] + public uint32_t CrudChecksum { get; set; } + + [BindProperty] + public ObjectType CrudObjectType { get; set; } + + [BindProperty] + public string CrudCategory { get; set; } = string.Empty; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + // ── POST: Create author/tag/licence ── + + public async Task OnPostCreateAsync() + { + if (!IsAdmin) + return Forbid(); + + if (string.IsNullOrWhiteSpace(CrudName)) + { + ErrorMessage = "Name is required."; + return RedirectToPage(new { category = CrudCategory }); + } + + try + { + switch (CrudCategory) + { + case "authors": + { + var entry = new DtoAuthorEntry(0, CrudName.Trim()); + if (!_authorService.TryValidateCreate(entry, out var err)) + { + ErrorMessage = err; + return RedirectToPage(new { category = CrudCategory }); + } + await _authorService.CreateAsync(entry, CancellationToken.None); + SuccessMessage = $"Author '{CrudName.Trim()}' created."; + break; + } + case "tags": + { + var entry = new DtoTagEntry(0, CrudName.Trim()); + if (!_tagService.TryValidateCreate(entry, out var err)) + { + ErrorMessage = err; + return RedirectToPage(new { category = CrudCategory }); + } + await _tagService.CreateAsync(entry, CancellationToken.None); + SuccessMessage = $"Tag '{CrudName.Trim()}' created."; + break; + } + case "licences": + { + var entry = new DtoLicenceEntry(0, CrudName.Trim(), CrudText?.Trim() ?? string.Empty); + if (!_licenceService.TryValidateCreate(entry, out var err)) + { + ErrorMessage = err; + return RedirectToPage(new { category = CrudCategory }); + } + await _licenceService.CreateAsync(entry, CancellationToken.None); + SuccessMessage = $"Licence '{CrudName.Trim()}' created."; + break; + } + case "objectsmissing": + { + var entry = new DtoObjectMissingEntry(0, CrudName.Trim(), CrudChecksum, CrudObjectType); + if (!_objectsMissingService.TryValidateCreate(entry, out var err)) + { + ErrorMessage = err; + return RedirectToPage(new { category = CrudCategory }); + } + await _objectsMissingService.CreateAsync(entry, CancellationToken.None); + SuccessMessage = $"Missing object '{CrudName.Trim()}' created."; + break; + } + case "objectpacks": + { + var newPack = new TblObjectPack { Name = CrudName.Trim(), Description = CrudDescription?.Trim() }; + _ = _db.ObjectPacks.Add(newPack); + _ = await _db.SaveChangesAsync(); + SuccessMessage = $"Object pack '{CrudName.Trim()}' created."; + break; + } + case "sc5filepacks": + { + var newPack = new TblSC5FilePack { Name = CrudName.Trim(), Description = CrudDescription?.Trim() }; + _ = _db.SC5FilePacks.Add(newPack); + _ = await _db.SaveChangesAsync(); + SuccessMessage = $"Scenario pack '{CrudName.Trim()}' created."; + break; + } + default: + break; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error creating: {ex.Message}"; + } + + return RedirectToPage(new { category = CrudCategory }); + } + + // ── POST: Edit author/tag/licence ── + + public async Task OnPostEditAsync() + { + if (!IsAdmin) + return Forbid(); + + if (string.IsNullOrWhiteSpace(CrudName)) + { + ErrorMessage = "Name is required."; + return RedirectToPage(new { category = CrudCategory }); + } + + try + { + switch (CrudCategory) + { + case "authors": + { + var entry = new DtoAuthorEntry(CrudId, CrudName.Trim()); + var updated = await _authorService.UpdateAsync(CrudId, entry, CancellationToken.None); + SuccessMessage = updated != null ? $"Author '{CrudName.Trim()}' updated." : "Author not found."; + if (updated == null) ErrorMessage = "Author not found."; + break; + } + case "tags": + { + var entry = new DtoTagEntry(CrudId, CrudName.Trim()); + var updated = await _tagService.UpdateAsync(CrudId, entry, CancellationToken.None); + SuccessMessage = updated != null ? $"Tag '{CrudName.Trim()}' updated." : "Tag not found."; + if (updated == null) ErrorMessage = "Tag not found."; + break; + } + case "licences": + { + var entry = new DtoLicenceEntry(CrudId, CrudName.Trim(), CrudText?.Trim() ?? string.Empty); + var updated = await _licenceService.UpdateAsync(CrudId, entry, CancellationToken.None); + SuccessMessage = updated != null ? $"Licence '{CrudName.Trim()}' updated." : "Licence not found."; + if (updated == null) ErrorMessage = "Licence not found."; + break; + } + case "objectsmissing": + { + var entry = new DtoObjectMissingEntry(CrudId, CrudName.Trim(), CrudChecksum, CrudObjectType); + var updated = await _objectsMissingService.UpdateAsync(CrudId, entry, CancellationToken.None); + SuccessMessage = updated != null ? $"Missing object '{CrudName.Trim()}' updated." : "Missing object not found."; + if (updated == null) ErrorMessage = "Missing object not found."; + break; + } + default: + break; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error updating: {ex.Message}"; + } + + return RedirectToPage(new { category = CrudCategory }); + } + + // ── POST: Delete author/tag/licence/objectpacks/sc5filepacks ── + + public async Task OnPostDeleteAsync() + { + if (!IsAdmin) + return Forbid(); + + try + { + switch (CrudCategory) + { + case "authors": + { + var deleted = await _authorService.DeleteAsync(CrudId, CancellationToken.None); + SuccessMessage = deleted ? "Author deleted." : null; + ErrorMessage = deleted ? null : "Failed to delete author."; + break; + } + case "tags": + { + var deleted = await _tagService.DeleteAsync(CrudId, CancellationToken.None); + SuccessMessage = deleted ? "Tag deleted." : null; + ErrorMessage = deleted ? null : "Failed to delete tag."; + break; + } + case "licences": + { + var deleted = await _licenceService.DeleteAsync(CrudId, CancellationToken.None); + SuccessMessage = deleted ? "Licence deleted." : null; + ErrorMessage = deleted ? null : "Failed to delete licence."; + break; + } + case "objectsmissing": + { + var deleted = await _objectsMissingService.DeleteAsync(CrudId, CancellationToken.None); + SuccessMessage = deleted ? "Missing object deleted." : null; + ErrorMessage = deleted ? null : "Failed to delete missing object."; + break; + } + case "objectpacks": + { + var pack = await _db.ObjectPacks.FindAsync(new object[] { (object)CrudId }, CancellationToken.None); + if (pack != null) + { + _db.ObjectPacks.Remove(pack); + await _db.SaveChangesAsync(); + SuccessMessage = $"Object pack '{pack.Name}' deleted."; + } + else + ErrorMessage = "Pack not found."; + break; + } + case "sc5filepacks": + { + var pack = await _db.SC5FilePacks.FindAsync(new object[] { (object)CrudId }, CancellationToken.None); + if (pack != null) + { + _db.SC5FilePacks.Remove(pack); + await _db.SaveChangesAsync(); + SuccessMessage = $"SC5 file pack '{pack.Name}' deleted."; + } + else + ErrorMessage = "Pack not found."; + break; + } + default: + ErrorMessage = "Unknown category."; + break; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error deleting: {ex.Message}"; + } + + return RedirectToPage(new { category = CrudCategory }); + } + + // ── View models ── + public record AuthorListViewModel(UniqueObjectId Id, string Name); + public record TagListViewModel(UniqueObjectId Id, string Name); + public record LicenceListViewModel(UniqueObjectId Id, string Name, string Text); + public record ObjectPackListViewModel(UniqueObjectId Id, string Name, string Description, DateOnly UploadedDate, int AuthorCount, int TagCount, string Licence, int ObjectCount); + public record SC5FileListViewModel(UniqueObjectId Id, string Name, string Description, DateOnly UploadedDate, ObjectSource ObjectSource, int AuthorCount, int TagCount, string Licence, int PackCount); + public record SC5FilePackListViewModel(UniqueObjectId Id, string Name, string Description, DateOnly UploadedDate, int AuthorCount, int TagCount, string Licence, int FileCount); + public record ObjectsMissingListViewModel(UniqueObjectId Id, string DatName, uint32_t DatChecksum, ObjectType ObjectType); } diff --git a/ObjectService/Pages/Licences/Details.cshtml b/ObjectService/Pages/Licences/Details.cshtml new file mode 100644 index 00000000..0e5d0b13 --- /dev/null +++ b/ObjectService/Pages/Licences/Details.cshtml @@ -0,0 +1,179 @@ +@page "/Licences/{id}" +@model ObjectService.Pages.Licences.DetailsModel +@{ + ViewData["Title"] = Model.Licence?.Name ?? "Licence"; + var isAdmin = User.IsInRole("Admin"); +} + +@if (Model.Licence is null) +{ +
+

Licence not found

+

The licence you are looking for does not exist.

+ Back to licence list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to licences +
+

@Model.Licence.Name

+ @if (isAdmin) + { +
+ +
+ +
+
+ } +
+ @if (isAdmin) + { + + } + @if (!string.IsNullOrWhiteSpace(Model.Licence.Text)) + { +
@Model.Licence.Text
+ } +
+
+ +
+
+

Objects

+ @if (Model.Objects.Count == 0) + { +

No objects use this licence.

+ } + else + { +
+ + + + + + + + @foreach (var obj in Model.Objects) + { + + + + } + +
Name
@obj.Name
+
+ } +
+ +
+

Object Packs

+ @if (Model.ObjectPacks.Count == 0) + { +

No object packs use this licence.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.ObjectPacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+ +
+

SC5 Files

+ @if (Model.SC5Files.Count == 0) + { +

No SC5 files use this licence.

+ } + else + { +
+ + + + + + + + @foreach (var file in Model.SC5Files) + { + + + + } + +
Name
@file.Name
+
+ } +
+ +
+

SC5 File Packs

+ @if (Model.SC5FilePacks.Count == 0) + { +

No SC5 file packs use this licence.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.SC5FilePacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+
\ No newline at end of file diff --git a/ObjectService/Pages/Licences/Details.cshtml.cs b/ObjectService/Pages/Licences/Details.cshtml.cs new file mode 100644 index 00000000..2e424d20 --- /dev/null +++ b/ObjectService/Pages/Licences/Details.cshtml.cs @@ -0,0 +1,150 @@ +using Definitions.Database; +using Definitions.DTO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using ObjectService.Services; + +namespace ObjectService.Pages.Licences; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + readonly ICrudService _licenceService; + + public DetailsModel(LocoDbContext db, ICrudService licenceService) + { + _db = db; + _licenceService = licenceService; + } + + public TblLicence? Licence { get; private set; } + + public List Objects { get; private set; } = []; + public List ObjectPacks { get; private set; } = []; + public List SC5Files { get; private set; } = []; + public List SC5FilePacks { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + [BindProperty] + public UniqueObjectId Id { get; set; } + + [BindProperty] + public string Name { get; set; } = string.Empty; + + [BindProperty] + public string Text { get; set; } = string.Empty; + + public bool IsAdmin => User.IsInRole("Admin"); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + Licence = await _db.Licences + .FirstOrDefaultAsync(l => l.Id == id, ct); + + if (Licence is null) + { + return NotFound(); + } + + var licenceId = Licence.Id; + + Objects = await _db.Objects + .Where(o => o.Licence != null && o.Licence.Id == licenceId) + .OrderBy(o => o.Name) + .Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)) + .ToListAsync(ct); + + ObjectPacks = await _db.ObjectPacks + .Where(p => p.Licence != null && p.Licence.Id == licenceId) + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)) + .ToListAsync(ct); + + SC5Files = await _db.SC5Files + .Where(f => f.Licence != null && f.Licence.Id == licenceId) + .OrderBy(f => f.Name) + .Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)) + .ToListAsync(ct); + + SC5FilePacks = await _db.SC5FilePacks + .Where(p => p.Licence != null && p.Licence.Id == licenceId) + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)) + .ToListAsync(ct); + + return Page(); + } + + public async Task OnPostEditAsync() + { + if (!IsAdmin) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Licence name is required."; + await ReloadAsync(Id); + return Page(); + } + + try + { + var entry = new DtoLicenceEntry(Id, Name.Trim(), Text?.Trim() ?? string.Empty); + var updated = await _licenceService.UpdateAsync(Id, entry, CancellationToken.None); + if (updated != null) + { + SuccessMessage = $"Licence '{Name.Trim()}' updated."; + } + else + { + ErrorMessage = "Licence not found."; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error updating licence: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!IsAdmin) + return Forbid(); + + var deleted = await _licenceService.DeleteAsync(id, CancellationToken.None); + if (deleted) + { + SuccessMessage = "Licence deleted."; + return RedirectToPage("/Index", new { category = "licences" }); + } + + await ReloadAsync(id); + ErrorMessage = "Failed to delete licence."; + return Page(); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + Licence = await _db.Licences.FirstOrDefaultAsync(l => l.Id == id); + if (Licence != null) + { + var licenceId = Licence.Id; + Objects = await _db.Objects.Where(o => o.Licence != null && o.Licence.Id == licenceId).OrderBy(o => o.Name).Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)).ToListAsync(); + ObjectPacks = await _db.ObjectPacks.Where(p => p.Licence != null && p.Licence.Id == licenceId).OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)).ToListAsync(); + SC5Files = await _db.SC5Files.Where(f => f.Licence != null && f.Licence.Id == licenceId).OrderBy(f => f.Name).Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)).ToListAsync(); + SC5FilePacks = await _db.SC5FilePacks.Where(p => p.Licence != null && p.Licence.Id == licenceId).OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)).ToListAsync(); + } + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Pages/Manage/Users/Edit.cshtml b/ObjectService/Pages/Manage/Users/Edit.cshtml new file mode 100644 index 00000000..ad90e9d3 --- /dev/null +++ b/ObjectService/Pages/Manage/Users/Edit.cshtml @@ -0,0 +1,370 @@ +@page "/manage/users/Edit/{id}" +@model ObjectService.Pages.Manage.Users.EditModel +@{ + ViewData["Title"] = "Edit User"; +} + +
+
+

Manage User: @(Model.UserDetail?.UserName ?? "…")

+ Back to Users +
+ + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
@Model.SuccessMessage
+ } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + + @if (Model.UserDetail == null) + { +

User not found.

+ } + else + { + +
+

User Details

+
+
+
ID
+
@Model.UserDetail.Id
+
+
+
Email
+
@Model.UserDetail.Email
+
+
+
Email Confirmed
+
+ + @(Model.UserDetail.EmailConfirmed ? "Yes" : "No") + +
+
+
+
Lockout Status
+
+ + @(Model.UserDetail.IsLockedOut ? "Locked" : "Active") + +
+
+ @if (Model.UserDetail.AssociatedAuthorId != null) + { +
+
Associated Author
+
@Model.UserDetail.AssociatedAuthorName (@Model.UserDetail.AssociatedAuthorId)
+
+ } +
+
+ + +
+

Display Name

+
+ +
+ + +
+ +
+
+ + +
+

Roles

+

Current roles: + @if (Model.UserDetail.Roles.Count == 0) + {None} + else + {@string.Join(", ", Model.UserDetail.Roles)} +

+
+ @foreach (var role in Model.AllRoles) + { + var isInRole = Model.UserDetail.Roles.Contains(role.Name); +
+ + + +
+ } +
+
+ + +
+

Direct Permissions

+

These are user-level permission claims. Role-based permissions are inherited automatically.

+
+ @foreach (var claim in Model.PermissionClaims) + { +
+ + + +
+ } +
+
+ +
+

Account Actions

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Danger Zone

+

Deleting a user is permanent and cannot be undone. Owned objects will be disassociated.

+
+ + +
+
+ } +
+ + \ No newline at end of file diff --git a/ObjectService/Pages/Manage/Users/Edit.cshtml.cs b/ObjectService/Pages/Manage/Users/Edit.cshtml.cs new file mode 100644 index 00000000..3dbc27f6 --- /dev/null +++ b/ObjectService/Pages/Manage/Users/Edit.cshtml.cs @@ -0,0 +1,324 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Definitions.Database; +using ObjectService.Identity; + +namespace ObjectService.Pages.Manage.Users; + +[Authorize(Policy = "AdminOnly")] +public sealed class EditModel : PageModel +{ + private readonly LocoDbContext _db; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public EditModel( + LocoDbContext db, + UserManager userManager, + RoleManager roleManager) + { + _db = db; + _userManager = userManager; + _roleManager = roleManager; + } + + // ── View data ── + + public UserDetailViewModel? UserDetail { get; set; } + public List AllRoles { get; set; } = []; + public List PermissionClaims { get; set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + // ── Form inputs (bound per-handler) ── + + [BindProperty] + public UniqueObjectId UserId { get; set; } + + [BindProperty] + public string? NewDisplayName { get; set; } + + [BindProperty] + public string? RoleToToggle { get; set; } + + [BindProperty] + public string? PermissionToToggle { get; set; } + + // ── GET ── + + public async Task OnGetAsync(UniqueObjectId id) + { + await LoadUserAsync(id); + if (UserDetail == null) + return NotFound(); + + return Page(); + } +// ── POST: Update display name ── + + public async Task OnPostUpdateDisplayNameAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + if (string.IsNullOrWhiteSpace(NewDisplayName)) + { + ErrorMessage = "Display name cannot be empty."; + await LoadUserAsync(UserId); + return Page(); + } + + var result = await _userManager.SetUserNameAsync(user, NewDisplayName.Trim()); + if (result.Succeeded) + { + SuccessMessage = $"Display name updated to \"{NewDisplayName.Trim()}\"."; + } + else + { + ErrorMessage = string.Join("; ", result.Errors.Select(e => e.Description)); + } + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Toggle role membership ── + + public async Task OnPostToggleRoleAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + if (string.IsNullOrEmpty(RoleToToggle)) + { + await LoadUserAsync(UserId); + return Page(); + } + + if (await _userManager.IsInRoleAsync(user, RoleToToggle)) + { + var currentUserId = _userManager.GetUserId(User); + if (RoleToToggle == "Admin" && user.Id.ToString() == currentUserId) + { + ErrorMessage = "You cannot remove yourself from the Admin role."; + await LoadUserAsync(UserId); + return Page(); + } + + await _userManager.RemoveFromRoleAsync(user, RoleToToggle); + SuccessMessage = $"Removed \"{user.UserName}\" from role \"{RoleToToggle}\"."; + } + else + { + await _userManager.AddToRoleAsync(user, RoleToToggle); + SuccessMessage = $"Added \"{user.UserName}\" to role \"{RoleToToggle}\"."; + } + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Toggle user permission claim ── + + public async Task OnPostTogglePermissionAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + if (string.IsNullOrEmpty(PermissionToToggle)) + { + await LoadUserAsync(UserId); + return Page(); + } + + var existingClaims = await _userManager.GetClaimsAsync(user); + var existing = existingClaims.FirstOrDefault(c => + c.Type == LocoPermissions.ClaimType && c.Value == PermissionToToggle); + + if (existing != null) + { + await _userManager.RemoveClaimAsync(user, existing); + SuccessMessage = $"Revoked \"{PermissionToToggle}\" from \"{user.UserName}\"."; + } + else + { + await _userManager.AddClaimAsync(user, + new Claim(LocoPermissions.ClaimType, PermissionToToggle)); + SuccessMessage = $"Granted \"{PermissionToToggle}\" to \"{user.UserName}\"."; + } + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Force password reset ── + + public async Task OnPostForcePasswordResetAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + SuccessMessage = $"Password reset token: {token}"; + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Toggle email confirmation ── + + public async Task OnPostToggleEmailConfirmedAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + if (user.EmailConfirmed) + { + user.EmailConfirmed = false; + await _userManager.UpdateAsync(user); + SuccessMessage = $"Email confirmation revoked for \"{user.UserName}\"."; + } + else + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var result = await _userManager.ConfirmEmailAsync(user, token); + if (result.Succeeded) + { + SuccessMessage = $"Email confirmed for \"{user.UserName}\"."; + } + else + { + ErrorMessage = string.Join("; ", result.Errors.Select(e => e.Description)); + } + } + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Toggle lockout ── + + public async Task OnPostToggleLockoutAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + if (await _userManager.IsLockedOutAsync(user)) + { + await _userManager.SetLockoutEndDateAsync(user, null); + SuccessMessage = $"\"{user.UserName}\" has been unlocked."; + } + else + { + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + SuccessMessage = $"\"{user.UserName}\" has been locked out."; + } + + await LoadUserAsync(UserId); + return Page(); + } + + // ── POST: Delete user ── + + public async Task OnPostDeleteUserAsync() + { + var user = await _userManager.FindByIdAsync(UserId.ToString()); + if (user == null) return NotFound(); + + var currentUserId = _userManager.GetUserId(User); + if (user.Id.ToString() == currentUserId) + { + ErrorMessage = "You cannot delete your own account."; + await LoadUserAsync(UserId); + return Page(); + } + + var userName = user.UserName; + + var ownedObjects = await _db.Objects.Where(o => o.OwnerUserId == user.Id).ToListAsync(); + foreach (var obj in ownedObjects) + obj.OwnerUserId = null; + await _db.SaveChangesAsync(); + + var result = await _userManager.DeleteAsync(user); + if (result.Succeeded) + { + SuccessMessage = $"User \"{userName}\" has been deleted."; + return RedirectToPage("/Manage/Users/Index"); + } + + ErrorMessage = string.Join("; ", result.Errors.Select(e => e.Description)); + await LoadUserAsync(UserId); + return Page(); + } +// ── Helpers ── + + private async Task LoadUserAsync(UniqueObjectId id) + { + var user = await _db.Users + .Include(u => u.AssociatedAuthor) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) return; + + UserId = user.Id; + + var roles = await _userManager.GetRolesAsync(user); + var claims = await _userManager.GetClaimsAsync(user); + var isLockedOut = await _userManager.IsLockedOutAsync(user); + + UserDetail = new UserDetailViewModel( + user.Id, + user.UserName ?? string.Empty, + user.Email ?? string.Empty, + user.EmailConfirmed, + isLockedOut, + roles.ToList(), + user.AssociatedAuthorId, + user.AssociatedAuthor?.Name); + + AllRoles = await _roleManager.Roles + .OrderBy(r => r.Name) + .Select(r => new RoleViewModel(r.Id, r.Name ?? string.Empty)) + .ToListAsync(); + + var knownPermissions = new[] + { + LocoPermissions.ObjectPacksCreate, + LocoPermissions.TagsManage, + LocoPermissions.LicenceManage, + LocoPermissions.AuthorManage, + LocoPermissions.DisplayNameChange, + }; + + PermissionClaims = knownPermissions.Select(p => + new UserClaimViewModel( + Permission: p, + HasClaim: claims.Any(c => c.Type == LocoPermissions.ClaimType && c.Value == p))) + .ToList(); + } + + // ── View models ── + + public sealed record UserDetailViewModel( + UniqueObjectId Id, + string UserName, + string Email, + bool EmailConfirmed, + bool IsLockedOut, + List Roles, + UniqueObjectId? AssociatedAuthorId, + string? AssociatedAuthorName); + + public sealed record RoleViewModel(UniqueObjectId Id, string Name); + + public sealed record UserClaimViewModel(string Permission, bool HasClaim); +} \ No newline at end of file diff --git a/ObjectService/Pages/Manage/Users/Index.cshtml b/ObjectService/Pages/Manage/Users/Index.cshtml new file mode 100644 index 00000000..a1a45e0b --- /dev/null +++ b/ObjectService/Pages/Manage/Users/Index.cshtml @@ -0,0 +1,71 @@ +@page "/manage/users" +@model ObjectService.Pages.Manage.Users.IndexModel +@{ + ViewData["Title"] = "Manage Users"; +} + +
+
+

Manage Users

+
+ + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
@Model.SuccessMessage
+ } + + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + +
+
+
+ + +
+
+ + Clear +
+
+
+ +
+ @if (Model.Users.Count > 0) + { + + + + + + + + + + + @foreach (var user in Model.Users) + { + + + + + + + } + +
UsernameEmailRolesActions
@user.UserName@user.Email@(string.IsNullOrEmpty(user.Roles) ? "None" : user.Roles) + Manage +
+ } + else + { +

No users found.

+ } +
+ + Back to Dashboard +
+ + diff --git a/ObjectService/Pages/Manage/Users/Index.cshtml.cs b/ObjectService/Pages/Manage/Users/Index.cshtml.cs new file mode 100644 index 00000000..c773a210 --- /dev/null +++ b/ObjectService/Pages/Manage/Users/Index.cshtml.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Definitions.Database; + +namespace ObjectService.Pages.Manage.Users; + +[Authorize(Policy = "AdminOnly")] +public sealed class IndexModel : PageModel +{ + private readonly LocoDbContext _db; + + public IndexModel(LocoDbContext db) + { + _db = db; + } + + public List Users { get; set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Search { get; set; } + + public async Task OnGetAsync() + { + var allRoles = await _db.Roles.ToDictionaryAsync(r => r.Id, r => r.Name); + var userRolesData = await _db.UserRoles.ToListAsync(); + var userQuery = _db.Users.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(Search)) + { + var s = Search.Trim(); + userQuery = userQuery.Where(u => (u.UserName != null && u.UserName.Contains(s)) || (u.Email != null && u.Email.Contains(s))); + } + + var userList = await userQuery.OrderBy(u => u.UserName).ToListAsync(); + + Users = userList.Select(u => + { + var userRoles = userRolesData.Where(ur => ur.UserId == u.Id); + var roleNames = userRoles.Where(ur => allRoles.ContainsKey(ur.RoleId)).Select(ur => allRoles[ur.RoleId] ?? "").ToList(); + return new UserViewModel( + u.Id, + u.UserName ?? "Unknown", + u.Email ?? "", + string.Join(", ", roleNames)); + }).ToList(); + } + + public record UserViewModel( + UniqueObjectId Id, + string UserName, + string Email, + string Roles); +} diff --git a/ObjectService/Pages/ObjectPacks/Details.cshtml b/ObjectService/Pages/ObjectPacks/Details.cshtml new file mode 100644 index 00000000..e7d576d0 --- /dev/null +++ b/ObjectService/Pages/ObjectPacks/Details.cshtml @@ -0,0 +1,264 @@ +@page "/ObjectPacks/{id}" +@model ObjectService.Pages.ObjectPacks.DetailsModel +@{ + var pack = Model.ObjectPack; + ViewData["Title"] = pack?.Name ?? "Object Pack"; +} + +@if (pack is null) +{ +
+

Object pack not found

+

The object pack you are looking for does not exist.

+ Back to object pack list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to object packs +
+

@pack.Name

+ @if (Model.CanEdit) + { +
+ +
+ +
+
+ } +
+

@(string.IsNullOrWhiteSpace(pack.Description) ? "No description is stored for this object pack yet." : pack.Description)

+ + @if (Model.CanEdit) + { + + } +
+
+
+
+

Metadata

+
+
+
Created
+
@(pack.CreatedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Modified
+
@(pack.ModifiedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Uploaded
+
@pack.UploadedDate.ToString("yyyy-MM-dd")
+
+
+
Licence
+
+ @if (pack.Licence is not null) + { + @pack.Licence.Name + } + else + { + - + } +
+
+
+ + @if (!string.IsNullOrWhiteSpace(pack.Licence?.Text)) + { +
@pack.Licence?.Text
+ } +
+ +
+

Relationships

+
+
+

Authors

+

+ @if (pack.Authors.Count > 0) + { + @for (var i = 0; i < pack.Authors.Count; i++) + { + var author = pack.Authors.ElementAt(i); + @author.Name + @if (i < pack.Authors.Count - 1) + { + , + } + } + } + else + { + No authors linked + } +

+
+
+

Tags

+

+ @if (pack.Tags.Count > 0) + { + @for (var i = 0; i < pack.Tags.Count; i++) + { + var tag = pack.Tags.ElementAt(i); + @tag.Name + @if (i < pack.Tags.Count - 1) + { + , + } + } + } + else + { + No tags linked + } +

+
+
+
+ +
+

Objects in this pack (@pack.Objects.Count)

+ @if (pack.Objects.Count == 0) + { +

No objects are linked to this pack.

+ } + else + { +
+ + + + + + + + @foreach (var obj in pack.Objects.OrderBy(o => o.Name)) + { + + + + } + +
Name
@obj.Name
+
+ } +
+
\ No newline at end of file diff --git a/ObjectService/Pages/ObjectPacks/Details.cshtml.cs b/ObjectService/Pages/ObjectPacks/Details.cshtml.cs new file mode 100644 index 00000000..88877d8d --- /dev/null +++ b/ObjectService/Pages/ObjectPacks/Details.cshtml.cs @@ -0,0 +1,226 @@ +using Definitions.Database; +using Definitions.DTO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using ObjectService.Identity; + +namespace ObjectService.Pages.ObjectPacks; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + + public DetailsModel(LocoDbContext db) + { + _db = db; + } + + public TblObjectPack? ObjectPack { get; private set; } + + public List Objects { get; private set; } = []; + + // ── Edit form available values ── + public List AvailableAuthors { get; private set; } = []; + public List AvailableTags { get; private set; } = []; + public List AvailableLicences { get; private set; } = []; + public List AvailableObjects { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public bool CanEdit => User.IsInRole("Admin") || User.IsInRole("Curator") + || User.HasClaim(LocoPermissions.ClaimType, LocoPermissions.ObjectPacksModify); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + ObjectPack = await _db.ObjectPacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.Objects) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == id, ct); + + if (ObjectPack is null) + { + return NotFound(); + } + + await LoadRelatedDataAsync(ct); + + return Page(); + } + + public async Task OnPostEditAsync( + [FromForm] UniqueObjectId Id, + [FromForm] string Name, + [FromForm] string? Description, + [FromForm] DateOnly? CreatedDate, + [FromForm] DateOnly? ModifiedDate, + [FromForm] UniqueObjectId? LicenceId, + [FromForm] List? SelectedAuthorIds, + [FromForm] List? SelectedTagIds, + [FromForm] List? SelectedObjectIds) + { + if (!CanEdit) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Object pack name is required."; + await ReloadAsync(Id); + return Page(); + } + + SelectedAuthorIds ??= []; + SelectedTagIds ??= []; + SelectedObjectIds ??= []; + + try + { + var pack = await _db.ObjectPacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.Objects) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == Id); + + if (pack is null) + { + ErrorMessage = "Object pack not found."; + return Page(); + } + + // Basic properties + pack.Name = Name.Trim(); + pack.Description = Description?.Trim(); + pack.CreatedDate = CreatedDate; + pack.ModifiedDate = ModifiedDate; + // UploadedDate is a database-generated computed column — do not set it + + // Licence + if (LicenceId.HasValue) + { + var licence = await _db.Licences.FindAsync(new object[] { (object)LicenceId.Value }); + pack.Licence = licence; + } + else + { + pack.Licence = null; + } + + // Authors + pack.Authors.Clear(); + foreach (var authorId in SelectedAuthorIds) + { + var author = await _db.Authors.FindAsync(new object[] { (object)authorId }); + if (author != null) + pack.Authors.Add(author); + } + + // Tags + pack.Tags.Clear(); + foreach (var tagId in SelectedTagIds) + { + var tag = await _db.Tags.FindAsync(new object[] { (object)tagId }); + if (tag != null) + pack.Tags.Add(tag); + } + + // Objects + pack.Objects.Clear(); + foreach (var objId in SelectedObjectIds) + { + var obj = await _db.Objects.FindAsync(new object[] { (object)objId }); + if (obj != null) + pack.Objects.Add(obj); + } + + await _db.SaveChangesAsync(); + SuccessMessage = $"Object pack '{Name.Trim()}' updated."; + } + catch (Exception ex) + { + ErrorMessage = $"Error updating object pack: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!CanEdit) + return Forbid(); + + var pack = await _db.ObjectPacks.FindAsync(new object[] { (object)id }); + if (pack is null) + { + await ReloadAsync(id); + ErrorMessage = "Failed to delete object pack."; + return Page(); + } + + _db.ObjectPacks.Remove(pack); + await _db.SaveChangesAsync(); + + SuccessMessage = "Object pack deleted."; + return RedirectToPage("/Index", new { category = "objectpacks" }); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + ObjectPack = await _db.ObjectPacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.Objects) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == id); + + await LoadRelatedDataAsync(CancellationToken.None); + } + + private async Task LoadRelatedDataAsync(CancellationToken ct) + { + if (ObjectPack is not null) + { + Objects = ObjectPack.Objects + .OrderBy(o => o.Name) + .Select(o => new ListItem(o.Id, o.Name, "Objects", null)) + .ToList(); + } + + AvailableAuthors = await _db.Authors + .OrderBy(a => a.Name) + .Select(a => new DtoAuthorEntry(a.Id, a.Name)) + .ToListAsync(ct); + + AvailableTags = await _db.Tags + .OrderBy(t => t.Name) + .Select(t => new DtoTagEntry(t.Id, t.Name)) + .ToListAsync(ct); + + AvailableLicences = await _db.Licences + .OrderBy(l => l.Name) + .Select(l => new DtoLicenceEntry(l.Id, l.Name, l.Text)) + .ToListAsync(ct); + + AvailableObjects = await _db.Objects + .OrderBy(o => o.Name) + .Select(o => new DtoObjectEntry(o.Id, o.Name, o.Name, null, null, + o.ObjectSource, + o.ObjectType, + null, + o.Availability, + null, null, o.UploadedDate)) + .ToListAsync(ct); + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Pages/Objects/Details.cshtml b/ObjectService/Pages/Objects/Details.cshtml index 015b0980..ec1ed735 100644 --- a/ObjectService/Pages/Objects/Details.cshtml +++ b/ObjectService/Pages/Objects/Details.cshtml @@ -16,6 +16,10 @@
@item.Availability + @if (User.IsInRole("Admin")) + { + Edit + } Open JSON @if (item.CanDownloadAnyFile) { @@ -50,7 +54,14 @@
Licence
-
@(item.LicenceName ?? "-")
+
@if (item.Licence is not null) + { + @item.Licence.Name + } + else + { + - + }
Files indexed
@@ -58,9 +69,9 @@
- @if (!string.IsNullOrWhiteSpace(item.LicenceText)) + @if (!string.IsNullOrWhiteSpace(item.Licence?.Text)) { -
@item.LicenceText
+
@item.Licence?.Text
} @@ -69,11 +80,45 @@

Authors

-

@(item.Authors.Count > 0 ? string.Join(", ", item.Authors) : "No authors linked")

+

+ @if (item.Authors.Count > 0) + { + @for (var i = 0; i < item.Authors.Count; i++) + { + var author = item.Authors[i]; + @author.Name + @if (i < item.Authors.Count - 1) + { + , + } + } + } + else + { + No authors linked + } +

Tags

-

@(item.Tags.Count > 0 ? string.Join(", ", item.Tags) : "No tags linked")

+

+ @if (item.Tags.Count > 0) + { + @for (var i = 0; i < item.Tags.Count; i++) + { + var tag = item.Tags[i]; + @tag.Name + @if (i < item.Tags.Count - 1) + { + , + } + } + } + else + { + No tags linked + } +

Object packs

diff --git a/ObjectService/Pages/Objects/Edit.cshtml b/ObjectService/Pages/Objects/Edit.cshtml new file mode 100644 index 00000000..c2cb3ae0 --- /dev/null +++ b/ObjectService/Pages/Objects/Edit.cshtml @@ -0,0 +1,172 @@ +@page "/Objects/Edit/{id}" +@model ObjectService.Pages.Objects.EditModel +@{ + ViewData["Title"] = $"Edit Object - {Model.Name}"; +} + +
+

Edit Object: @Model.Name

+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + + @if (Model.Object == null) + { +

Object not found.

+ } + else + { +
+ +
+ Basic Information + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ Metadata + +
+ + +
+ +
+ +
+ @foreach (var author in Model.AvailableAuthors) + { + var isSelected = Model.SelectedAuthorIds.Contains(author.Id); + + } +
+
+ +
+ +
+ @foreach (var tag in Model.AvailableTags) + { + var isSelected = Model.SelectedTagIds.Contains(tag.Id); + + } +
+
+ +
+ +
+ @foreach (var pack in Model.AvailableObjectPacks) + { + var isSelected = Model.SelectedObjectPackIds.Contains(pack.Id); + + } +
+
+
+ +
+ + Cancel +
+
+ } +
+ + + \ No newline at end of file diff --git a/ObjectService/Pages/Objects/Edit.cshtml.cs b/ObjectService/Pages/Objects/Edit.cshtml.cs new file mode 100644 index 00000000..2f4c502d --- /dev/null +++ b/ObjectService/Pages/Objects/Edit.cshtml.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Definitions; +using Definitions.Database; +using Definitions.DTO; +using Definitions.ObjectModels.Types; +using ObjectService.Services; +using static Definitions.ObjectAvailability; + +namespace ObjectService.Pages.Objects; + +[Authorize(Policy = "AdminOnly")] +public sealed class EditModel : PageModel +{ + private readonly IObjectQueryService _objectService; + private readonly LocoDbContext _db; + private readonly ICrudService _authorService; + private readonly ICrudService _tagService; + private readonly ICrudService _licenceService; + + public EditModel( + IObjectQueryService objectService, + LocoDbContext db, + ICrudService authorService, + ICrudService tagService, + ICrudService licenceService) + { + _objectService = objectService; + _db = db; + _authorService = authorService; + _tagService = tagService; + _licenceService = licenceService; + } + + [BindProperty] + public UniqueObjectId Id { get; set; } + + [BindProperty] + public string Name { get; set; } = string.Empty; + + [BindProperty] + public string? Description { get; set; } + + [BindProperty] + public DateOnly? CreatedDate { get; set; } + + [BindProperty] + public DateOnly? ModifiedDate { get; set; } + + [BindProperty] + public DateOnly UploadedDate { get; set; } + + [BindProperty] + public UniqueObjectId? LicenceId { get; set; } + + [BindProperty] + public List SelectedAuthorIds { get; set; } = []; + + [BindProperty] + public List SelectedTagIds { get; set; } = []; + + [BindProperty] + public List SelectedObjectPackIds { get; set; } = []; + + [BindProperty] + public ObjectAvailability Availability { get; set; } + + public DtoObjectPostResponse? Object { get; set; } + public List AvailableAuthors { get; set; } = []; + public List AvailableTags { get; set; } = []; + public List AvailableLicences { get; set; } = []; + public List AvailableObjectPacks { get; set; } = []; + + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync(UniqueObjectId id) + { + Object = await _objectService.GetByIdAsync(id, CancellationToken.None); + if (Object == null) + { + return NotFound(); + } + + // Check if object is vanilla (not editable) + if (Object.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) + { + ErrorMessage = "Vanilla game objects cannot be edited."; + return Page(); + } + + // Load current values + Id = Object.Id; + Name = Object.Name; + Description = Object.Description; + CreatedDate = Object.CreatedDate; + ModifiedDate = Object.ModifiedDate; + UploadedDate = Object.UploadedDate; + LicenceId = Object.Licence?.Id; + Availability = Object.Availability; + SelectedAuthorIds = Object.Authors.Select(a => a.Id).ToList(); + SelectedTagIds = Object.Tags.Select(t => t.Id).ToList(); + SelectedObjectPackIds = Object.ObjectPacks.Select(p => p.Id).ToList(); + + // Load available values for dropdowns + AvailableAuthors = (await _authorService.ListAsync(HttpContext, CancellationToken.None)).OrderBy(a => a.Name).ToList(); + AvailableTags = (await _tagService.ListAsync(HttpContext, CancellationToken.None)).OrderBy(t => t.Name).ToList(); + AvailableLicences = (await _licenceService.ListAsync(HttpContext, CancellationToken.None)).OrderBy(l => l.Name).ToList(); + AvailableObjectPacks = (await _db.ObjectPacks.ToListAsync()).Select(p => new DtoItemPackEntry(p.Id, p.Name, p.Description, p.CreatedDate, p.ModifiedDate, p.UploadedDate, null)).OrderBy(p => p.Name).ToList(); + + return Page(); + } + + public async Task OnPostAsync() + { + try + { + var licence = LicenceId.HasValue && Object != null ? Object.Licence : null; + var licenceEntry = licence != null ? new DtoLicenceEntry(licence.Id, licence.Name, licence.Text) : null; + + var authors = await _authorService.ListAsync(HttpContext, CancellationToken.None); + var authorEntries = authors.Where(a => SelectedAuthorIds.Contains(a.Id)).ToList(); + + var tags = await _tagService.ListAsync(HttpContext, CancellationToken.None); + var tagEntries = tags.Where(t => SelectedTagIds.Contains(t.Id)).ToList(); + + var packItems = (await _db.ObjectPacks + .Where(p => SelectedObjectPackIds.Contains(p.Id)) + .ToListAsync()) + .Select(p => new DtoItemPackEntry(p.Id, p.Name, p.Description, p.CreatedDate, p.ModifiedDate, p.UploadedDate, null)) + .ToList(); + + var updateRequest = new DtoObjectPostResponse( + Id, + Name, + Name, // DisplayName + Object?.DatChecksum, + Description, + Object?.ObjectSource ?? ObjectSource.Custom, + Object?.ObjectType ?? ObjectType.Bridge, + Object?.VehicleType, + Availability, + CreatedDate, + ModifiedDate, + UploadedDate, + licenceEntry, + authorEntries, + tagEntries, + packItems, + Object?.DatObjects ?? [], + Object?.StringTable ?? new DtoStringTableDescriptor([], 0)); + + var updated = await _objectService.UpdateAsync(Id, updateRequest, CancellationToken.None); + if (updated != null) + { + return RedirectToPage("/Objects/Details", new { id = Id.ToString() }); + } + + ErrorMessage = "Failed to update object. It may no longer exist."; + return Page(); + } + catch (Exception ex) + { + ErrorMessage = $"Error updating object: {ex.Message}"; + return Page(); + } + } +} \ No newline at end of file diff --git a/ObjectService/Pages/SC5FilePacks/Details.cshtml b/ObjectService/Pages/SC5FilePacks/Details.cshtml new file mode 100644 index 00000000..23aa349e --- /dev/null +++ b/ObjectService/Pages/SC5FilePacks/Details.cshtml @@ -0,0 +1,264 @@ +@page "/SC5FilePacks/{id}" +@model ObjectService.Pages.SC5FilePacks.DetailsModel +@{ + var pack = Model.Pack; + ViewData["Title"] = pack?.Name ?? "Scenario Pack"; +} + +@if (pack is null) +{ +
+

Scenario pack not found

+

The scenario pack you are looking for does not exist.

+ Back to scenario pack list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to scenario packs +
+

@pack.Name

+ @if (Model.CanEdit) + { +
+ +
+ +
+
+ } +
+

@(string.IsNullOrWhiteSpace(pack.Description) ? "No description is stored for this scenario pack yet." : pack.Description)

+ + @if (Model.CanEdit) + { + + } +
+
+
+
+

Metadata

+
+
+
Created
+
@(pack.CreatedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Modified
+
@(pack.ModifiedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Uploaded
+
@pack.UploadedDate.ToString("yyyy-MM-dd")
+
+
+
Licence
+
+ @if (pack.Licence is not null) + { + @pack.Licence.Name + } + else + { + - + } +
+
+
+ + @if (!string.IsNullOrWhiteSpace(pack.Licence?.Text)) + { +
@pack.Licence?.Text
+ } +
+ +
+

Relationships

+
+
+

Authors

+

+ @if (pack.Authors.Count > 0) + { + @for (var i = 0; i < pack.Authors.Count; i++) + { + var author = pack.Authors.ElementAt(i); + @author.Name + @if (i < pack.Authors.Count - 1) + { + , + } + } + } + else + { + No authors linked + } +

+
+
+

Tags

+

+ @if (pack.Tags.Count > 0) + { + @for (var i = 0; i < pack.Tags.Count; i++) + { + var tag = pack.Tags.ElementAt(i); + @tag.Name + @if (i < pack.Tags.Count - 1) + { + , + } + } + } + else + { + No tags linked + } +

+
+
+
+ +
+

Scenarios in this pack (@pack.SC5Files.Count)

+ @if (pack.SC5Files.Count == 0) + { +

No scenarios are linked to this pack.

+ } + else + { +
+ + + + + + + + @foreach (var file in pack.SC5Files.OrderBy(f => f.Name)) + { + + + + } + +
Name
@file.Name
+
+ } +
+
\ No newline at end of file diff --git a/ObjectService/Pages/SC5FilePacks/Details.cshtml.cs b/ObjectService/Pages/SC5FilePacks/Details.cshtml.cs new file mode 100644 index 00000000..95a88d3d --- /dev/null +++ b/ObjectService/Pages/SC5FilePacks/Details.cshtml.cs @@ -0,0 +1,221 @@ +using Definitions.Database; +using Definitions.DTO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using ObjectService.Identity; + +namespace ObjectService.Pages.SC5FilePacks; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + + public DetailsModel(LocoDbContext db) + { + _db = db; + } + + public TblSC5FilePack? Pack { get; private set; } + + public List SC5Files { get; private set; } = []; + + // ── Edit form available values ── + public List AvailableAuthors { get; private set; } = []; + public List AvailableTags { get; private set; } = []; + public List AvailableLicences { get; private set; } = []; + public List AvailableScenarios { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public bool CanEdit => User.IsInRole("Admin") || User.IsInRole("Curator") + || User.HasClaim(LocoPermissions.ClaimType, LocoPermissions.SC5FilePacksModify); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + Pack = await _db.SC5FilePacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.SC5Files) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == id, ct); + + if (Pack is null) + { + return NotFound(); + } + + await LoadRelatedDataAsync(ct); + + return Page(); + } + + public async Task OnPostEditAsync( + [FromForm] UniqueObjectId Id, + [FromForm] string Name, + [FromForm] string? Description, + [FromForm] DateOnly? CreatedDate, + [FromForm] DateOnly? ModifiedDate, + [FromForm] UniqueObjectId? LicenceId, + [FromForm] List? SelectedAuthorIds, + [FromForm] List? SelectedTagIds, + [FromForm] List? SelectedSC5FileIds) + { + if (!CanEdit) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Scenario pack name is required."; + await ReloadAsync(Id); + return Page(); + } + + SelectedAuthorIds ??= []; + SelectedTagIds ??= []; + SelectedSC5FileIds ??= []; + + try + { + var pack = await _db.SC5FilePacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.SC5Files) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == Id); + + if (pack is null) + { + ErrorMessage = "Scenario pack not found."; + return Page(); + } + + // Basic properties + pack.Name = Name.Trim(); + pack.Description = Description?.Trim(); + pack.CreatedDate = CreatedDate; + pack.ModifiedDate = ModifiedDate; + // UploadedDate is a database-generated computed column — do not set it + + // Licence + if (LicenceId.HasValue) + { + var licence = await _db.Licences.FindAsync(new object[] { (object)LicenceId.Value }); + pack.Licence = licence; + } + else + { + pack.Licence = null; + } + + // Authors + pack.Authors.Clear(); + foreach (var authorId in SelectedAuthorIds) + { + var author = await _db.Authors.FindAsync(new object[] { (object)authorId }); + if (author != null) + pack.Authors.Add(author); + } + + // Tags + pack.Tags.Clear(); + foreach (var tagId in SelectedTagIds) + { + var tag = await _db.Tags.FindAsync(new object[] { (object)tagId }); + if (tag != null) + pack.Tags.Add(tag); + } + + // SC5 Files (scenarios) + pack.SC5Files.Clear(); + foreach (var fileId in SelectedSC5FileIds) + { + var file = await _db.SC5Files.FindAsync(new object[] { (object)fileId }); + if (file != null) + pack.SC5Files.Add(file); + } + + await _db.SaveChangesAsync(); + SuccessMessage = $"Scenario pack '{Name.Trim()}' updated."; + } + catch (Exception ex) + { + ErrorMessage = $"Error updating scenario pack: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!CanEdit) + return Forbid(); + + var pack = await _db.SC5FilePacks.FindAsync(new object[] { (object)id }); + if (pack is null) + { + await ReloadAsync(id); + ErrorMessage = "Failed to delete scenario pack."; + return Page(); + } + + _db.SC5FilePacks.Remove(pack); + await _db.SaveChangesAsync(); + + SuccessMessage = "Scenario pack deleted."; + return RedirectToPage("/Index", new { category = "sc5filepacks" }); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + Pack = await _db.SC5FilePacks + .Include(p => p.Licence) + .Include(p => p.Authors) + .Include(p => p.Tags) + .Include(p => p.SC5Files) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == id); + + await LoadRelatedDataAsync(CancellationToken.None); + } + + private async Task LoadRelatedDataAsync(CancellationToken ct) + { + if (Pack is not null) + { + SC5Files = Pack.SC5Files + .OrderBy(f => f.Name) + .Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)) + .ToList(); + } + + AvailableAuthors = await _db.Authors + .OrderBy(a => a.Name) + .Select(a => new DtoAuthorEntry(a.Id, a.Name)) + .ToListAsync(ct); + + AvailableTags = await _db.Tags + .OrderBy(t => t.Name) + .Select(t => new DtoTagEntry(t.Id, t.Name)) + .ToListAsync(ct); + + AvailableLicences = await _db.Licences + .OrderBy(l => l.Name) + .Select(l => new DtoLicenceEntry(l.Id, l.Name, l.Text)) + .ToListAsync(ct); + + AvailableScenarios = await _db.SC5Files + .OrderBy(s => s.Name) + .Select(s => new DtoScenarioEntry(s.Id, s.Name)) + .ToListAsync(ct); + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Pages/Scenarios/Details.cshtml b/ObjectService/Pages/Scenarios/Details.cshtml new file mode 100644 index 00000000..2cea913f --- /dev/null +++ b/ObjectService/Pages/Scenarios/Details.cshtml @@ -0,0 +1,231 @@ +@page "/Scenarios/{id}" +@model ObjectService.Pages.Scenarios.DetailsModel +@{ + var scenario = Model.Scenario; + ViewData["Title"] = scenario?.Name ?? "Scenario"; +} + +@if (scenario is null) +{ +
+

Scenario not found

+

The scenario you are looking for does not exist.

+ Back to scenario list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to scenarios +
+

@scenario.Name

+ @if (Model.CanEdit) + { +
+ +
+ +
+
+ } +
+

Source: @scenario.ObjectSource

+

@(string.IsNullOrWhiteSpace(scenario.Description) ? "No description is stored for this scenario yet." : scenario.Description)

+ + @if (Model.CanEdit) + { + + } +
+
+ +
+
+

Metadata

+
+
+
Created
+
@(scenario.CreatedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Modified
+
@(scenario.ModifiedDate?.ToString("yyyy-MM-dd") ?? "-")
+
+
+
Uploaded
+
@scenario.UploadedDate.ToString("yyyy-MM-dd")
+
+
+
Licence
+
+ @if (scenario.Licence is not null) + { + @scenario.Licence.Name + } + else + { + - + } +
+
+
+ + @if (!string.IsNullOrWhiteSpace(scenario.Licence?.Text)) + { +
@scenario.Licence?.Text
+ } +
+ +
+

Relationships

+
+
+

Authors

+

+ @if (scenario.Authors.Count > 0) + { + @for (var i = 0; i < scenario.Authors.Count; i++) + { + var author = scenario.Authors.ElementAt(i); + @author.Name + @if (i < scenario.Authors.Count - 1) + { + , + } + } + } + else + { + No authors linked + } +

+
+
+

Tags

+

+ @if (scenario.Tags.Count > 0) + { + @for (var i = 0; i < scenario.Tags.Count; i++) + { + var tag = scenario.Tags.ElementAt(i); + @tag.Name + @if (i < scenario.Tags.Count - 1) + { + , + } + } + } + else + { + No tags linked + } +

+
+
+

SC5 File Packs

+

@(scenario.SC5FilePacks.Count > 0 ? string.Join(", ", scenario.SC5FilePacks.Select(p => p.Name)) : "No packs linked")

+
+
+
+
\ No newline at end of file diff --git a/ObjectService/Pages/Scenarios/Details.cshtml.cs b/ObjectService/Pages/Scenarios/Details.cshtml.cs new file mode 100644 index 00000000..e70cac23 --- /dev/null +++ b/ObjectService/Pages/Scenarios/Details.cshtml.cs @@ -0,0 +1,248 @@ +using Definitions.Database; +using Definitions.DTO; +using Definitions.ObjectModels.Types; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace ObjectService.Pages.Scenarios; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + + public DetailsModel(LocoDbContext db) + { + _db = db; + } + + public TblSC5File? Scenario { get; private set; } + + public List SC5FilePacks { get; private set; } = []; + + // ── Edit form available values ── + public List AvailableAuthors { get; private set; } = []; + public List AvailableTags { get; private set; } = []; + public List AvailableLicences { get; private set; } = []; + public List AvailablePacks { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + // ── Edit form bindings ── + + [BindProperty] + public UniqueObjectId Id { get; set; } + + [BindProperty] + public string Name { get; set; } = string.Empty; + + [BindProperty] + public string? Description { get; set; } + + [BindProperty] + public ObjectSource ObjectSource { get; set; } + + [BindProperty] + public DateOnly? CreatedDate { get; set; } + + [BindProperty] + public DateOnly? ModifiedDate { get; set; } + + [BindProperty] + public DateOnly UploadedDate { get; set; } + + [BindProperty] + public UniqueObjectId? LicenceId { get; set; } + + [BindProperty] + public List SelectedAuthorIds { get; set; } = []; + + [BindProperty] + public List SelectedTagIds { get; set; } = []; + + [BindProperty] + public List SelectedPackIds { get; set; } = []; + + public bool CanEdit => User.IsInRole("Admin") || User.IsInRole("Curator"); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + Scenario = await _db.SC5Files + .Include(s => s.Licence) + .Include(s => s.Authors) + .Include(s => s.Tags) + .Include(s => s.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(s => s.Id == id, ct); + + if (Scenario is null) + { + return NotFound(); + } + + await LoadRelatedDataAsync(ct); + + return Page(); + } + + public async Task OnPostEditAsync() + { + if (!CanEdit) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Scenario name is required."; + await ReloadAsync(Id); + return Page(); + } + + try + { + var scenario = await _db.SC5Files + .Include(s => s.Licence) + .Include(s => s.Authors) + .Include(s => s.Tags) + .Include(s => s.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(s => s.Id == Id); + + if (scenario is null) + { + ErrorMessage = "Scenario not found."; + return Page(); + } + + // Basic properties + scenario.Name = Name.Trim(); + scenario.Description = Description?.Trim(); + scenario.ObjectSource = ObjectSource; + scenario.CreatedDate = CreatedDate; + scenario.ModifiedDate = ModifiedDate; + + // Licence + if (LicenceId.HasValue) + { + var licence = await _db.Licences.FindAsync(new object[] { (object)LicenceId.Value }); + scenario.Licence = licence; + } + else + { + scenario.Licence = null; + } + + // Authors + scenario.Authors.Clear(); + if (SelectedAuthorIds.Count > 0) + { + var authors = await _db.Authors + .Where(a => SelectedAuthorIds.Contains(a.Id)) + .ToListAsync(); + foreach (var author in authors) + scenario.Authors.Add(author); + } + + // Tags + scenario.Tags.Clear(); + if (SelectedTagIds.Count > 0) + { + var tags = await _db.Tags + .Where(t => SelectedTagIds.Contains(t.Id)) + .ToListAsync(); + foreach (var tag in tags) + scenario.Tags.Add(tag); + } + + // SC5 File Packs + scenario.SC5FilePacks.Clear(); + if (SelectedPackIds.Count > 0) + { + var packs = await _db.SC5FilePacks + .Where(p => SelectedPackIds.Contains(p.Id)) + .ToListAsync(); + foreach (var pack in packs) + scenario.SC5FilePacks.Add(pack); + } + + await _db.SaveChangesAsync(); + SuccessMessage = $"Scenario '{Name.Trim()}' updated."; + } + catch (Exception ex) + { + ErrorMessage = $"Error updating scenario: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!CanEdit) + return Forbid(); + + var scenario = await _db.SC5Files.FindAsync(new object[] { (object)id }); + if (scenario is null) + { + await ReloadAsync(id); + ErrorMessage = "Failed to delete scenario."; + return Page(); + } + + _db.SC5Files.Remove(scenario); + await _db.SaveChangesAsync(); + + SuccessMessage = "Scenario deleted."; + return RedirectToPage("/Index", new { category = "sc5files" }); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + Scenario = await _db.SC5Files + .Include(s => s.Licence) + .Include(s => s.Authors) + .Include(s => s.Tags) + .Include(s => s.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(s => s.Id == id); + + await LoadRelatedDataAsync(CancellationToken.None); + } + + private async Task LoadRelatedDataAsync(CancellationToken ct) + { + if (Scenario is not null) + { + SC5FilePacks = Scenario.SC5FilePacks + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)) + .ToList(); + } + + AvailableAuthors = await _db.Authors + .OrderBy(a => a.Name) + .Select(a => new DtoAuthorEntry(a.Id, a.Name)) + .ToListAsync(ct); + + AvailableTags = await _db.Tags + .OrderBy(t => t.Name) + .Select(t => new DtoTagEntry(t.Id, t.Name)) + .ToListAsync(ct); + + AvailableLicences = await _db.Licences + .OrderBy(l => l.Name) + .Select(l => new DtoLicenceEntry(l.Id, l.Name, l.Text)) + .ToListAsync(ct); + + AvailablePacks = await _db.SC5FilePacks + .OrderBy(p => p.Name) + .Select(p => new DtoScenarioEntry(p.Id, p.Name)) + .ToListAsync(ct); + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Pages/Shared/_Layout.cshtml b/ObjectService/Pages/Shared/_Layout.cshtml index c8eb8298..81525bbe 100644 --- a/ObjectService/Pages/Shared/_Layout.cshtml +++ b/ObjectService/Pages/Shared/_Layout.cshtml @@ -1,5 +1,13 @@ @using Microsoft.Extensions.Diagnostics.HealthChecks +@using Microsoft.Extensions.Hosting +@using Microsoft.AspNetCore.Identity +@using Microsoft.EntityFrameworkCore +@using Definitions.Database @inject HealthCheckService HealthCheckService +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IHostEnvironment HostEnvironment +@inject LocoDbContext LocoDb @{ var healthReport = await HealthCheckService.CheckHealthAsync(); var healthDescription = healthReport.Entries.Values @@ -12,6 +20,32 @@ HealthStatus.Degraded => "health-indicator-degraded", _ => "health-indicator-unhealthy", }; + var isAuthenticated = SignInManager.IsSignedIn(User); + // Defense-in-depth: verify the user actually exists in the database. + // A fake ClaimsPrincipal (e.g. from a misconfigured auth bypass) may appear + // signed in but has no backing DB record; treating it as unauthenticated + // prevents the header showing a non-existent user and avoids 401 errors + // when clicking the user name link. + if (isAuthenticated) + { + var currentUser = await UserManager.GetUserAsync(User); + isAuthenticated = currentUser != null; + } + + var isDevelopment = HostEnvironment.IsDevelopment(); + var isAdmin = User.IsInRole("Admin"); + string? databaseProvider = null; + string? databaseSource = null; + string? databaseName = null; + string? databaseConnectionString = null; + if (isDevelopment) + { + var connection = LocoDb.Database.GetDbConnection(); + databaseProvider = LocoDb.Database.ProviderName; + databaseSource = connection.DataSource; + databaseName = connection.Database; + databaseConnectionString = connection.ConnectionString; + } } @@ -65,6 +99,88 @@ return `rgba(${red}, ${green}, ${blue}, ${alpha})`; }; + const hexToRgb = (hex) => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b]; + }; + + const rgbToHsl = (r, g, b) => { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + let h = 0, s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + return [h * 360, s * 100, l * 100]; + }; + + const hslToRgb = (h, s, l) => { + h /= 360; s /= 100; l /= 100; + let r, g, b; + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + }; + + const rgbToHex = (r, g, b) => { + return "#" + [r, g, b].map(x => x.toString(16).padStart(2, "0").toUpperCase()).join(""); + }; + + // Generate 5 evenly-spaced brightness variants from a base colour. + // Returns [darkest, darker, mid, lighter, lightest] as hex strings. + const generateVariants = (hex) => { + const [r, g, b] = hexToRgb(hex); + const [h, s, l] = rgbToHsl(r, g, b); + + const lightnesses = [ + Math.max(3, l - 44), // darkest + Math.max(3, l - 22), // darker + l, // mid (the base colour) + Math.min(97, l + 22), // lighter + Math.min(97, l + 44), // lightest + ]; + + return lightnesses.map(lv => { + const [rr, gg, bb] = hslToRgb(h, s, lv); + return rgbToHex(rr, gg, bb); + }); + }; + + // Reduce the HSL saturation of a hex colour by a given ratio (0–1). + // e.g. desaturateColour("#F6C945", 0.6) keeps 40% of original saturation. + const desaturateColour = (hex, amount) => { + const [r, g, b] = hexToRgb(hex); + const [h, s, l] = rgbToHsl(r, g, b); + const newS = Math.max(0, s * (1 - amount)); + const [rr, gg, bb] = hslToRgb(h, newS, l); + return rgbToHex(rr, gg, bb); + }; + const normalisePalette = (candidate) => { if (candidate === null || typeof candidate !== "object") { @@ -87,97 +203,72 @@ }; const buildTokens = (theme, palette) => { + // Generate 5 brightness variants [darkest, darker, mid, lighter, lightest] per palette key. + const variants = {}; + for (const key of paletteFields) + { + variants[key] = generateVariants(palette[key]); + } + + // In dark mode, desaturate the accent's darker variants so they lean + // towards grey instead of vivid yellow / whatever the accent hue is. + // This keeps the page dark and understated, not bright and colourful. if (theme === "dark") { - return { - bg: palette.background, - "bg-accent": palette.muted, - "bg-end": palette.background, - panel: rgba(palette.background, 0.84), - "panel-strong": rgba(palette.background, 0.96), - "panel-glass": rgba(palette.background, 0.78), - "surface-raised": rgba(palette.muted, 0.18), - "surface-subtle": rgba(palette.muted, 0.12), - "surface-table": rgba(palette.muted, 0.16), - border: rgba(palette.surface, 0.18), - "border-soft": rgba(palette.surface, 0.1), - "field-border": rgba(palette.surface, 0.22), - "field-bg": rgba(palette.background, 0.94), - text: palette.contrast, - muted: palette.surface, - accent: palette.accent, - "accent-strong": palette.contrast, - "accent-soft": rgba(palette.accent, 0.2), - "accent-soft-strong": rgba(palette.accent, 0.14), - "accent-border": rgba(palette.accent, 0.32), - "accent-border-strong": rgba(palette.accent, 0.4), - success: palette.success, - "success-soft": rgba(palette.success, 0.18), - "success-border": rgba(palette.success, 0.3), - warning: palette.warning, - "warning-soft": rgba(palette.warning, 0.14), - "warning-border": rgba(palette.warning, 0.26), - error: palette.error, - "error-soft": rgba(palette.error, 0.16), - "error-border": rgba(palette.error, 0.28), - danger: palette.error, - "danger-soft": rgba(palette.error, 0.16), - "danger-border": rgba(palette.error, 0.28), - "disabled-bg": rgba(palette.muted, 0.12), - "disabled-border": rgba(palette.muted, 0.22), - "button-primary-text": palette.background, - "page-glow-accent": rgba(palette.accent, 0.18), - "page-glow-warning": rgba(palette.warning, 0.12), - "image-grid-accent": rgba(palette.accent, 0.08), - "image-grid-warning": rgba(palette.warning, 0.08), - "image-shadow": "rgba(0, 0, 0, 0.32)", - shadow: "0 18px 48px rgba(0, 0, 0, 0.28)", - }; + variants["accent"] = variants["accent"].map((hex, i) => + desaturateColour(hex, [0.7, 0.55, 0.35, 0.15, 0][i]) + ); } + // In dark mode, flip the brightness position: 0↔4, 1↔3, 2→2. + const pos = (lightPos) => theme === "dark" ? 4 - lightPos : lightPos; + + // v(key, lightPos) → the appropriate hex colour for the current theme. + const v = (key, lightPos) => variants[key][pos(lightPos)]; + return { - bg: palette.contrast, - "bg-accent": palette.accent, - "bg-end": palette.surface, - panel: rgba(palette.contrast, 0.84), - "panel-strong": palette.contrast, - "panel-glass": rgba(palette.contrast, 0.74), - "surface-raised": rgba(palette.contrast, 0.68), - "surface-subtle": rgba(palette.surface, 0.58), - "surface-table": rgba(palette.surface, 0.52), - border: rgba(palette.background, 0.16), - "border-soft": rgba(palette.background, 0.08), - "field-border": rgba(palette.background, 0.18), - "field-bg": rgba(palette.contrast, 0.9), - text: palette.background, - muted: palette.muted, - accent: palette.accent, - "accent-strong": palette.background, - "accent-soft": rgba(palette.accent, 0.18), - "accent-soft-strong": rgba(palette.accent, 0.12), - "accent-border": rgba(palette.accent, 0.32), - "accent-border-strong": rgba(palette.accent, 0.4), - success: palette.success, - "success-soft": rgba(palette.success, 0.14), - "success-border": rgba(palette.success, 0.24), - warning: palette.warning, - "warning-soft": rgba(palette.warning, 0.14), - "warning-border": rgba(palette.warning, 0.24), - error: palette.error, - "error-soft": rgba(palette.error, 0.12), - "error-border": rgba(palette.error, 0.24), - danger: palette.error, - "danger-soft": rgba(palette.error, 0.12), - "danger-border": rgba(palette.error, 0.24), - "disabled-bg": rgba(palette.surface, 0.42), - "disabled-border": rgba(palette.muted, 0.22), - "button-primary-text": palette.background, - "page-glow-accent": rgba(palette.accent, 0.24), - "page-glow-warning": rgba(palette.warning, 0.22), - "image-grid-accent": rgba(palette.accent, 0.08), - "image-grid-warning": rgba(palette.warning, 0.08), - "image-shadow": rgba(palette.background, 0.18), - shadow: `0 18px 48px ${rgba(palette.background, 0.14)}`, + bg: v("contrast", 4), + "bg-accent": v("accent", 4), + "bg-end": v("surface", 4), + panel: rgba(v("contrast", 3), 0.84), + "panel-strong": v("contrast", 4), + "panel-glass": rgba(v("contrast", 3), 0.74), + "surface-raised": rgba(v("contrast", 3), 0.68), + "surface-subtle": rgba(v("surface", 2), 0.58), + "surface-table": rgba(v("surface", 2), 0.52), + border: rgba(v("background", 1), 0.16), + "border-soft": rgba(v("background", 0), 0.08), + "field-border": rgba(v("background", 1), 0.18), + "field-bg": rgba(v("contrast", 3), 0.9), + text: v("background", 0), + muted: v("muted", 2), + accent: v("accent", 2), + "accent-strong": v("background", 0), + "accent-soft": rgba(v("accent", 2), 0.18), + "accent-soft-strong": rgba(v("accent", 2), 0.12), + "accent-border": rgba(v("accent", 2), 0.32), + "accent-border-strong": rgba(v("accent", 2), 0.4), + success: v("success", 1), + "success-soft": rgba(v("success", 1), 0.14), + "success-border": rgba(v("success", 1), 0.22), + warning: v("warning", 1), + "warning-soft": rgba(v("warning", 1), 0.14), + "warning-border": rgba(v("warning", 1), 0.22), + error: v("error", 1), + "error-soft": rgba(v("error", 1), 0.12), + "error-border": rgba(v("error", 1), 0.24), + danger: v("error", 1), + "danger-soft": rgba(v("error", 1), 0.12), + "danger-border": rgba(v("error", 1), 0.24), + "disabled-bg": rgba(v("surface", 1), 0.42), + "disabled-border": rgba(v("muted", 1), 0.22), + "button-primary-text": v("background", 0), + "page-glow-accent": rgba(v("accent", 2), 0.24), + "page-glow-warning": rgba(v("warning", 2), 0.22), + "image-grid-accent": rgba(v("accent", 2), 0.08), + "image-grid-warning": rgba(v("warning", 2), 0.08), + "image-shadow": rgba(v("background", 0), 0.18), + shadow: `0 18px 48px ${rgba(v("background", 0), 0.14)}`, }; }; @@ -202,13 +293,11 @@ { element.style.setProperty(`--${name}`, value); } + element.style.setProperty("color-scheme", theme); }; const clearPalette = (element) => { - for (const name of Object.keys(buildTokens("light", defaultPalette))) - { - element.style.removeProperty(`--${name}`); - } + applyPalette(element, "light", defaultPalette); }; const readStorage = (key) => { @@ -265,6 +354,7 @@ buildTokens, clearPalette, defaultPalette, + generateVariants, getStoredPalette, normaliseHex, normalisePalette, @@ -284,6 +374,7 @@ root.dataset.theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } + // Always apply a palette — default or custom. const storedPalette = getStoredPalette(paletteStorageKey) ?? getStoredPalette(legacyPaletteStorageKey, true); if (storedPalette !== null) { @@ -291,6 +382,10 @@ removeStorage(legacyPaletteStorageKey); applyPalette(root, root.dataset.theme, storedPalette); } + else + { + applyPalette(root, root.dataset.theme, defaultPalette); + } })(); @@ -303,12 +398,27 @@ @await RenderSectionAsync("HeaderContent", required: false) Server @healthReport.Status - - + + }
diff --git a/ObjectService/Pages/Tags/Details.cshtml b/ObjectService/Pages/Tags/Details.cshtml new file mode 100644 index 00000000..c255b7ce --- /dev/null +++ b/ObjectService/Pages/Tags/Details.cshtml @@ -0,0 +1,171 @@ +@page "/Tags/{id}" +@model ObjectService.Pages.Tags.DetailsModel +@{ + ViewData["Title"] = Model.Tag?.Name ?? "Tag"; + var isAdmin = User.IsInRole("Admin"); +} + +@if (Model.Tag is null) +{ +
+

Tag not found

+

The tag you are looking for does not exist.

+ Back to tag list +
+ return; +} + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ +
@Model.SuccessMessage
+} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ Back to tags +
+

@Model.Tag.Name

+ @if (isAdmin) + { +
+ +
+ +
+
+ } +
+ @if (isAdmin) + { + + } +
+
+ +
+
+

Objects

+ @if (Model.Objects.Count == 0) + { +

No objects are associated with this tag.

+ } + else + { +
+ + + + + + + + @foreach (var obj in Model.Objects) + { + + + + } + +
Name
@obj.Name
+
+ } +
+ +
+

Object Packs

+ @if (Model.ObjectPacks.Count == 0) + { +

No object packs are associated with this tag.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.ObjectPacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+ +
+

SC5 Files

+ @if (Model.SC5Files.Count == 0) + { +

No SC5 files are associated with this tag.

+ } + else + { +
+ + + + + + + + @foreach (var file in Model.SC5Files) + { + + + + } + +
Name
@file.Name
+
+ } +
+ +
+

SC5 File Packs

+ @if (Model.SC5FilePacks.Count == 0) + { +

No SC5 file packs are associated with this tag.

+ } + else + { +
+ + + + + + + + @foreach (var pack in Model.SC5FilePacks) + { + + + + } + +
Name
@pack.Name
+
+ } +
+
\ No newline at end of file diff --git a/ObjectService/Pages/Tags/Details.cshtml.cs b/ObjectService/Pages/Tags/Details.cshtml.cs new file mode 100644 index 00000000..9c1e6678 --- /dev/null +++ b/ObjectService/Pages/Tags/Details.cshtml.cs @@ -0,0 +1,152 @@ +using Definitions.Database; +using Definitions.DTO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using ObjectService.Services; + +namespace ObjectService.Pages.Tags; + +public sealed class DetailsModel : PageModel +{ + readonly LocoDbContext _db; + readonly ICrudService _tagService; + + public DetailsModel(LocoDbContext db, ICrudService tagService) + { + _db = db; + _tagService = tagService; + } + + public TblTag? Tag { get; private set; } + + public List Objects { get; private set; } = []; + public List ObjectPacks { get; private set; } = []; + public List SC5Files { get; private set; } = []; + public List SC5FilePacks { get; private set; } = []; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + [BindProperty] + public UniqueObjectId Id { get; set; } + + [BindProperty] + public string Name { get; set; } = string.Empty; + + public bool IsAdmin => User.IsInRole("Admin"); + + public async Task OnGetAsync(UniqueObjectId id, CancellationToken ct) + { + Tag = await _db.Tags + .Include(t => t.Objects) + .Include(t => t.ObjectPacks) + .Include(t => t.SC5Files) + .Include(t => t.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.Id == id, ct); + + if (Tag is null) + { + return NotFound(); + } + + Objects = Tag.Objects + .OrderBy(o => o.Name) + .Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)) + .ToList(); + + ObjectPacks = Tag.ObjectPacks + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)) + .ToList(); + + SC5Files = Tag.SC5Files + .OrderBy(f => f.Name) + .Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)) + .ToList(); + + SC5FilePacks = Tag.SC5FilePacks + .OrderBy(p => p.Name) + .Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)) + .ToList(); + + return Page(); + } + + public async Task OnPostEditAsync() + { + if (!IsAdmin) + return Forbid(); + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Tag name is required."; + await ReloadAsync(Id); + return Page(); + } + + try + { + var entry = new DtoTagEntry(Id, Name.Trim()); + var updated = await _tagService.UpdateAsync(Id, entry, CancellationToken.None); + if (updated != null) + { + SuccessMessage = $"Tag '{Name.Trim()}' updated."; + } + else + { + ErrorMessage = "Tag not found."; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error updating tag: {ex.Message}"; + } + + await ReloadAsync(Id); + return Page(); + } + + public async Task OnPostDeleteAsync(UniqueObjectId id) + { + if (!IsAdmin) + return Forbid(); + + var deleted = await _tagService.DeleteAsync(id, CancellationToken.None); + if (deleted) + { + SuccessMessage = "Tag deleted."; + return RedirectToPage("/Index", new { category = "tags" }); + } + + await ReloadAsync(id); + ErrorMessage = "Failed to delete tag."; + return Page(); + } + + private async Task ReloadAsync(UniqueObjectId id) + { + Tag = await _db.Tags + .Include(t => t.Objects) + .Include(t => t.ObjectPacks) + .Include(t => t.SC5Files) + .Include(t => t.SC5FilePacks) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.Id == id); + + if (Tag != null) + { + Objects = Tag.Objects.OrderBy(o => o.Name).Select(o => new ListItem(o.Id, o.Description ?? o.Name, "Objects", null)).ToList(); + ObjectPacks = Tag.ObjectPacks.OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "ObjectPacks", null)).ToList(); + SC5Files = Tag.SC5Files.OrderBy(f => f.Name).Select(f => new ListItem(f.Id, f.Name, "SC5Files", null)).ToList(); + SC5FilePacks = Tag.SC5FilePacks.OrderBy(p => p.Name).Select(p => new ListItem(p.Id, p.Name, "SC5FilePacks", null)).ToList(); + } + } + + public record ListItem(UniqueObjectId Id, string Name, string Kind, string? Extra); +} \ No newline at end of file diff --git a/ObjectService/Program.cs b/ObjectService/Program.cs index 07df6995..aae1d979 100644 --- a/ObjectService/Program.cs +++ b/ObjectService/Program.cs @@ -2,6 +2,7 @@ using Definitions.ObjectModels.Graphics; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; @@ -11,9 +12,11 @@ using Microsoft.OpenApi; using ObjectService; using ObjectService.Frontend; -using ObjectService.RouteHandlers; +using ObjectService.Identity; using ObjectService.Services; +using ObjectService.RouteHandlers; using Scalar.AspNetCore; +using System.Security.Claims; using System.Text; using System.Threading.RateLimiting; @@ -63,7 +66,7 @@ } }); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddObjectEditorServices(); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); @@ -138,8 +141,20 @@ builder.Services .AddIdentityApiEndpoints() + .AddRoles() .AddEntityFrameworkStores(); +// Relax default password rules for development. +// Override via appsettings or user secrets in production. +builder.Services.Configure(options => +{ + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequiredLength = 12; +}); + // Configure bearer token expiration from settings builder.Services.Configure(IdentityConstants.BearerScheme, options => { @@ -164,13 +179,55 @@ builder.Services.AddAuthorization(options => { - // Configure the default policy to accept both Identity Bearer tokens and JWT tokens + // Configure the default policy to accept Identity cookies, Identity Bearer tokens, and JWT tokens. + // This allows both page-based cookie auth (from SignInManager) and API bearer token auth. options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() - .AddAuthenticationSchemes(IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser() .Build(); + + // Policy: user must own the object (id from route) or be an Admin + options.AddPolicy("CanEditObject", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .AddRequirements(new ObjectOwnershipRequirement())); + + // Admin-only policy (for user/role management) + options.AddPolicy("AdminOnly", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireRole("Admin")); +// Curator policy – any user with at least one curator permission (or Admin) + options.AddPolicy("Curator", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .RequireAssertion(context => + context.User.IsInRole("Admin") || + context.User.HasClaim(LocoPermissions.ClaimType, LocoPermissions.TagsManage) || + context.User.HasClaim(LocoPermissions.ClaimType, LocoPermissions.LicenceManage) || + context.User.HasClaim(LocoPermissions.ClaimType, LocoPermissions.AuthorManage))); + + // Individual permission policies for fine-grained control when needed + options.AddPolicy("CanManageTags", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(LocoPermissions.TagsManage))); + + options.AddPolicy("CanManageLicences", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(LocoPermissions.LicenceManage))); + + options.AddPolicy("CanManageAuthors", policy => + policy.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(LocoPermissions.AuthorManage))); }); +// Register the ownership authorization handler +builder.Services.AddScoped(); +// Register the permission authorization handler +builder.Services.AddScoped(); + // Used for the Identity stuff to send emails to users // disabling this line effectively disables all email sending, as a default NoOpEmailSender is used in place // builder.Services.AddTransient(); @@ -178,15 +235,45 @@ var app = builder.Build(); app.UseForwardedHeaders(); + app.UseHttpLogging(); app.UseRateLimiter(); app.UseStaticFiles(); + +// Dev-mode authentication bypass: when enabled, every API request runs as an authenticated admin. +// Set "ObjectService:DisableAuthentication": true in appsettings.Development.json. +// UI pages (Razor Pages) are excluded so the login/logout flow works normally; +// developers can use the "Dev Login" button in the header for quick authentication. +var disableAuth = builder.Configuration.GetValue("ObjectService:DisableAuthentication") ?? false; +if (disableAuth) +{ + app.Use((context, next) => + { + // Only inject the fake identity for API endpoints. UI pages (/Account/*, + // /Manage/*, etc.) should go through the normal cookie-based auth flow so + // that login, logout, and the header's "Log in"/user-name display all work correctly. + if (context.Request.Path.StartsWithSegments("/v2", StringComparison.OrdinalIgnoreCase)) + { + // Inject a fake admin ClaimsPrincipal before the auth middleware runs. + // ASP.NET Core's UseAuthentication skips when context.User is already set. + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "0"), + new Claim(ClaimTypes.Name, "devadmin"), + new Claim(ClaimTypes.Role, "Admin"), + }; + var identity = new ClaimsIdentity(claims, IdentityConstants.ApplicationScheme); + context.User = new ClaimsPrincipal(identity); + } + + return next(); + }); +} + app.UseAuthentication(); app.UseAuthorization(); app.MapIdentityApi(); - -// defining routes here, after MapIdentityApi, will overwrite them, allowing us to customise them // app.MapPost("/register", () => Results.Ok()); _ = app @@ -195,10 +282,7 @@ _ = app.MapRazorPages(); -_ = app.MapV2Routes() - .RequireRateLimiting(tokenPolicy); - -_ = app.MapV1Routes() +_ = app.MapApiRoutes() .RequireRateLimiting(tokenPolicy); var showScalar = builder.Configuration.GetValue("ObjectService:ShowScalar"); @@ -218,6 +302,9 @@ }); } +// Run database initialization (creates admin user, assigns ownership, etc.) +await DatabaseInitializer.InitializeAsync(app); + app.Run(); #pragma warning disable CA1050 // Declare types in namespaces diff --git a/ObjectService/RouteHandlers/BaseDataTableRouteHandler.cs b/ObjectService/RouteHandlers/BaseDataTableRouteHandler.cs deleted file mode 100644 index 10382a76..00000000 --- a/ObjectService/RouteHandlers/BaseDataTableRouteHandler.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Definitions; -using Definitions.Database; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers; - -public abstract class BaseDataTableRouteHandler : ITableRouteHandler - where TDto : class, IHasId - where TRow : class, IHasId - where THandler : ITableRouteConfig -{ - public static string BaseRoute => THandler.GetBaseRoute(); - - public static Delegate ListDelegate => ListAsync; - - public static Delegate CreateDelegate => CreateAsync; - - public static Delegate ReadDelegate => ReadAsync; - - public static Delegate UpdateDelegate => UpdateAsync; - - public static Delegate DeleteDelegate => DeleteAsync; - - public static async Task CreateAsync(TDto request, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - => await BaseDataTableRouteHandlerImpl.CreateAsync( - THandler.GetTable(db), - THandler.ToDtoFunc, - THandler.ToRowFunc, - request, - () => (THandler.TryValidateCreate(request, db, out var result), result), - THandler.GetBaseRoute(), - db, - cancellationToken); - - public static async Task ReadAsync(UniqueObjectId id, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - => await BaseDataTableRouteHandlerImpl.ReadAsync(THandler.GetTable(db), THandler.ToDtoFunc, id, db, cancellationToken); - - public static async Task UpdateAsync(UniqueObjectId id, TDto request, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - => await BaseDataTableRouteHandlerImpl.UpdateAsync( - THandler.GetTable(db), - THandler.ToDtoFunc, - THandler.ToRowFunc, - request, - () => (THandler.TryValidateCreate(request, db, out var result), result), - THandler.GetBaseRoute(), - id, - db, - THandler.UpdateFunc, - cancellationToken); - - public static async Task DeleteAsync(UniqueObjectId id, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - => await BaseDataTableRouteHandlerImpl.DeleteAsync(THandler.GetTable(db), THandler.ToDtoFunc, id, db, cancellationToken); - - public static async Task ListAsync(HttpContext context, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - => await BaseDataTableRouteHandlerImpl.ListAsync(context, THandler.GetTable(db), THandler.ToDtoFunc, cancellationToken); -} - -public static class BaseDataTableRouteHandlerImpl -{ - public static async Task CreateAsync(DbSet table, Func dtoConverter, Func rowConverter, TDto request, Func<(bool Success, IResult? ErrorMessage)> tryValidateFunc, string baseRoute, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - where TDto : class, IHasId - where TRow : class, IHasId - { - var (Success, ErrorMessage) = tryValidateFunc(); - if (!Success) - { - return ErrorMessage!; - } - - var row = rowConverter(request); - _ = await table.AddAsync(row, cancellationToken); - _ = await db.SaveChangesAsync(cancellationToken); - return Results.Created($"{baseRoute}/{row.Id}", dtoConverter(row)); - } - - public static async Task ReadAsync(DbSet table, Func dtoConverter, UniqueObjectId id, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - where TDto : class, IHasId - where TRow : class, IHasId - => await table.FindAsync([id], cancellationToken) is TRow row - ? Results.Ok(dtoConverter(row)) - : Results.NotFound(); - - public static async Task UpdateAsync(DbSet table, Func dtoConverter, Func rowConverter, TDto request, Func<(bool Success, IResult? ErrorMessage)> tryValidateFunc, string baseRoute, UniqueObjectId id, [FromServices] LocoDbContext db, Action updateFunc, CancellationToken cancellationToken) - where TDto : class, IHasId - where TRow : class, IHasId - { - if (await table.FindAsync([id], cancellationToken) is not TRow row) // do not use Request.Id here, use the route id - { - return await CreateAsync(table, dtoConverter, rowConverter, request, tryValidateFunc, baseRoute, db, cancellationToken); - } - - updateFunc(request, row); - _ = await db.SaveChangesAsync(cancellationToken); - return Results.Accepted($"{baseRoute}/{row.Id}", dtoConverter(row)); - } - - public static async Task DeleteAsync(DbSet table, Func dtoConverter, UniqueObjectId id, [FromServices] LocoDbContext db, CancellationToken cancellationToken) - where TDto : class, IHasId - where TRow : class, IHasId - { - if (await table.FindAsync([id], cancellationToken) is TRow row) - { - _ = table.Remove(row); - _ = await db.SaveChangesAsync(cancellationToken); - return Results.Ok(); - } - - return Results.NotFound(); - } - - public static async Task ListAsync(HttpContext context, DbSet table, Func dtoConverter, CancellationToken cancellationToken) - where TDto : class, IHasId - where TRow : class, IHasId - => Results.Ok(await table - .Select(x => dtoConverter(x)) - .ToListAsync(cancellationToken)); -} diff --git a/ObjectService/RouteHandlers/BaseTableRouteHandler.cs b/ObjectService/RouteHandlers/BaseTableRouteHandler.cs index 0e498673..00e23bb3 100644 --- a/ObjectService/RouteHandlers/BaseTableRouteHandler.cs +++ b/ObjectService/RouteHandlers/BaseTableRouteHandler.cs @@ -4,29 +4,64 @@ namespace ObjectService.RouteHandlers; public static class BaseTableRouteHandler { -public static void MapRoutes( -IEndpointRouteBuilder parentRoute -) where THandler : ITableRouteHandler -{ -var baseRoute = parentRoute -.MapGroup(THandler.BaseRoute) -.WithTags(RouteHelpers.MakeNicePlural(typeof(THandler).Name)); + /// + /// Maps only the read (GET) routes (list + get-by-id) for a handler. + /// Use this for publicly-accessible read routes. + /// + public static void MapRoutes( + ITableRouteHandler handler, + IEndpointRouteBuilder parentRoute, + IConfiguration config) + { + var baseRoute = parentRoute + .MapGroup(handler.BaseRoute) + .WithTags(RouteHelpers.MakeNicePlural(handler.GetType().Name)); -_ = baseRoute.MapGet(string.Empty, THandler.ListDelegate); + _ = baseRoute.MapGet(string.Empty, handler.ListDelegate); -var resourceRoute = baseRoute.MapGroup(RoutesV2.ResourceRoute); -_ = resourceRoute.MapGet(string.Empty, THandler.ReadDelegate); + var resourceRoute = baseRoute.MapGroup(RoutesV2.ResourceRoute); + _ = resourceRoute.MapGet(string.Empty, handler.ReadDelegate); -var config = parentRoute.ServiceProvider.GetRequiredService(); -var enableWriteRoutes = config.GetValue("ObjectService:EnableWriteRoutes") ?? false; + handler.MapAdditionalRoutes(baseRoute); + } -if (enableWriteRoutes) -{ -_ = baseRoute.MapPost(string.Empty, THandler.CreateDelegate); -_ = resourceRoute.MapPut(string.Empty, THandler.UpdateDelegate); -_ = resourceRoute.MapDelete(string.Empty, THandler.DeleteDelegate); -} + /// + /// Maps only the write (POST/PUT/DELETE) routes for a handler, + /// for use when write routes need a different authorization group than read routes. + /// + public static void MapWriteRoutes( + ITableRouteHandler handler, + IEndpointRouteBuilder parentRoute, + IConfiguration config) + { + var enableWriteRoutes = config.GetValue("ObjectService:EnableWriteRoutes") ?? false; -THandler.MapAdditionalRoutes(baseRoute); -} + if (!enableWriteRoutes) + { + return; + } + + var baseRoute = parentRoute + .MapGroup(handler.BaseRoute) + .WithTags(RouteHelpers.MakeNicePlural(handler.GetType().Name)); + + _ = baseRoute.MapPost(string.Empty, handler.CreateDelegate); + + var resourceRoute = baseRoute.MapGroup(RoutesV2.ResourceRoute); + _ = resourceRoute.MapPut(string.Empty, handler.UpdateDelegate); + _ = resourceRoute.MapDelete(string.Empty, handler.DeleteDelegate); + } + + /// + /// Maps all routes (both read and write) for a handler. + /// Use this for handlers where read and write share the same authorization level (e.g. admin-only). + /// + public static void MapAllRoutes( + ITableRouteHandler handler, + IEndpointRouteBuilder parentRoute, + IConfiguration config) + { + MapRoutes(handler, parentRoute, config); + MapWriteRoutes(handler, parentRoute, config); + } } diff --git a/ObjectService/RouteHandlers/ITableRouteConfig.cs b/ObjectService/RouteHandlers/ITableRouteConfig.cs deleted file mode 100644 index d0936ebf..00000000 --- a/ObjectService/RouteHandlers/ITableRouteConfig.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Definitions; -using Definitions.Database; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers; - -public interface ITableRouteConfig - where TDto : class, IHasId - where TRow : class, IHasId -{ - static abstract string GetBaseRoute(); - static abstract DbSet GetTable(LocoDbContext db); - static abstract TRow ToRowFunc(TDto request); - static abstract TDto ToDtoFunc(TRow request); - static abstract void UpdateFunc(TDto request, TRow row); - - static abstract bool TryValidateCreate(TDto request, [FromServices] LocoDbContext db, out IResult? result); -} diff --git a/ObjectService/RouteHandlers/ITableRouteHandler.cs b/ObjectService/RouteHandlers/ITableRouteHandler.cs index b7e9ea34..ccb2a80f 100644 --- a/ObjectService/RouteHandlers/ITableRouteHandler.cs +++ b/ObjectService/RouteHandlers/ITableRouteHandler.cs @@ -2,14 +2,14 @@ namespace ObjectService.RouteHandlers; public interface ITableRouteHandler { - static abstract string BaseRoute { get; } - static abstract Delegate ListDelegate { get; } - static abstract Delegate CreateDelegate { get; } - static abstract Delegate ReadDelegate { get; } - static abstract Delegate UpdateDelegate { get; } - static abstract Delegate DeleteDelegate { get; } + string BaseRoute { get; } + Delegate ListDelegate { get; } + Delegate CreateDelegate { get; } + Delegate ReadDelegate { get; } + Delegate UpdateDelegate { get; } + Delegate DeleteDelegate { get; } - static virtual void MapRoutes(IEndpointRouteBuilder endpoints) { } + void MapRoutes(IEndpointRouteBuilder endpoints); - static virtual void MapAdditionalRoutes(IEndpointRouteBuilder endpoints) { } + void MapAdditionalRoutes(IEndpointRouteBuilder endpoints); } diff --git a/ObjectService/RouteHandlers/TableHandlers/AuthorRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/AuthorRouteHandler.cs deleted file mode 100644 index 6a72bfa3..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/AuthorRouteHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Definitions.Database; -using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers; - -public class AuthorRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Authors; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.Authors; - - public static DtoAuthorEntry ToDtoFunc(TblAuthor table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoAuthorEntry request, TblAuthor row) - => row.Name = request.Name; - - public static TblAuthor ToRowFunc(DtoAuthorEntry request) - => request.ToTable(); - - public static bool TryValidateCreate(DtoAuthorEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - result = Results.BadRequest("Cannot add an empty or whitespace-only name."); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/CrudRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/CrudRouteHandler.cs new file mode 100644 index 00000000..c4942020 --- /dev/null +++ b/ObjectService/RouteHandlers/TableHandlers/CrudRouteHandler.cs @@ -0,0 +1,67 @@ +using Definitions; +using Microsoft.AspNetCore.Mvc; +using ObjectService.Services; + +namespace ObjectService.RouteHandlers.TableHandlers; + +public class CrudRouteHandler : ITableRouteHandler + where TDto : class, IHasId + where TRow : class, IHasId +{ + private readonly string _baseRoute; + + public CrudRouteHandler(string baseRoute) + { + _baseRoute = baseRoute; + BaseRoute = baseRoute; + } + + public string BaseRoute { get; } + + public Delegate ListDelegate => ListAsync; + public Delegate CreateDelegate => CreateAsync; + public Delegate ReadDelegate => ReadAsync; + public Delegate UpdateDelegate => UpdateAsync; + public Delegate DeleteDelegate => DeleteAsync; + + public void MapRoutes(IEndpointRouteBuilder endpoints) + => BaseTableRouteHandler.MapRoutes(this, endpoints, + endpoints.ServiceProvider.GetRequiredService()); + + public void MapAdditionalRoutes(IEndpointRouteBuilder endpoints) { } + + async Task ListAsync(HttpContext context, [FromServices] ICrudService service, CancellationToken ct) + { + var items = await service.ListAsync(context, ct); + return Results.Ok(items); + } + + async Task CreateAsync([FromBody] TDto request, [FromServices] ICrudService service, CancellationToken ct) + { + if (!service.TryValidateCreate(request, out var error)) + { + return Results.Problem(error, statusCode: StatusCodes.Status400BadRequest); + } + + var dto = await service.CreateAsync(request, ct); + return Results.Created($"{_baseRoute}/{dto.Id}", dto); + } + + async Task ReadAsync([FromRoute] UniqueObjectId id, [FromServices] ICrudService service, CancellationToken ct) + { + var dto = await service.ReadAsync(id, ct); + return dto != null ? Results.Ok(dto) : Results.NotFound(); + } + + async Task UpdateAsync([FromRoute] UniqueObjectId id, [FromBody] TDto request, [FromServices] ICrudService service, CancellationToken ct) + { + var dto = await service.UpdateAsync(id, request, ct); + return dto != null ? Results.Accepted($"{_baseRoute}/{dto.Id}", dto) : Results.NotFound(); + } + + async Task DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] ICrudService service, CancellationToken ct) + { + var deleted = await service.DeleteAsync(id, ct); + return deleted ? Results.Ok() : Results.NotFound(); + } +} diff --git a/ObjectService/RouteHandlers/TableHandlers/LicenceRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/LicenceRouteHandler.cs deleted file mode 100644 index f4de07f0..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/LicenceRouteHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Definitions.Database; -using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public class LicenceRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Licences; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.Licences; - - public static DtoLicenceEntry ToDtoFunc(TblLicence table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoLicenceEntry request, TblLicence row) - { - row.Name = request.Name; - row.Text = request.Text; - } - - public static TblLicence ToRowFunc(DtoLicenceEntry request) - => request.ToTable(); - - public static bool TryValidateCreate([FromBody] DtoLicenceEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - result = Results.Problem("Cannot add an empty or whitespace-only name.", statusCode: StatusCodes.Status400BadRequest); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/ObjectMissingRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ObjectMissingRouteHandler.cs deleted file mode 100644 index a6da94bc..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/ObjectMissingRouteHandler.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Definitions.Database; -using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public class ObjectMissingRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Missing; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.ObjectsMissing; - - public static DtoObjectMissingEntry ToDtoFunc(TblObjectMissing table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoObjectMissingEntry request, TblObjectMissing row) - { - row.DatName = request.DatName; - row.DatChecksum = request.DatChecksum; - row.ObjectType = request.ObjectType; - } - - public static TblObjectMissing ToRowFunc(DtoObjectMissingEntry request) - => request.ToTable(); - - public static bool TryValidateCreate([FromBody] DtoObjectMissingEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.DatName)) - { - result = Results.BadRequest("Cannot add an empty or whitespace-only DatName."); - return false; - } - - if (request.DatChecksum == 0) - { - result = Results.BadRequest("Cannot set a DatChecksum to 0"); - return false; - } - - if (!Enum.IsDefined(request.ObjectType)) - { - result = Results.BadRequest($"Invalid ObjectType: {request.ObjectType}"); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/ObjectPackRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ObjectPackRouteHandler.cs index dde95a86..ef3b0910 100644 --- a/ObjectService/RouteHandlers/TableHandlers/ObjectPackRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/ObjectPackRouteHandler.cs @@ -1,126 +1,46 @@ -using Common; -using Definitions; -using Definitions.Database; using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.ObjectModels.Types; -using Definitions.SourceData; using Definitions.Web; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.IO.Compression; +using ObjectService.Services; +using System.Security.Claims; namespace ObjectService.RouteHandlers.TableHandlers; public class ObjectPackRouteHandler : ITableRouteHandler { - public static string BaseRoute => RoutesV2.ObjectPacks; - public static Delegate ListDelegate => ListAsync; - public static Delegate CreateDelegate => CreateAsync; - public static Delegate ReadDelegate => ReadAsync; - public static Delegate UpdateDelegate => UpdateAsync; - public static Delegate DeleteDelegate => DeleteAsync; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) - { - var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute); - _ = resourceRoute.MapGet(RoutesV2.File, GetObjectPackFileAsync); - } - - public static async Task ListAsync(HttpContext context, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.ObjectPacks - .Include(l => l.Licence) - .ToListAsync()) - .Select(x => x.ToDtoEntry()) - .OrderBy(x => x.Name)); - - public static async Task CreateAsync([FromBody] DtoItemPackDescriptor request, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task ReadAsync(UniqueObjectId id, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.ObjectPacks - .Where(x => x.Id == id) - .Include(l => l.Licence) - .Select(x => new ExpandedTblPack(x, x.Objects, x.Authors, x.Tags)) - .ToListAsync()) - .Select(x => x.ToDtoDescriptor()) - .OrderBy(x => x.Name)); - - public static async Task UpdateAsync([FromRoute] UniqueObjectId id, [FromBody] DtoItemPackDescriptor request, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task GetObjectPackFileAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp) - { - var pack = await db.ObjectPacks - .Where(x => x.Id == id) - .Include(x => x.Objects) - .ThenInclude(o => o.DatObjects) - .SingleOrDefaultAsync(); - - if (pack == null) - { - return Results.NotFound(); - } - - var sfm = sp.GetRequiredService(); - var tempZipPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.zip"); - var zipStream = new FileStream( - tempZipPath, - FileMode.Create, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 4096, - options: FileOptions.Asynchronous | FileOptions.DeleteOnClose); - - using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var obj in pack.Objects) - { - if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) - { - continue; - } - - if (obj.Availability == ObjectAvailability.Unavailable) - { - continue; - } - - foreach (var dat in obj.DatObjects) - { - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null) - { - continue; - } - - if (!RouteHelpers.TryGetSafeRelativePathUnderRoot(sfm.ObjectsFolder, entry.FileName, out var fullFilePath, out var entryName)) - { - continue; - } - - if (!File.Exists(fullFilePath)) - { - continue; - } +public string BaseRoute => RoutesV2.ObjectPacks; +public Delegate ListDelegate => ListAsync; +public Delegate CreateDelegate => CreateAsync; +public Delegate ReadDelegate => ReadAsync; +public Delegate UpdateDelegate => UpdateAsync; +public Delegate DeleteDelegate => DeleteAsync; +public void MapRoutes(IEndpointRouteBuilder e) => BaseTableRouteHandler.MapRoutes(this, e, e.ServiceProvider.GetRequiredService()); +public void MapAdditionalRoutes(IEndpointRouteBuilder p) => p.MapGroup(RoutesV2.ResourceRoute).MapGet(RoutesV2.File, GetPackFileAsync); + +async Task ListAsync([FromServices] IObjectPackService svc, CancellationToken ct) => Results.Ok(await svc.ListPacksAsync(ct)); +async Task ReadAsync(UniqueObjectId id, [FromServices] IObjectPackService svc, CancellationToken ct) => Results.Ok(await svc.GetPackAsync(id, ct)); +async Task GetPackFileAsync([FromRoute] UniqueObjectId id, [FromServices] IObjectPackService svc, CancellationToken ct) +{ +var (stream, name) = await svc.GetPackFileAsync(id, ct); +return stream != null ? Results.File(stream, "application/zip", name) : Results.NotFound(); +} - await using var fileStream = File.OpenRead(fullFilePath); - var zipEntry = archive.CreateEntry(entryName, CompressionLevel.Optimal); - await using var entryStream = zipEntry.Open(); - await fileStream.CopyToAsync(entryStream); - } - } - } +async Task CreateAsync( +[FromBody] DtoItemPackDescriptor request, +HttpContext httpContext, +[FromServices] IObjectPackService svc, +CancellationToken ct) +{ +var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; +if (string.IsNullOrEmpty(userIdClaim) || !ulong.TryParse(userIdClaim, out var userId)) +{ +return Results.Unauthorized(); +} - zipStream.Position = 0; - var downloadFileName = DownloadNameHelper.MakeSafeDownloadFileName(pack.Name, ".zip", "object-pack"); - return Results.File(zipStream, "application/zip", downloadFileName); - } +var created = await svc.CreatePackAsync(request, userId, ct); +return Results.Created($"{RoutesV2.Prefix}{BaseRoute}/{created.Id}", created); } + +Task UpdateAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); +Task DeleteAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); +} \ No newline at end of file diff --git a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs index 448e4654..a0167688 100644 --- a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs @@ -1,758 +1,117 @@ -using Common; -using Common.Logging; -using Dat.Converters; -using Dat.FileParsing; using Definitions; -using Definitions.Database; using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.ObjectModels; -using Definitions.ObjectModels.Graphics; -using Definitions.ObjectModels.Objects.Vehicle; using Definitions.ObjectModels.Types; -using Definitions.SourceData; using Definitions.Web; -using Index; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SixLabors.ImageSharp; -using System.IO.Compression; -using System.IO.Hashing; +using ObjectService.Services; namespace ObjectService.RouteHandlers.TableHandlers; public class ObjectRouteHandler : ITableRouteHandler { - public static string BaseRoute => RoutesV2.Objects; - public static Delegate ListDelegate => ListAsync; - public static Delegate CreateDelegate => CreateDatAsync; - public static Delegate ReadDelegate => ReadAsync; - public static Delegate UpdateDelegate => UpdateAsync; - public static Delegate DeleteDelegate => DeleteAsync; - - public static void MapRoutes(IEndpointRouteBuilder parentRoute) - => BaseTableRouteHandler.MapRoutes(parentRoute); - - public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) + public string BaseRoute => RoutesV2.Objects; + public Delegate ListDelegate => ListAsync; + public Delegate CreateDelegate => CreateDatAsync; + public Delegate ReadDelegate => ReadAsync; + public Delegate UpdateDelegate => UpdateAsync; + public Delegate DeleteDelegate => DeleteAsync; + + public void MapRoutes(IEndpointRouteBuilder parentRoute) { - BaseTableRouteHandler.MapRoutes(parentRoute); + var config = parentRoute.ServiceProvider.GetRequiredService(); + BaseTableRouteHandler.MapRoutes(this, parentRoute, config); + } + public void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) + { var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute); _ = resourceRoute.MapGet(RoutesV2.File, GetObjectFileAsync); _ = resourceRoute.MapGet(RoutesV2.Images, GetObjectImagesAsync); + _ = resourceRoute.MapGet(RoutesV2.FirstImage, GetObjectFirstImageAsync); } - //static async Task CreateAsync(DtoObjectDescriptor request, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp, [FromServices] ILogger logger) - //{ - // logger.LogInformation("[CreateAsync] Upload requested"); - - // var dtoDatObjectEntry = request.DatObjects.First(); - // if (string.IsNullOrEmpty(dtoDatObjectEntry.DatBytesAsBase64)) - // { - // return Results.BadRequest($"{nameof(dtoDatObjectEntry.DatBytesAsBase64)} cannot be null - it must contain the valid bytes of a loco dat object."); - // } - - // byte[]? datFileBytes; - // try - // { - // datFileBytes = Convert.FromBase64String(dtoDatObjectEntry.DatBytesAsBase64); - // } - // catch (FormatException ex) - // { - // return Results.BadRequest(ex.Message); - // } - - // if (datFileBytes == null || datFileBytes.Length == 0) - // { - // return Results.BadRequest($"Unable to decode {nameof(dtoDatObjectEntry.DatBytesAsBase64)} - it must contain the valid bytes of a loco dat object."); - // } - - // if (datFileBytes.Length > ServerLimits.MaximumUploadFileSize) - // { - // return Results.BadRequest("Unable to accept file sizes > 5MB"); - // } - - // var ssrLogger = sp.GetRequiredService().CreateLogger("SawyerStreamReader"); - // if (!SawyerStreamReader.TryGetHeadersFromBytes(datFileBytes, out var hdrs, ssrLogger)) - // { - // return Results.BadRequest("Provided data had invalid dat file headers"); - // } - - // if (hdrs.S5.IsVanilla()) - // { - // return Results.BadRequest("Nice try genius. Uploading vanilla objects is not allowed."); - // } - - // if (!hdrs.S5.IsValid() || !hdrs.Obj.IsValid()) - // { - // return Results.BadRequest("Invalid DAT file."); - // } - - // if (db.DoesObjectExist(hdrs.S5, out var existingObject)) - // { - // return Results.Accepted($"Object already exists in the database. DatName={hdrs.S5.Name} DatChecksum={hdrs.S5.Checksum} UploadedDate={existingObject!.UploadedDate}"); - // } - - // // at this stage, headers must be valid. we can add it to the object index/database, even if the remainder of the object is invalid - - // var sfm = sp.GetRequiredService(); - // var (DatFileInfo, LocoObject) = SawyerStreamReader.LoadFullObjectFromStream(datFileBytes, ssrLogger); - - // if (LocoObject == null) - // { - // return Results.BadRequest("Could not parse DAT object from request"); - - // // cannot proceed - // } - - // var uuid = Guid.NewGuid(); - // var saveFileName = Path.Combine(sfm.ObjectsCustomFolder, $"{uuid}.dat"); - // File.WriteAllBytes(saveFileName, datFileBytes); - - // logger.LogInformation("File accepted DatName={DatName} DatChecksum={DatChecksum} PathOnDisk={SaveFileName}", hdrs.S5.Name, hdrs.S5.Checksum, saveFileName); - - // var creationTime = request.CreatedDate; - - // VehicleType? vehicleType = null; - // if (LocoObject.Object is VehicleObject veh) - // { - // vehicleType = veh.Type; - // } - - // // make object header - // var tblObject = new TblObject() - // { - // Name = $"{hdrs.S5.Name}_{hdrs.S5.Checksum}", // same as DB seeder name. this is NOT unique - // Description = string.Empty, - // ObjectSource = ObjectSource.Custom, // not possible to upload vanilla objects - // ObjectType = hdrs.S5.ObjectType, - // VehicleType = vehicleType, - // Availability = request.Availability, - // CreatedDate = creationTime, - // ModifiedDate = null, - // UploadedDate = DateOnly.Now, - // Authors = [], - // Tags = [], - // ObjectPacks = [], - // DatObjects = [], - // StringTable = [], - // SubObjectId = 0, - // Licence = null, - // }; - - // // must save here to obtain the id for the new object, to link the dependent tables - // _ = await db.Objects.AddAsync(tblObject); - // _ = await db.SaveChangesAsync(); - - // // make string table - // foreach (var s in LocoObject.StringTable.Table) - // { - // foreach (var t in s.Value) - // { - // tblObject.StringTable.Add(new TblStringTableRow() - // { - // Name = s.Key, - // Language = t.Key, - // Text = t.Value, - // ObjectId = tblObject.Id, - // }); - // } - // } - - // // make dat objects - // var xxHash3 = XxHash3.HashToUInt64(datFileBytes); - // tblObject.DatObjects.Add(new TblDatObject() - // { - // ObjectId = tblObject.Id, - // DatName = DatFileInfo.S5Header.Name, - // DatChecksum = DatFileInfo.S5Header.Checksum, - // xxHash3 = xxHash3, - // Object = tblObject, - // }); - - // // make sub object - // _ = await DbSubObjectHelper.AddOrUpdate(db, tblObject, LocoObject.Object); - - // // save again - // _ = await db.SaveChangesAsync(); - - // // update server index - // sfm.ObjectIndex.AddEntry( - // new ObjectIndexEntry(hdrs.S5.Name, saveFileName, tblObject.Id, hdrs.S5.Checksum, xxHash3, tblObject.ObjectType, tblObject.ObjectSource, tblObject.CreatedDate, tblObject.UploadedDate, tblObject.VehicleType)); - - // _ = sfm.ObjectIndex.SaveIndexAsync(sfm.IndexFile); - // return Results.Created($"Successfully added {tblObject.Name} with unique id {tblObject.Id}", tblObject.Id); - //} - - static async Task CreateDatAsync(DtoObjectPost request, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp, [FromServices] ILogger logger) + async Task CreateDatAsync([FromBody] DtoObjectPost request, [FromServices] IObjectUploadService upload, CancellationToken ct) { - Microsoft.Extensions.Logging.LoggerExtensions.LogInformation(logger, "[CreateAsync] Upload requested"); - - if (string.IsNullOrEmpty(request.DatBytesAsBase64)) - { - return Results.Problem($"{nameof(request.DatBytesAsBase64)} cannot be null - it must contain the valid bytes of a loco dat object.", statusCode: StatusCodes.Status400BadRequest); - } - - byte[]? datFileBytes; - try - { - datFileBytes = Convert.FromBase64String(request.DatBytesAsBase64); - } - catch (FormatException ex) - { - return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); - } - - if (datFileBytes == null || datFileBytes.Length == 0) - { - return Results.Problem($"Unable to decode {nameof(request.DatBytesAsBase64)} - it must contain the valid bytes of a loco dat object.", statusCode: StatusCodes.Status400BadRequest); - } - - if (datFileBytes.Length > ServerLimits.MaximumUploadFileSize) - { - return Results.Problem($"Uploads are limited to {ServerLimits.MaximumUploadFileSize / (1024 * 1024)}MB.", statusCode: StatusCodes.Status413PayloadTooLarge); - } - - var ssrLogger = sp.GetRequiredService().CreateLogger("SawyerStreamReader"); - if (!SawyerStreamReader.TryGetHeadersFromBytes(datFileBytes, out var hdrs, ssrLogger)) - { - return Results.Problem("Provided data had invalid dat file headers", statusCode: StatusCodes.Status400BadRequest); - } - - if (hdrs.S5.IsVanilla()) - { - return Results.Problem("Uploading vanilla objects is not allowed.", statusCode: StatusCodes.Status400BadRequest); - } - - if (!hdrs.S5.IsValid() || !hdrs.Obj.IsValid()) - { - return Results.Problem("Invalid DAT file.", statusCode: StatusCodes.Status400BadRequest); - } - - // todo: check the nested DAT objects and update appropriately, even if this top object exists already - var objName = $"{hdrs.S5.Name}_{hdrs.S5.Checksum}"; - var existing = await db.Objects.FirstOrDefaultAsync(x => x.Name == objName); - - if (existing != null) - { - return Results.Accepted($"Object already exists in the database. DatName={hdrs.S5.Name} DatChecksum={hdrs.S5.Checksum} UploadedDate={existing!.UploadedDate}"); - } - - // Check if this object was previously marked as missing - var missingEntry = await db.ObjectsMissing - .FirstOrDefaultAsync(x => x.DatName == hdrs.S5.Name && x.DatChecksum == hdrs.S5.Checksum); - - if (missingEntry != null) - { - // we've found a missing object! delete it from the missing objects table since we're now adding the real object - // see #196 - _ = db.ObjectsMissing.Remove(missingEntry); - _ = await db.SaveChangesAsync(); - - logger.LogInformation("[CreateAsync] Missing object found! DatName={datName} DatChecksum={datChecksum} MissingId={missingId}", missingEntry.DatName, missingEntry.DatChecksum, missingEntry.Id); - } - - if (db.DoesObjectExist(hdrs.S5.Name, hdrs.S5.Checksum, out var existingObject)) - { - // todo: if we get here - the object doesn't exist but the dat object does - we should then link them - return Results.Accepted($"Object already exists in the database. DatName={hdrs.S5.Name} DatChecksum={hdrs.S5.Checksum} UploadedDate={existingObject!.UploadedDate}"); - } - - // at this stage, headers must be valid. we can add it to the object index/database, even if the remainder of the object is invalid - - var sfm = sp.GetRequiredService(); - var (DatFileInfo, LocoObject) = SawyerStreamReader.LoadFullObject(datFileBytes, ssrLogger); - - if (LocoObject == null) - { - return Results.Problem("Could not parse DAT object from request", statusCode: StatusCodes.Status400BadRequest); - - // cannot proceed - } - - var uuid = Guid.NewGuid(); - var saveFileName = Path.Combine(sfm.ObjectsCustomFolder, $"{uuid}.dat"); - File.WriteAllBytes(saveFileName, datFileBytes); - - logger.LogInformation("File accepted DatName={DatName} DatChecksum={DatChecksum} PathOnDisk={SaveFileName}", hdrs.S5.Name, hdrs.S5.Checksum, saveFileName); - - VehicleType? vehicleType = null; - if (LocoObject.Object is VehicleObject veh) - { - vehicleType = veh.Type; - } - - // make object header - var tblObject = new TblObject() - { - Name = objName, // same as DB seeder name. this is NOT unique - Description = string.Empty, - ObjectSource = ObjectSource.Custom, // not possible to upload vanilla objects - ObjectType = hdrs.S5.ObjectType.Convert(), - VehicleType = vehicleType, - Availability = request.InitialAvailability, - CreatedDate = request.CreatedDate, - ModifiedDate = request.ModifiedDate, - UploadedDate = DateOnly.UtcToday, - Authors = [], - Tags = [], - ObjectPacks = [], - DatObjects = [], - StringTable = [], - SubObjectId = 0, - Licence = null, - }; - - // must save here to obtain the id for the new object, to link the dependent tables - _ = await db.Objects.AddAsync(tblObject); - _ = await db.SaveChangesAsync(); - - // make string table - foreach (var s in LocoObject.StringTable.Table) - { - foreach (var t in s.Value) - { - tblObject.StringTable.Add(new TblStringTableRow() - { - Name = s.Key, - Language = t.Key, - Text = t.Value, - ObjectId = tblObject.Id, - }); - } - } - - // make dat objects - var xxHash3 = XxHash3.HashToUInt64(datFileBytes); - tblObject.DatObjects.Add(new TblDatObject() - { - ObjectId = tblObject.Id, - DatName = DatFileInfo.S5Header.Name, - DatChecksum = DatFileInfo.S5Header.Checksum, - xxHash3 = xxHash3, - Object = tblObject, - }); - - // make sub object - _ = await DbSubObjectHelper.AddOrUpdate(db, tblObject, LocoObject.Object); - - // save again - _ = await db.SaveChangesAsync(); - - // update server index - sfm.ObjectIndex.AddEntry( - new ObjectIndexEntry(hdrs.S5.Name, saveFileName, tblObject.Id, hdrs.S5.Checksum, xxHash3, tblObject.ObjectType, tblObject.ObjectSource, tblObject.CreatedDate, tblObject.UploadedDate, tblObject.VehicleType)); - - _ = sfm.ObjectIndex.SaveIndexAsync(sfm.IndexFile); - - var response = new ExpandedTbl(tblObject, [], [], []).ToDtoDescriptor(); - return Results.Created($"Successfully added {tblObject.Name} with unique id {tblObject.Id}", response); + var result = await upload.UploadDatAsync(request, ct); + return result.Success ? Results.Created($"{BaseRoute}/{result.Descriptor!.Id}", result.Descriptor) : Results.Problem(result.ErrorMessage, statusCode: result.StatusCode); } - static async Task ReadAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp, [FromServices] ILogger logger, CancellationToken cancellationToken) + async Task ReadAsync([FromRoute] UniqueObjectId id, [FromServices] IObjectQueryService query, [FromServices] ILogger logger, CancellationToken ct) { - logger.LogInformation("[ReadAsync] Read requested for object {ObjectId}", id); - - var eObj = await db.Objects - .Where(x => x.Id == id) - .Include(x => x.Licence) - .Include(x => x.DatObjects) - .Include(x => x.StringTable) - .Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)) - .SingleOrDefaultAsync(cancellationToken); - - //var subObject = DbSubObjectHelper.GetDbSubForType(db, eObj.Object.ObjectType, id); - var descriptor = eObj?.ToDtoDescriptor(/*subObject*/); - - var sfm = sp.GetRequiredService(); - return ReturnObject(descriptor, sfm, logger); + logger.LogInformation("[Read] Object {ObjectId}", id); + var d = await query.GetByIdAsync(id, ct); + return d != null ? Results.Ok(d) : Results.NotFound(); } - static async Task UpdateAsync([FromRoute] UniqueObjectId id, DtoObjectPostResponse request, [FromServices] LocoDbContext db, [FromServices] ILogger logger, CancellationToken cancellationToken) + async Task UpdateAsync([FromRoute] UniqueObjectId id, [FromBody] DtoObjectPostResponse request, [FromServices] IObjectQueryService query, [FromServices] ILogger logger, CancellationToken ct) { - logger.LogInformation("[UpdateAsync] Update requested for object {ObjectId}", id); - - var obj = await db.Objects - .Include(x => x.Licence) - .Include(x => x.Authors) - .Include(x => x.Tags) - .Include(x => x.ObjectPacks) - .Include(x => x.DatObjects) - .Include(x => x.StringTable) - .Where(x => x.Id == id) - .SingleOrDefaultAsync(cancellationToken); - - if (obj == null) - { - return Results.NotFound($"Object with id {id} not found"); - } - - // Update editable metadata fields - obj.Description = request.Description; - obj.CreatedDate = request.CreatedDate; - obj.ModifiedDate = request.ModifiedDate; - obj.Availability = request.Availability; - - // Update licence navigation property - if (request.Licence == null) - { - obj.Licence = null; - } - else - { - var licenceEntity = await db.Licences - .SingleOrDefaultAsync(l => l.Id == request.Licence.Id, cancellationToken); - - obj.Licence = licenceEntity; - } - - // Update authors collection - if (request.Authors == null || request.Authors.Count == 0) - { - obj.Authors.Clear(); - } - else - { - var authorIds = request.Authors - .Select(a => a.Id) - .ToList(); - - var authors = await db.Authors - .Where(a => authorIds.Contains(a.Id)) - .ToListAsync(cancellationToken); - - obj.Authors.Clear(); - foreach (var author in authors) - { - obj.Authors.Add(author); - } - } - - // Update tags collection - if (request.Tags == null || request.Tags.Count == 0) - { - obj.Tags.Clear(); - } - else - { - var tagIds = request.Tags - .Select(t => t.Id) - .ToList(); - - var tags = await db.Tags - .Where(t => tagIds.Contains(t.Id)) - .ToListAsync(cancellationToken); - - obj.Tags.Clear(); - foreach (var tag in tags) - { - obj.Tags.Add(tag); - } - } - - // Update object packs collection - if (request.ObjectPacks == null || request.ObjectPacks.Count == 0) - { - obj.ObjectPacks.Clear(); - } - else - { - var packIds = request.ObjectPacks - .Select(p => p.Id) - .ToList(); - - var packs = await db.ObjectPacks - .Where(p => packIds.Contains(p.Id)) - .ToListAsync(cancellationToken); - - obj.ObjectPacks.Clear(); - foreach (var pack in packs) - { - obj.ObjectPacks.Add(pack); - } - } - // Save changes - _ = await db.SaveChangesAsync(cancellationToken); - - logger.LogInformation("[UpdateAsync] Successfully updated object {ObjectId}", id); - - // Return updated object - var expandedObj = new ExpandedTbl(obj, obj.Authors, obj.Tags, obj.ObjectPacks); - var descriptor = expandedObj.ToDtoDescriptor(); - return Results.Ok(descriptor); + logger.LogInformation("[Update] Object {ObjectId}", id); + var r = await query.UpdateAsync(id, request, ct); + return r != null ? Results.Ok(r) : Results.NotFound(); } - static async Task DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] ILogger logger, CancellationToken cancellationToken) + async Task DeleteAsync([FromServices] ILogger logger, CancellationToken ct) { - logger.LogInformation("[DeleteAsync] Delete requested for object {ObjectId}", id); - // for now we could soft-delete by marking an object as Unavailable? - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented), cancellationToken); + logger.LogInformation("[Delete] Not implemented"); + return Results.Problem(statusCode: StatusCodes.Status501NotImplemented); } - static async Task ListAsync(HttpContext context, [FromServices] LocoDbContext db, [FromServices] ILogger logger, CancellationToken cancellationToken) + async Task ListAsync(HttpContext context, [FromServices] IObjectQueryService query, [FromServices] ILogger logger, CancellationToken ct) { - Microsoft.Extensions.Logging.LoggerExtensions.LogInformation(logger, "[ListAsync] List requested for object"); - - if (context.Request.Query.Count == 0) - { - return Results.Ok( - await db.Objects - .Include(x => x.DatObjects) - .Select(x => x.ToDtoEntry()) - .ToListAsync()); - } - else - { - logger.LogInformation("[ListAsync] Request had {ParamCount} query params {Params}", context.Request.Query.Count, context.Request.Query.ToString()); - - var query = db.Objects.AsQueryable(); - var filters = context.Request.Query; - - if (!filters.TryGetValue("objectType", out var objectTypeStr) || !Enum.TryParse(objectTypeStr, out ObjectType objectType)) - { - return Results.BadRequest(); - } - - query = query.Where(x => x.ObjectType == objectType); - - var locoPropertiesForObject = ObjectTypeMapping - .TypeToStruct(objectType) - .GetProperties(); - - var metadataPropertiesForObject = typeof(Definitions.SourceData.ObjectMetadata).GetProperties(); - - var allProperties = locoPropertiesForObject.Union(metadataPropertiesForObject); - - foreach (var filter in filters.Where(x => x.Key != "objectType" && !string.IsNullOrEmpty(x.Key) && !string.IsNullOrEmpty(x.Value))) - { - var key = filter.Key; - var value = filter.Value.FirstOrDefault(); - - if (!allProperties.Any(x => x.Name == key)) - { - continue; // Skip disallowed keys - } - - //query = query.Where("{key} == @0", value); - - //switch (key) - //{ - // case "name": - // query = query.Where("Name.Contains(@0)", value); - // break; - // case "age": - // if (int.TryParse(value, out var age)) - // { - // query = query.Where("Age == @0", age); - // } - // break; - // case "category": - // query = query.Where("Category == @0", value); - // break; - //} - } - - var results = await query.ToListAsync(cancellationToken); - return Results.Ok(results); - - // transform query into linq/db query - - // s5 header query params - // object-specific query params - // metadata query params - - //return Results.Problem(statusCode: StatusCodes.Status501NotImplemented); - } + logger.LogInformation("[List] Objects"); + return Results.Ok(await query.ListAsync(context, ct)); } - // eg: http://localhost:7229/v2/objects/{id}/images - static async Task GetObjectImagesAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] ServerFolderManager sfm, [FromServices] PaletteMap paletteMap, [FromServices] ILogger logger, CancellationToken cancellationToken) + async Task GetObjectImagesAsync([FromRoute] UniqueObjectId id, [FromServices] IObjectQueryService query, [FromServices] ILogger logger, CancellationToken ct) { - logger.LogInformation("[GetObjectImages] Get requested for object {ObjectId}", id); - - var obj = await db.Objects - .AsNoTracking() - .Include(x => x.DatObjects) - .SingleOrDefaultAsync(x => x.Id == id, cancellationToken); - - if (obj == null) + var descriptor = await query.GetByIdAsync(id, ct); + if (descriptor == null) { return Results.NotFound(); } - if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) + if (descriptor.Availability == ObjectAvailability.Unavailable || descriptor.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "Indexed object is a vanilla object."); + logger.LogWarning("Object {ObjectId} cannot expose images due to source/availability restrictions", id); return Results.Forbid(); } - if (obj.Availability == ObjectAvailability.Unavailable) - { - logger.LogWarning("Object [Id={Id} Name={Name}] is marked as Unavailable and cannot expose images", obj.Id, obj.Name); - return Results.Forbid(); - } - - var dat = obj.DatObjects.FirstOrDefault(); - if (dat == null) - { - logger.LogWarning("Object {ObjectId} has no DAT entries to load image data from", id); - return Results.NotFound(); - } - - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var index) || string.IsNullOrWhiteSpace(index?.FileName)) - { - logger.LogWarning("Object {ObjectId} DAT {DatName} was not found in the server object index", id, dat.DatName); - return Results.NotFound(); - } - - var pathOnDisk = Path.Combine(sfm.ObjectsFolder, index!.FileName!); - if (!File.Exists(pathOnDisk)) - { - logger.LogWarning("Indexed object file for {ObjectId} was not found on disk at {PathOnDisk}", id, pathOnDisk); - return Results.NotFound(); - } - - var dummyLogger = new Logger(); - var (_, locoObject) = SawyerStreamReader.LoadFullObject(pathOnDisk, dummyLogger, true); - if (locoObject == null) - { - logger.LogWarning("Object {ObjectId} could not be loaded from {PathOnDisk} to render images", id, pathOnDisk); - return Results.NotFound(); - } - - await using var memoryStream = new MemoryStream(); - using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) - { - var count = 0; - foreach (var g1 in locoObject.ImageTable?.GraphicsElements ?? []) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!paletteMap.TryConvertG1ToRgba32Bitmap(g1, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var image) || image == null) - { - continue; - } - - await using (var pngStream = new MemoryStream()) - { - await image.SaveAsPngAsync(pngStream); - pngStream.Position = 0; - - var zipEntry = zipArchive.CreateEntry(count++ + ".png", CompressionLevel.Optimal); - await using (var zipEntryStream = zipEntry.Open()) - { - await pngStream.CopyToAsync(zipEntryStream); - } - } - } - } - - memoryStream.Position = 0; // Reset stream position for reading - var bytes = memoryStream.ToArray(); - return Results.File(bytes, "application/zip", "images.zip"); + var zip = await query.GetImagesZipAsync(id, ct); + return zip != null ? Results.File(zip, "application/zip", $"{id}_images.zip") : Results.NotFound(); } - // eg: https://localhost:7230/objects/114 - static async Task GetObjectFileAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp, [FromServices] ILogger logger) + async Task GetObjectFirstImageAsync([FromRoute] UniqueObjectId id, [FromServices] IObjectQueryService query, [FromServices] ILogger logger, CancellationToken ct) { - logger.LogInformation("[GetObjectFile] Get requested for object {ObjectId}", id); - - var obj = await db.Objects - .Include(x => x.DatObjects) - .Where(x => x.Id == id) - .SingleOrDefaultAsync(); - - var sfm = sp.GetRequiredService(); - return ReturnFile(obj, sfm, logger); - } - - static IResult ReturnObject(DtoObjectPostResponse? dtoDescriptor, ServerFolderManager sfm, ILogger logger) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogDebug(logger, "[ReturnObject]"); - - if (dtoDescriptor == null) + var descriptor = await query.GetByIdAsync(id, ct); + if (descriptor == null) { return Results.NotFound(); } - if (dtoDescriptor.Availability == ObjectAvailability.Unavailable) + if (descriptor.Availability == ObjectAvailability.Unavailable || descriptor.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) { - logger.LogError("Object [Id={Id} Name={Name}] is marked as Unavailable and cannot be downloaded", dtoDescriptor.Id, dtoDescriptor); + logger.LogWarning("Object {ObjectId} cannot expose images due to source/availability restrictions", id); return Results.Forbid(); } - foreach (var dat in dtoDescriptor.DatObjects) - { - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null) - { - logger.LogWarning("Object {datFile} didn't exist in the object index", dat); - continue; - } - - if (entry.FileName == null) - { - logger.LogWarning("Object {datFile} has a null filename - suggest re-indexing the current folder", dat); - continue; - } - - var path = Path.Combine(sfm.ObjectsFolder, entry.FileName); - - if (!File.Exists(path)) - { - logger.LogWarning("Object {datFile} existed in the object index but not on disk. ExpectedPath=\"{path}\"", dat, path); - continue; - } - - if (dtoDescriptor.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "User attempted to download a vanilla object"); - dat.DatBytesAsBase64 = null; - } - else - { - dat.DatBytesAsBase64 = Convert.ToBase64String(File.ReadAllBytes(path)); - } - } - - return Results.Ok(dtoDescriptor); + var png = await query.GetFirstImagePngAsync(id, ct); + return png != null ? Results.File(png, "image/png") : Results.NotFound(); } - static IResult ReturnFile(TblObject? obj, ServerFolderManager sfm, ILogger logger) + async Task GetObjectFileAsync([FromRoute] UniqueObjectId id, [FromServices] IObjectQueryService query, CancellationToken ct) { - Microsoft.Extensions.Logging.LoggerExtensions.LogDebug(logger, "[ReturnFile]"); - - if (obj == null) + var descriptor = await query.GetByIdAsync(id, ct); + if (descriptor == null) { return Results.NotFound(); } - if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) + if (descriptor.Availability == ObjectAvailability.Unavailable || descriptor.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) { - Microsoft.Extensions.Logging.LoggerExtensions.LogDebug(logger, "User attempted to download a vanilla object"); return Results.Forbid(); } - var dat = obj.DatObjects.First(); - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null) - { - logger.LogDebug("Object {datFile} didn't exist in the object index", dat); - return Results.NotFound(); - } - - if (string.IsNullOrEmpty(entry.FileName)) - { - logger.LogWarning("Object {datFile} has a null filename - suggest re-indexing the current folder", dat); - return Results.NotFound(); - } - - var path = Path.Combine(sfm.ObjectsFolder, entry.FileName); - const string contentType = "application/octet-stream"; - - if (!File.Exists(path)) - { - logger.LogWarning("Object {datFile} existed in the object index but not on disk. ExpectedPath=\"{path}\"", dat, path); - } - - return Results.File(path, contentType, Path.GetFileName(path)); + var path = await query.GetFilePathAsync(id, ct); + return path != null && File.Exists(path) ? Results.File(path, "application/octet-stream", Path.GetFileName(path)) : Results.NotFound(); } } diff --git a/ObjectService/RouteHandlers/TableHandlers/RoleRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/RoleRouteHandler.cs deleted file mode 100644 index 2632ac08..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/RoleRouteHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Definitions.Database; -using Definitions.DTO.Identity; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public class RoleRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Roles; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.Roles; - - public static DtoRoleEntry ToDtoFunc(TblUserRole table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoRoleEntry request, TblUserRole row) - => row.Name = request.Name; - - public static TblUserRole ToRowFunc(DtoRoleEntry request) - => request.ToTable(); - - public static bool TryValidateCreate(DtoRoleEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - result = Results.Problem("Cannot add an empty or whitespace-only name.", statusCode: StatusCodes.Status400BadRequest); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/SC5FilePackRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/SC5FilePackRouteHandler.cs index 8e287bc0..56662288 100644 --- a/ObjectService/RouteHandlers/TableHandlers/SC5FilePackRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/SC5FilePackRouteHandler.cs @@ -1,105 +1,46 @@ -using Common; -using Definitions.Database; using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.SourceData; using Definitions.Web; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.IO.Compression; +using ObjectService.Services; +using System.Security.Claims; namespace ObjectService.RouteHandlers.TableHandlers; public class SC5FilePackRouteHandler : ITableRouteHandler { - public static string BaseRoute => RoutesV2.SC5FilePacks; - public static Delegate ListDelegate => ListAsync; - public static Delegate CreateDelegate => CreateAsync; - public static Delegate ReadDelegate => ReadAsync; - public static Delegate UpdateDelegate => UpdateAsync; - public static Delegate DeleteDelegate => DeleteAsync; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) - { - var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute); - _ = resourceRoute.MapGet(RoutesV2.File, GetSC5FilePackFileAsync); - } - - public static async Task ListAsync(HttpContext context, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.SC5FilePacks - .Include(l => l.Licence) - .ToListAsync()) - .Select(x => x.ToDtoEntry()) - .OrderBy(x => x.Name)); - - public static async Task CreateAsync([FromBody] DtoItemPackDescriptor request, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task ReadAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.SC5FilePacks - .Where(x => x.Id == id) - .Include(l => l.Licence) - .Select(x => new ExpandedTblPack(x, x.SC5Files, x.Authors, x.Tags)) - .ToListAsync()) - .Select(x => x.ToDtoDescriptor()) - .OrderBy(x => x.Name)); - - public static async Task UpdateAsync([FromRoute] UniqueObjectId id, [FromBody] DtoItemPackDescriptor request, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - public static async Task GetSC5FilePackFileAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp) - { - var pack = await db.SC5FilePacks - .Where(x => x.Id == id) - .Include(x => x.SC5Files) - .SingleOrDefaultAsync(); - - if (pack == null) - { - return Results.NotFound(); - } - - var sfm = sp.GetRequiredService(); - var tempZipPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.zip"); - var zipStream = new FileStream( - tempZipPath, - FileMode.Create, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 4096, - options: FileOptions.Asynchronous | FileOptions.DeleteOnClose); - - using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var sc5File in pack.SC5Files) - { - if (!RouteHelpers.TryGetSafeRelativePathUnderRoot(sfm.ScenariosFolder, sc5File.Name, out var fullFilePath, out var entryName)) - { - continue; - } - - if (!File.Exists(fullFilePath)) - { - continue; - } +public string BaseRoute => RoutesV2.SC5FilePacks; +public Delegate ListDelegate => ListAsync; +public Delegate CreateDelegate => CreateAsync; +public Delegate ReadDelegate => ReadAsync; +public Delegate UpdateDelegate => UpdateAsync; +public Delegate DeleteDelegate => DeleteAsync; +public void MapRoutes(IEndpointRouteBuilder e) => BaseTableRouteHandler.MapRoutes(this, e, e.ServiceProvider.GetRequiredService()); +public void MapAdditionalRoutes(IEndpointRouteBuilder p) => p.MapGroup(RoutesV2.ResourceRoute).MapGet(RoutesV2.File, GetPackFileAsync); + +async Task ListAsync([FromServices] ISC5FilePackService svc, CancellationToken ct) => Results.Ok(await svc.ListPacksAsync(ct)); +async Task ReadAsync(UniqueObjectId id, [FromServices] ISC5FilePackService svc, CancellationToken ct) => Results.Ok(await svc.GetPackAsync(id, ct)); +async Task GetPackFileAsync([FromRoute] UniqueObjectId id, [FromServices] ISC5FilePackService svc, CancellationToken ct) +{ +var (stream, name) = await svc.GetPackFileAsync(id, ct); +return stream != null ? Results.File(stream, "application/zip", name) : Results.NotFound(); +} - await using var fileStream = File.OpenRead(fullFilePath); - var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); - await using var entryStream = entry.Open(); - await fileStream.CopyToAsync(entryStream); - } - } +async Task CreateAsync( +[FromBody] DtoItemPackDescriptor request, +HttpContext httpContext, +[FromServices] ISC5FilePackService svc, +CancellationToken ct) +{ +var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; +if (string.IsNullOrEmpty(userIdClaim) || !ulong.TryParse(userIdClaim, out var userId)) +{ +return Results.Unauthorized(); +} - zipStream.Position = 0; - var downloadFileName = DownloadNameHelper.MakeSafeDownloadFileName(pack.Name, ".zip", "scenario-pack"); - return Results.File(zipStream, "application/zip", downloadFileName); - } +var created = await svc.CreatePackAsync(request, userId, ct); +return Results.Created($"{RoutesV2.Prefix}{BaseRoute}/{created.Id}", created); } + +Task UpdateAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); +Task DeleteAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); +} \ No newline at end of file diff --git a/ObjectService/RouteHandlers/TableHandlers/ScenarioRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ScenarioRouteHandler.cs index ac57a467..14ad0730 100644 --- a/ObjectService/RouteHandlers/TableHandlers/ScenarioRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/ScenarioRouteHandler.cs @@ -1,67 +1,38 @@ -using Definitions.DTO; using Definitions.Web; using Microsoft.AspNetCore.Mvc; +using ObjectService.Services; namespace ObjectService.RouteHandlers.TableHandlers; public class ScenarioRouteHandler : ITableRouteHandler { - public static string BaseRoute => RoutesV2.Scenarios; - public static Delegate ListDelegate => ListAsync; - public static Delegate CreateDelegate => CreateAsync; - public static Delegate ReadDelegate => ReadAsync; - public static Delegate UpdateDelegate => UpdateAsync; - public static Delegate DeleteDelegate => DeleteAsync; + public string BaseRoute => RoutesV2.Scenarios; + public Delegate ListDelegate => ListAsync; + public Delegate CreateDelegate => CreateAsync; + public Delegate ReadDelegate => ReadAsync; + public Delegate UpdateDelegate => UpdateAsync; + public Delegate DeleteDelegate => DeleteAsync; - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); + public void MapRoutes(IEndpointRouteBuilder endpoints) + => BaseTableRouteHandler.MapRoutes(this, endpoints, endpoints.ServiceProvider.GetRequiredService()); - public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) + public void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute) + => parentRoute.MapGroup(RoutesV2.ResourceRoute).MapGet(RoutesV2.File, GetScenarioFileAsync); + + Task ListAsync([FromServices] IScenarioService svc) { - var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute); - _ = resourceRoute.MapGet(RoutesV2.File, GetScenarioFileAsync); + var items = svc.ListScenarios(); + return Task.FromResult(Results.Ok(items.ToList())); } - static string[] GetSortedScenarioFiles(string scenarioFolder) - => [.. Directory - .GetFiles(scenarioFolder, "*.SC5", SearchOption.AllDirectories) - .OrderBy(x => Path.GetRelativePath(scenarioFolder, x), StringComparer.Ordinal)]; - - static async Task ListAsync([FromServices] IServiceProvider sp) - => await Task.Run(() => - { - var sfm = sp.GetRequiredService(); - var scenarioFolder = sfm.ScenariosFolder; - var files = GetSortedScenarioFiles(scenarioFolder); - var count = 0UL; - var filenames = files.Select(x => new DtoScenarioEntry(count++, Path.GetRelativePath(scenarioFolder, x))); - return Results.Ok(filenames.ToList()); - }); - - static Task GetScenarioFileAsync([FromRoute] UniqueObjectId id, [FromServices] IServiceProvider sp) + Task GetScenarioFileAsync([FromRoute] UniqueObjectId id, [FromServices] IScenarioService svc) { - var sfm = sp.GetRequiredService(); - var files = GetSortedScenarioFiles(sfm.ScenariosFolder); - - if (id >= (ulong)files.Length) - { - return Task.FromResult(Results.NotFound()); - } - - var path = files[(int)id]; - const string contentType = "application/octet-stream"; - return Task.FromResult(Results.File(path, contentType, Path.GetFileName(path))); + var path = svc.GetScenarioFilePath(id); + return Task.FromResult(path != null ? Results.File(path, "application/octet-stream", Path.GetFileName(path)) : Results.NotFound()); } - static async Task CreateAsync(DtoScenarioEntry request) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - static async Task ReadAsync([FromRoute] UniqueObjectId Id) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - static async Task UpdateAsync([FromRoute] UniqueObjectId Id, DtoScenarioEntry request) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - - static async Task DeleteAsync([FromRoute] UniqueObjectId Id) - => await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); + Task CreateAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); + Task ReadAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); + Task UpdateAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); + Task DeleteAsync() => Task.FromResult(Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); } diff --git a/ObjectService/RouteHandlers/TableHandlers/TableRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/TableRouteHandler.cs new file mode 100644 index 00000000..1f70191d --- /dev/null +++ b/ObjectService/RouteHandlers/TableHandlers/TableRouteHandler.cs @@ -0,0 +1,13 @@ +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Identity; +using Definitions.Web; + +namespace ObjectService.RouteHandlers.TableHandlers; + +public class AuthorRouteHandler : CrudRouteHandler { public AuthorRouteHandler() : base(RoutesV2.Authors) { } } +public class TagRouteHandler : CrudRouteHandler { public TagRouteHandler() : base(RoutesV2.Tags) { } } +public class LicenceRouteHandler : CrudRouteHandler { public LicenceRouteHandler() : base(RoutesV2.Licences) { } } +public class UserRouteHandler : CrudRouteHandler { public UserRouteHandler() : base(RoutesV2.Users) { } } +public class RoleRouteHandler : CrudRouteHandler { public RoleRouteHandler() : base(RoutesV2.Roles) { } } +public class ObjectMissingRouteHandler : CrudRouteHandler { public ObjectMissingRouteHandler() : base(RoutesV2.Objects + RoutesV2.Missing) { } } diff --git a/ObjectService/RouteHandlers/TableHandlers/TagRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/TagRouteHandler.cs deleted file mode 100644 index 5e6ba337..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/TagRouteHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Definitions.Database; -using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public class TagRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Tags; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.Tags; - - public static DtoTagEntry ToDtoFunc(TblTag table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoTagEntry request, TblTag row) - => row.Name = request.Name; - - public static TblTag ToRowFunc(DtoTagEntry request) - => request.ToTable(); - - public static bool TryValidateCreate([FromBody] DtoTagEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.Name)) - { - result = Results.Problem("Cannot add an empty or whitespace-only name.", statusCode: StatusCodes.Status400BadRequest); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/UserRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/UserRouteHandler.cs deleted file mode 100644 index 42ad0a49..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/UserRouteHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Definitions.Database; -using Definitions.DTO.Identity; -using Definitions.DTO.Mappers; -using Definitions.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public class UserRouteHandler - : BaseDataTableRouteHandler - , ITableRouteConfig -{ - public static string GetBaseRoute() - => RoutesV2.Users; - - public static void MapRoutes(IEndpointRouteBuilder endpoints) - => BaseTableRouteHandler.MapRoutes(endpoints); - - public static DbSet GetTable(LocoDbContext db) - => db.Users; - - public static DtoUserEntry ToDtoFunc(TblUser table) - => table.ToDtoEntry(); - - public static void UpdateFunc(DtoUserEntry request, TblUser row) - => row.UserName = request.UserName; - - public static TblUser ToRowFunc(DtoUserEntry request) - => request.ToTable(); - - public static bool TryValidateCreate([FromBody] DtoUserEntry request, [FromServices] LocoDbContext db, out IResult? result) - { - if (string.IsNullOrWhiteSpace(request.UserName)) - { - result = Results.Problem("Cannot add an empty or whitespace-only name.", statusCode: StatusCodes.Status400BadRequest); - return false; - } - - result = null; - return true; - } -} diff --git a/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs deleted file mode 100644 index 1e9e4495..00000000 --- a/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs +++ /dev/null @@ -1,661 +0,0 @@ -using Common; -using Common.Logging; -using Dat.Converters; -using Dat.FileParsing; -using Definitions; -using Definitions.Database; -using Definitions.DTO; -using Definitions.DTO.Mappers; -using Definitions.ObjectModels.Graphics; -using Definitions.ObjectModels.Objects.Vehicle; -using Definitions.ObjectModels.Types; -using Definitions.SourceData; -using Definitions.Web; -using Index; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SixLabors.ImageSharp; -using System.IO.Compression; -using static ObjectService.RouteHandlers.TableHandlers.V1DtoExtensions; - -namespace ObjectService.RouteHandlers.TableHandlers; - -public static class V1DtoExtensions -{ - public record V1DtoObjectDescriptor( - UniqueObjectId Id, - string DatName, - uint DatChecksum, - ObjectSource ObjectSource, - ObjectType ObjectType, - VehicleType? VehicleType, - string InternalName, - string? Description, - DateOnly? CreationDate, - DateOnly? LastEditDate, - DateOnly UploadDate); - - public static V1DtoObjectDescriptor ToDtoEntryLegacy(this TblObject table) - => new( - table.Id, - table.DatObjects.FirstOrDefault()?.DatName ?? "<--->", - table.DatObjects.FirstOrDefault()?.DatChecksum ?? 0, - table.ObjectSource, - table.ObjectType, - table.VehicleType, - table.Name, - table.Description, - table.CreatedDate, - table.ModifiedDate, - table.UploadedDate); - - public record V1DtoObjectDescriptorWithMetadata( - UniqueObjectId Id, - string UniqueName, - string DatName, - uint DatChecksum, - string? DatBytes, // base64-encoded - ObjectSource ObjectSource, - ObjectType ObjectType, - VehicleType? VehicleType, - string? Description, - ICollection Authors, - DateOnly? CreationDate, - DateOnly? LastEditDate, - DateOnly UploadDate, - ICollection Tags, - ICollection ObjectPacks, - TblLicence? Licence); -} - -public class LegacyRouteHandler() -{ - public static void MapRoutes(IEndpointRouteBuilder parentRoute) - { - // GET - _ = parentRoute.MapGet(RoutesV1.ListObjects, ListObjects); - _ = parentRoute.MapGet(RoutesV1.GetDat, GetDat); - _ = parentRoute.MapGet(RoutesV1.GetDatFile, GetDatFile); - _ = parentRoute.MapGet(RoutesV1.GetObject, GetObject); - _ = parentRoute.MapGet(RoutesV1.GetObjectFile, GetObjectFile); - _ = parentRoute.MapGet(RoutesV1.GetObjectImages, GetObjectImages); - _ = parentRoute.MapGet(RoutesV1.ListObjectPacks, ListObjectPacks); - _ = parentRoute.MapGet(RoutesV1.GetObjectPack, GetObjectPack); - _ = parentRoute.MapGet(RoutesV1.ListScenarios, ListScenarios); - _ = parentRoute.MapGet(RoutesV1.GetScenario, GetScenario); - _ = parentRoute.MapGet(RoutesV1.ListSC5FilePacks, ListSC5FilePacks); - _ = parentRoute.MapGet(RoutesV1.GetSC5FilePack, GetSC5FilePack); - _ = parentRoute.MapGet(RoutesV1.ListAuthors, ListAuthors); - _ = parentRoute.MapGet(RoutesV1.ListLicences, ListLicences); - _ = parentRoute.MapGet(RoutesV1.ListTags, ListTags); - - // POST - _ = parentRoute.MapPost(RoutesV1.UploadDat, UploadDat); - _ = parentRoute.MapPost(RoutesV1.UploadObject, UploadObject); - - // PATCH - _ = parentRoute.MapPatch(RoutesV1.UpdateDat, UpdateDat); - _ = parentRoute.MapPatch(RoutesV1.UpdateObject, UpdateObject); - } - - #region GET - - // eg: https://localhost:7230/v1/objects/list - public static async Task ListObjects( - [FromQuery] string? datName, - [FromQuery] uint? datChecksum, - [FromQuery] string? description, - [FromQuery] ObjectType? objectType, - [FromQuery] VehicleType? vehicleType, - [FromQuery] string? authorName, - [FromQuery] string? tagName, - [FromQuery] ObjectSource? objectSource, - LocoDbContext db, - [FromServices] ILogger logger) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogInformation(logger, "[ListObjects]"); - - // Cap free-text query parameters to a reasonable length. Without this an - // attacker can issue arbitrarily long `LIKE %…%` scans against SQLite. - const int maxQueryStringLength = 256; - var validationErrors = new Dictionary(); - if (datName?.Length > maxQueryStringLength) - { - validationErrors[nameof(datName)] = [$"Must be {maxQueryStringLength} characters or fewer."]; - } - if (description?.Length > maxQueryStringLength) - { - validationErrors[nameof(description)] = [$"Must be {maxQueryStringLength} characters or fewer."]; - } - if (authorName?.Length > maxQueryStringLength) - { - validationErrors[nameof(authorName)] = [$"Must be {maxQueryStringLength} characters or fewer."]; - } - if (tagName?.Length > maxQueryStringLength) - { - validationErrors[nameof(tagName)] = [$"Must be {maxQueryStringLength} characters or fewer."]; - } - if (validationErrors.Count > 0) - { - return Results.ValidationProblem(validationErrors); - } - - var query = db.Objects - .Include(x => x.DatObjects) - .AsQueryable(); - - #region Query Construction - - if (!string.IsNullOrEmpty(datName)) - { - query = query.Where(x => x.DatObjects.Any(y => y.DatName.Contains(datName))); - } - - if (datChecksum is not null and not 0) - { - query = query.Where(x => x.DatObjects.Any(y => y.DatChecksum == datChecksum)); - } - - if (!string.IsNullOrEmpty(description)) - { - query = query.Where(x => x.Description != null && x.Description.Contains(description)); - } - - if (objectType != null) - { - query = query.Where(x => x.ObjectType == objectType); - } - - if (objectType == ObjectType.Vehicle && vehicleType != null) - { - // can only query vehicle type if it's a vehicle. if ObjectType is unspecified, that is fine - if (objectType is not null and not ObjectType.Vehicle) - { - return Results.Problem("Cannot query for a Vehicle type on non-Vehicle objects", statusCode: StatusCodes.Status400BadRequest); - } - - query = query.Where(x => x.VehicleType == vehicleType); - } - - if (!string.IsNullOrEmpty(authorName)) - { - query = query.Where(x => x.Authors.Select(a => a.Name).Contains(authorName)); - } - - if (!string.IsNullOrEmpty(tagName)) - { - query = query.Where(x => x.Tags.Select(t => t.Name).Contains(tagName)); - } - - if (objectSource != null) - { - query = query.Where(x => x.ObjectSource == objectSource); - } - - #endregion - - try - { - var result = await query - .Select(x => x.ToDtoEntryLegacy()) - .ToListAsync(); - - return Results.Ok(result); - } - catch (Exception ex) when (ex is DbUpdateException or InvalidOperationException or IOException) - { - logger.LogError(ex, "Database error listing objects"); - return Results.Problem("An error occurred while retrieving objects."); - } - } - - // eg: https://localhost:7230/v1/objects/getdat?objectName=114&checksum=123$returnObjBytes=false - public static async Task GetDat([FromQuery] string datName, [FromQuery] uint datChecksum, [FromQuery] bool? returnObjBytes, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - logger.LogInformation("[GetDat] Object [({ObjectName}, {Checksum})] requested", datName, datChecksum); - - var eObj = await db.Objects - .Include(x => x.DatObjects) - .Include(x => x.Licence) - .Where(x => x.DatObjects.First().DatName == datName && x.DatObjects.First().DatChecksum == datChecksum && x.Availability == ObjectAvailability.Available) - .Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)) - .SingleOrDefaultAsync(); - - var sfm = sp.GetRequiredService(); - return await ReturnObject(returnObjBytes, logger, eObj, sfm); - } - - // eg: http://localhost:7229/v1/objects/getobjectimages?uniqueObjectId=1 - public static async Task GetObjectImages(UniqueObjectId uniqueObjectId, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - logger.LogInformation("[GetObjectImages] Object [{ObjectId}] requested with images", uniqueObjectId); - - var obj = await db.Objects - .Include(x => x.DatObjects) - .Where(x => x.Id == uniqueObjectId && x.Availability == ObjectAvailability.Available) - .SingleOrDefaultAsync(); - - if (obj == null) - { - return Results.NotFound(); - } - - var sfm = sp.GetRequiredService(); - - var dat = obj.DatObjects.First(); - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var index)) - { - return Results.NotFound(); - } - - if (index?.FileName == null) - { - return Results.NotFound(); - } - - var pathOnDisk = Path.Combine(sfm.ObjectsFolder, index.FileName); // handle windows paths by replacing path separator - logger.LogInformation("Loading file from {PathOnDisk}", pathOnDisk); - - var fileExists = File.Exists(pathOnDisk); - if (!fileExists) - { - logger.LogWarning("Indexed object had {PathOnDisk} but the file wasn't found there; suggest re-indexing the server object folder.", pathOnDisk); - return Results.NotFound(); - } - - if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "Indexed object is a vanilla object."); - return Results.Forbid(); - } - - var dummyLogger = new Logger(); // todo: make both libraries and server use a single logging interface - var locoObj = SawyerStreamReader.LoadFullObject(pathOnDisk, dummyLogger, true); - var pm = sp.GetRequiredService(); - - await using var memoryStream = new MemoryStream(); - using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) - { - var count = 0; - foreach (var g1 in locoObj!.LocoObject!.ImageTable?.GraphicsElements ?? []) - { - if (!pm.TryConvertG1ToRgba32Bitmap(g1, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var image)) - { - continue; - } - - await using (var pngStream = new MemoryStream()) - { - await image.SaveAsPngAsync(pngStream); - pngStream.Position = 0; - - var zipEntry = zipArchive.CreateEntry(count++ + ".png", CompressionLevel.Optimal); - await using (var zipEntryStream = zipEntry.Open()) - { - await pngStream.CopyToAsync(zipEntryStream); - } - } - } - } - - memoryStream.Position = 0; // Reset stream position for reading - var bytes = memoryStream.ToArray(); - return Results.File(bytes, "application/zip", "images.zip"); - } - - // eg: https://localhost:7230/v1/objects/getobject?uniqueObjectId=246263256&returnObjBytes=false - public static async Task GetObject([FromQuery] int uniqueObjectId, [FromQuery] bool? returnObjBytes, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - logger.LogInformation("[GetObject] Object [{ObjectId}] requested", uniqueObjectId); - - var eObj = await db.Objects - .Where(x => (int)x.Id == uniqueObjectId && x.Availability == ObjectAvailability.Available) - .Include(x => x.Licence) - .Include(x => x.DatObjects) - .Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)) - .SingleOrDefaultAsync(); - - var sfm = sp.GetRequiredService(); - return await ReturnObject(returnObjBytes, logger, eObj, sfm); - } - - static async Task ReturnObject(bool? returnObjBytes, ILogger logger, ExpandedTbl? eObj, ServerFolderManager sfm) - { - if (eObj == null || eObj.Object == null) - { - return Results.NotFound(); - } - - var dat = eObj.Object.DatObjects.First(); - if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var index)) - { - return Results.NotFound(); - } - - var obj = eObj!.Object; - - if (index?.FileName == null) - { - return Results.NotFound(); - } - - var pathOnDisk = Path.Combine(sfm.ObjectsFolder, index.FileName); // handle windows paths by replacing path separator - logger.LogInformation("Loading file from {PathOnDisk}", pathOnDisk); - - var fileExists = File.Exists(pathOnDisk); - if (!fileExists) - { - logger.LogWarning("Indexed object had {PathOnDisk} but the file wasn't found there; suggest re-indexing the server object folder.", pathOnDisk); - } - - var bytes = (returnObjBytes ?? false) && obj.ObjectSource is ObjectSource.Custom or ObjectSource.OpenLoco && fileExists - ? Convert.ToBase64String(await File.ReadAllBytesAsync(pathOnDisk)) - : null; - - var result = new V1DtoObjectDescriptorWithMetadata( - obj.Id, - obj.Name, - obj.DatObjects.FirstOrDefault()?.DatName ?? "<--->", - obj.DatObjects.FirstOrDefault()?.DatChecksum ?? 0, - bytes, - obj.ObjectSource, - obj.ObjectType, - obj.VehicleType, - obj.Description, - eObj.Authors, - obj.CreatedDate, - obj.ModifiedDate, - obj.UploadedDate, - eObj.Tags, - eObj.Packs, - obj.Licence); - - return Results.Ok(result); - } - - // eg: https://localhost:7230/v1/objects/originaldatfile?objectName=114&checksum=123 - public static async Task GetDatFile([FromQuery] string datName, [FromQuery] uint datChecksum, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - logger.LogInformation("[GetDatFile] DatName={DatName} DatChecksum={DatChecksum}", datName, datChecksum); - - var obj = await db.DatObjects - .Include(x => x.Object) - .Where(x => x.DatName == datName && x.DatChecksum == datChecksum && x.Object.Availability == ObjectAvailability.Available) - .SingleOrDefaultAsync(); - - if (obj == null) - { - return Results.NotFound(); - } - - var sfm = sp.GetRequiredService(); - return ReturnFile(obj.Object, (obj.DatName, obj.DatChecksum), obj.xxHash3, sfm); - } - - // eg: https://localhost:7230/v1/objects/getobjectfile?objectName=114&checksum=123 - public static async Task GetObjectFile([FromQuery] int uniqueObjectId, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - logger.LogInformation("[GetObjectFile] Object [{ObjectId}] requested", uniqueObjectId); - - var obj = await db.DatObjects - .Include(x => x.Object) - .Where(x => (int)x.Object.Id == uniqueObjectId && x.Object.Availability == ObjectAvailability.Available) - .FirstOrDefaultAsync(); // may be more than one dat file associated with this object, so just get the first for now - - if (obj == null) - { - return Results.NotFound(); - } - - var sfm = sp.GetRequiredService(); - return ReturnFile(obj.Object, (obj.DatName, obj.DatChecksum), obj.xxHash3, sfm); - } - - static IResult ReturnFile(TblObject? obj, (string objectName, uint checksum)? datDetails, ulong? xxHash3, ServerFolderManager sfm) - { - if (obj == null) - { - return Results.NotFound(); - } - - if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) - { - return Results.Forbid(); - } - - ObjectIndexEntry? entry = null; - if (datDetails != null && !sfm.ObjectIndex.TryFind(datDetails.Value, out entry)) - { - return Results.NotFound(); - } - else if (xxHash3 != null && !sfm.ObjectIndex.TryFind(xxHash3.Value, out entry)) - { - return Results.NotFound(); - } - - const string contentType = "application/octet-stream"; - - if (entry?.FileName == null) - { - return Results.NotFound(); - } - - var path = Path.Combine(sfm.ObjectsFolder, entry!.FileName); - return obj != null && File.Exists(path) - ? Results.File(path, contentType, Path.GetFileName(path)) - : Results.NotFound(); - } - - // eg: https://localhost:7230/v1/scenarios/list - public static async Task ListScenarios([FromServices] IServiceProvider sp) - => await Task.Run(() => - { - var sfm = sp.GetRequiredService(); - var files = Directory.GetFiles(sfm.ScenariosFolder, "*.SC5", SearchOption.AllDirectories); - var count = 0UL; - var filenames = files.Select(x => new DtoScenarioEntry(count++, Path.GetRelativePath(sfm.ScenariosFolder, x))); - return Results.Ok(filenames.ToList()); - }); - - // eg: https://localhost:7230/v1/scenarios/getscenario?uniqueScenarioId=246263256&returnObjBytes=false - public static async Task GetScenario([FromQuery] int uniqueScenarioId, [FromQuery] bool? returnObjBytes, [FromServices] LocoDbContext db, [FromServices] ILogger logger) - { - logger.LogInformation("[GetScenario] Scenario [{ScenarioId}] requested", uniqueScenarioId); - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - } - - #endregion - - // eg: https://localhost:7230/v1/authors/list - public static async Task ListAuthors(LocoDbContext db) - => Results.Ok(await db.Authors - .Select(x => new DtoAuthorEntry(x.Id, x.Name)) - .ToListAsync()); - - // eg: https://localhost:7230/v1/licences/list - public static async Task ListLicences(LocoDbContext db) - => Results.Ok(await db.Licences - .Select(x => new DtoLicenceEntry(x.Id, x.Name, x.Text)) - .ToListAsync()); - - // eg: https://localhost:7230/v1/tags/list - public static async Task ListTags(LocoDbContext db) - => Results.Ok(await db.Tags - .Select(x => new DtoTagEntry(x.Id, x.Name)) - .ToListAsync()); - - // eg: https://localhost:7230/v1/objectpacks/list - public static async Task ListObjectPacks(LocoDbContext db) - => Results.Ok( - (await db.ObjectPacks - .Include(l => l.Licence) - .ToListAsync()) - .Select(x => x.ToDtoEntry()) - .OrderBy(x => x.Name)); - - // eg: https://localhost:7230/v1/objectpacks/getpack?uniqueId=123 - public static async Task GetObjectPack([FromQuery] int uniqueId, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.ObjectPacks - .Where(x => (int)x.Id == uniqueId) - .Include(l => l.Licence) - .Select(x => new ExpandedTblPack(x, x.Objects, x.Authors, x.Tags)) - .ToListAsync()) - .Select(x => x.ToDtoDescriptor()) - .OrderBy(x => x.Name)); - - // eg: https://localhost:7230/v1/sc5filepacks/list - public static async Task ListSC5FilePacks(LocoDbContext db) - => Results.Ok( - (await db.SC5FilePacks - .Include(l => l.Licence) - .ToListAsync()) - .Select(x => x.ToDtoEntry()) - .OrderBy(x => x.Name)); - - // eg: https://localhost:7230/v1/sc5filepacks/getpack?uniqueId=123 - public static async Task GetSC5FilePack([FromQuery] int uniqueId, [FromServices] LocoDbContext db) - => Results.Ok( - (await db.SC5FilePacks - .Where(x => (int)x.Id == uniqueId) - .Include(l => l.Licence) - .Select(x => new ExpandedTblPack(x, x.SC5Files, x.Authors, x.Tags)) - .ToListAsync()) - .Select(x => x.ToDtoDescriptor()) - .OrderBy(x => x.Name)); - - #region POST - - // eg: https://localhost:7230/v1/uploaddat/... - public static async Task UploadDat(DtoObjectPost request, [FromServices] LocoDbContext db, [FromServices] ILogger logger, [FromServices] IServiceProvider sp) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogInformation(logger, "[UploadDat] Upload requested"); - - if (string.IsNullOrEmpty(request.DatBytesAsBase64)) - { - return Results.Problem($"{nameof(request.DatBytesAsBase64)} cannot be null - it must contain the valid bytes of a loco dat object.", statusCode: StatusCodes.Status400BadRequest); - } - - byte[]? datFileBytes; - try - { - datFileBytes = Convert.FromBase64String(request.DatBytesAsBase64); - } - catch (FormatException ex) - { - return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); - } - - if (datFileBytes == null || datFileBytes.Length == 0) - { - return Results.Problem($"Unable to decode {nameof(request.DatBytesAsBase64)} - it must contain the valid bytes of a loco dat object.", statusCode: StatusCodes.Status400BadRequest); - } - - if (datFileBytes.Length > ServerLimits.MaximumUploadFileSize) - { - return Results.Problem($"Uploads are limited to {ServerLimits.MaximumUploadFileSize / (1024 * 1024)}MB.", statusCode: StatusCodes.Status413PayloadTooLarge); - } - - var ssrLogger = new Logger(); - if (!SawyerStreamReader.TryGetHeadersFromBytes(datFileBytes, out var hdrs, ssrLogger)) - { - return Results.Problem("Provided data had invalid dat file headers", statusCode: StatusCodes.Status400BadRequest); - } - - if (hdrs.S5.IsVanilla()) - { - return Results.Problem("Uploading vanilla objects is not allowed.", statusCode: StatusCodes.Status400BadRequest); - } - - if (!hdrs.S5.IsValid() || !hdrs.Obj.IsValid()) - { - return Results.Problem("Invalid DAT file.", statusCode: StatusCodes.Status400BadRequest); - } - - if (db.DoesObjectExist(hdrs.S5.Name, hdrs.S5.Checksum, out var existingObject)) - { - return Results.Accepted($"Object already exists in the database. DatName={hdrs.S5.Name} DatChecksum={hdrs.S5.Checksum} UploadedDate={existingObject!.UploadedDate}"); - } - - // at this stage, headers must be valid. we can add it to the object index/database, even if the remainder of the object is invalid - - var sfm = sp.GetRequiredService(); - var locoLogger = new Logger(); - - var (_, LocoObject) = SawyerStreamReader.LoadFullObject(datFileBytes, locoLogger); - var uuid = Guid.NewGuid(); - var saveFileName = Path.Combine(sfm.ObjectsCustomFolder, $"{uuid}.dat"); - File.WriteAllBytes(saveFileName, datFileBytes); - - logger.LogInformation("File accepted DatName={DatName} DatChecksum={DatChecksum} PathOnDisk={SaveFileName}", hdrs.S5.Name, hdrs.S5.Checksum, saveFileName); - - var createdDate = request.CreatedDate; - var modifiedDate = request.ModifiedDate; - - VehicleType? vehicleType = null; - if (LocoObject?.Object is VehicleObject veh) - { - vehicleType = veh.Type; - } - - var locoTbl = new TblObject() - { - Name = uuid.ToString(), - Description = $"{hdrs.S5.Name}_{hdrs.S5.Checksum}", - ObjectSource = ObjectSource.Custom, // not possible to upload vanilla objects - ObjectType = hdrs.S5.ObjectType.Convert(), - VehicleType = vehicleType, - Availability = request.InitialAvailability, - CreatedDate = createdDate, - ModifiedDate = modifiedDate, - UploadedDate = DateOnly.UtcToday, - Authors = [], - Tags = [], - ObjectPacks = [], - Licence = null, - }; - var addedObj = db.Objects.Add(locoTbl); - - var locoLookupTbl = new TblDatObject() - { - DatName = hdrs.S5.Name, - DatChecksum = hdrs.S5.Checksum, - xxHash3 = request.xxHash3, - ObjectId = addedObj.Entity.Id, - Object = locoTbl, - }; - locoTbl.DatObjects.Add(locoLookupTbl); - - _ = db.DatObjects.Add(locoLookupTbl); - _ = await db.SaveChangesAsync(); - - sfm.ObjectIndex.AddEntry( - new ObjectIndexEntry(hdrs.S5.Name, saveFileName, locoTbl.Id, hdrs.S5.Checksum, request.xxHash3, locoTbl.ObjectType, locoTbl.ObjectSource, createdDate, modifiedDate, locoTbl.VehicleType)); - - return Results.Created($"Successfully added {locoTbl.Name} with unique id {locoTbl.Id}", locoTbl.Id); - } - - public static async Task UploadObject([FromServices] ILogger logger) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "[UploadDat] - not implemented"); - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - } - - #endregion - - #region PATCH - - public static async Task UpdateDat([FromServices] ILogger logger) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "[UploadDat] - not implemented"); - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - } - - public static async Task UpdateObject([FromServices] ILogger logger) - { - Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, "[UploadDat] - not implemented"); - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); - } - - #endregion -} diff --git a/ObjectService/RouteHandlers/V1RouteBuilderExtensions.cs b/ObjectService/RouteHandlers/V1RouteBuilderExtensions.cs index f2513a41..b56c736b 100644 --- a/ObjectService/RouteHandlers/V1RouteBuilderExtensions.cs +++ b/ObjectService/RouteHandlers/V1RouteBuilderExtensions.cs @@ -3,53 +3,54 @@ namespace ObjectService.RouteHandlers; -public static class V1RouteBuilderExtensions +public static class RouteBuilderExtensions { - public static IEndpointConventionBuilder MapV1Routes(this IEndpointRouteBuilder endpoints) - { - var routeGroup = endpoints.MapGroup(RoutesV1.Prefix); - ArgumentNullException.ThrowIfNull(routeGroup); - - LegacyRouteHandler.MapRoutes(routeGroup); - return routeGroup; - } -} - -public static class V2RouteBuilderExtensions -{ - public static IEndpointConventionBuilder MapV2Routes(this IEndpointRouteBuilder endpoints) + public static IEndpointConventionBuilder MapApiRoutes(this IEndpointRouteBuilder endpoints) { var v2 = endpoints.MapGroup(RoutesV2.Prefix); - _ = v2.MapServerRoutes(); - _ = v2.MapAdminRoutes().RequireAuthorization(); + var config = endpoints.ServiceProvider.GetRequiredService(); + + // Public read-only routes (guest + authenticated can read) + var publicGroup = v2.MapGroup(string.Empty); + MapHandler(new AuthorRouteHandler(), publicGroup, config); + MapHandler(new TagRouteHandler(), publicGroup, config); + MapHandler(new LicenceRouteHandler(), publicGroup, config); + MapHandler(new ObjectRouteHandler(), publicGroup, config); + MapHandler(new ObjectMissingRouteHandler(), publicGroup, config); + MapHandler(new ScenarioRouteHandler(), publicGroup, config); + MapHandler(new SC5FilePackRouteHandler(), publicGroup, config); + MapHandler(new ObjectPackRouteHandler(), publicGroup, config); + + // Authenticated write routes for general data (any authenticated user) + var authGroup = v2.MapGroup(string.Empty).RequireAuthorization(); + MapWriteHandler(new ObjectMissingRouteHandler(), authGroup, config); + MapWriteHandler(new ScenarioRouteHandler(), authGroup, config); + MapWriteHandler(new SC5FilePackRouteHandler(), authGroup, config); + MapWriteHandler(new ObjectPackRouteHandler(), authGroup, config); + // Curator write routes for metadata (requires Curator policy or Admin) + var curatorGroup = v2.MapGroup(string.Empty).RequireAuthorization("Curator"); + MapWriteHandler(new AuthorRouteHandler(), curatorGroup, config); + MapWriteHandler(new TagRouteHandler(), curatorGroup, config); + MapWriteHandler(new LicenceRouteHandler(), curatorGroup, config); + + // Object write routes (create/edit/delete require ownership or admin) + var ownerGroup = v2.MapGroup(string.Empty).RequireAuthorization("CanEditObject"); + MapWriteHandler(new ObjectRouteHandler(), ownerGroup, config); + + // Admin-only routes (both read and write) + var adminGroup = v2.MapGroup(string.Empty).RequireAuthorization("AdminOnly"); + MapAllHandler(new UserRouteHandler(), adminGroup, config); + MapAllHandler(new RoleRouteHandler(), adminGroup, config); return v2; } - static IEndpointConventionBuilder MapServerRoutes(this IEndpointRouteBuilder endpoints) - { - var routeGroup = endpoints.MapGroup(string.Empty); - ArgumentNullException.ThrowIfNull(routeGroup); - - AuthorRouteHandler.MapRoutes(routeGroup); - TagRouteHandler.MapRoutes(routeGroup); - LicenceRouteHandler.MapRoutes(routeGroup); - ObjectRouteHandler.MapRoutes(routeGroup); - ScenarioRouteHandler.MapRoutes(routeGroup); - SC5FilePackRouteHandler.MapRoutes(routeGroup); - ObjectPackRouteHandler.MapRoutes(routeGroup); - - return routeGroup; - } + private static void MapHandler(ITableRouteHandler handler, IEndpointRouteBuilder group, IConfiguration config) + => BaseTableRouteHandler.MapRoutes(handler, group, config); - static IEndpointConventionBuilder MapAdminRoutes(this IEndpointRouteBuilder endpoints) - { - var routeGroup = endpoints.MapGroup(string.Empty); - ArgumentNullException.ThrowIfNull(routeGroup); + private static void MapWriteHandler(ITableRouteHandler handler, IEndpointRouteBuilder group, IConfiguration config) + => BaseTableRouteHandler.MapWriteRoutes(handler, group, config); - UserRouteHandler.MapRoutes(routeGroup); - RoleRouteHandler.MapRoutes(routeGroup); - - return routeGroup; - } + private static void MapAllHandler(ITableRouteHandler handler, IEndpointRouteBuilder group, IConfiguration config) + => BaseTableRouteHandler.MapAllRoutes(handler, group, config); } diff --git a/ObjectService/ServerFolderManager.cs b/ObjectService/ServerFolderManager.cs index bece53b6..589f75a7 100644 --- a/ObjectService/ServerFolderManager.cs +++ b/ObjectService/ServerFolderManager.cs @@ -5,12 +5,12 @@ namespace ObjectService; public interface IServerFolderManager { -//string RootDirectory { get; init; } + //string RootDirectory { get; init; } } public class TestServerFolderManager : IServerFolderManager { -//string RootDirectory { get; init; } + //string RootDirectory { get; init; } } /// @@ -44,131 +44,131 @@ public class TestServerFolderManager : IServerFolderManager /// public class ServerFolderManager : IServerFolderManager { -string RootDirectory { get; init; } + string RootDirectory { get; init; } + + public ServerFolderManager(string rootDirectory) + { + if (!Directory.Exists(rootDirectory)) + { + throw new DirectoryNotFoundException($"The specified root directory does not exist: {rootDirectory}"); + } + + RootDirectory = rootDirectory; + + ILogger logger = new Common.Logging.Logger(); + + var indexFile = Path.Combine(rootDirectory, ObjectsFolderName); + try + { + ObjectIndex = ObjectIndex.LoadOrCreateIndex(indexFile, logger)!; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // Index file is corrupt or otherwise unreadable. Log the original failure + // before destroying the file so we can diagnose recurring corruption. + logger.LogError(ex, "Failed to load object index at \"{IndexFile}\"; deleting and recreating.", indexFile); + try + { + File.Delete(indexFile); + } + catch (Exception deleteEx) when (deleteEx is IOException or UnauthorizedAccessException) + { + logger.LogError(deleteEx, "Failed to delete corrupt index file \"{IndexFile}\".", indexFile); + throw; + } + + try + { + ObjectIndex = ObjectIndex.LoadOrCreateIndex(indexFile, logger)!; + } + catch (Exception retryEx) when (retryEx is IOException or UnauthorizedAccessException or JsonException) + { + logger.LogError(retryEx, "Failed to recreate object index at \"{IndexFile}\".", indexFile); + throw; + } + } + + EnsureDirectoryExists(ObjectsOriginalFolder); + EnsureDirectoryExists(ObjectsCustomFolder); + + EnsureDirectoryExists(GraphicsOriginalFolder); + EnsureDirectoryExists(GraphicsCustomFolder); + + EnsureDirectoryExists(MusicOriginalFolder); + EnsureDirectoryExists(MusicCustomFolder); + + EnsureDirectoryExists(SoundEffectsOriginalFolder); + EnsureDirectoryExists(SoundEffectsCustomFolder); + + EnsureDirectoryExists(TutorialsOriginalFolder); + EnsureDirectoryExists(TutorialsCustomFolder); + + EnsureDirectoryExists(ScenariosOriginalFolder); + EnsureDirectoryExists(ScenariosCustomFolder); + EnsureDirectoryExists(ScenariosGoGFolder); + EnsureDirectoryExists(ScenariosSteamFolder); + } + + private static void EnsureDirectoryExists(string path) + { + if (!Directory.Exists(path)) + { + throw new DirectoryNotFoundException($"Required server directory not found: {path}"); + } + } + + public ObjectIndex ObjectIndex { get; init; } + + public const string ObjectsFolderName = "Objects"; + + public const string LandscapesFolderName = "Landscapes"; + public const string ScenariosFolderName = "Scenarios"; + public const string GameDataFolderName = "GameData"; + + public const string GraphicsFolderName = "Graphics"; + public const string MusicFolderName = "Music"; + public const string SoundEffectsFolderName = "SoundEffects"; + public const string TutorialsFolderName = "Tutorials"; + + public const string OriginalFolderName = "Original"; + public const string CustomFolderName = "Custom"; + + public const string SteamFolderName = "Steam"; + public const string GoGFolderName = "GoG"; + + #region Objects + + public string IndexFile => Path.Combine(RootDirectory, ObjectsFolderName, ObjectIndex.DefaultIndexFileName); + public string ObjectsFolder => Path.Combine(RootDirectory, ObjectsFolderName); + public string ObjectsOriginalFolder => Path.Combine(ObjectsFolder, OriginalFolderName); + public string ObjectsCustomFolder => Path.Combine(ObjectsFolder, CustomFolderName); + + #endregion + + #region GameData + + public string GameDataFolder => Path.Combine(RootDirectory, GameDataFolderName); + + public string GraphicsOriginalFolder => Path.Combine(GameDataFolder, GraphicsFolderName, OriginalFolderName); + public string GraphicsCustomFolder => Path.Combine(GameDataFolder, GraphicsFolderName, CustomFolderName); + public string MusicOriginalFolder => Path.Combine(GameDataFolder, MusicFolderName, OriginalFolderName); + public string MusicCustomFolder => Path.Combine(GameDataFolder, MusicFolderName, CustomFolderName); + public string SoundEffectsOriginalFolder => Path.Combine(GameDataFolder, SoundEffectsFolderName, OriginalFolderName); + public string SoundEffectsCustomFolder => Path.Combine(GameDataFolder, SoundEffectsFolderName, CustomFolderName); + public string TutorialsOriginalFolder => Path.Combine(GameDataFolder, TutorialsFolderName, OriginalFolderName); + public string TutorialsCustomFolder => Path.Combine(GameDataFolder, TutorialsFolderName, CustomFolderName); + + #endregion + + #region SCV5 + + public string LandscapesFolder => Path.Combine(RootDirectory, LandscapesFolderName); + public string ScenariosFolder => Path.Combine(RootDirectory, ScenariosFolderName); + + public string ScenariosOriginalFolder => Path.Combine(ScenariosFolder, OriginalFolderName); + public string ScenariosCustomFolder => Path.Combine(ScenariosFolder, CustomFolderName); + public string ScenariosGoGFolder => Path.Combine(ScenariosOriginalFolder, GoGFolderName); + public string ScenariosSteamFolder => Path.Combine(ScenariosOriginalFolder, SteamFolderName); -public ServerFolderManager(string rootDirectory) -{ -if (!Directory.Exists(rootDirectory)) -{ -throw new DirectoryNotFoundException($"The specified root directory does not exist: {rootDirectory}"); -} - -RootDirectory = rootDirectory; - -ILogger logger = new Common.Logging.Logger(); - -var indexFile = Path.Combine(rootDirectory, ObjectsFolderName); -try -{ -ObjectIndex = ObjectIndex.LoadOrCreateIndex(indexFile, logger)!; -} -catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) -{ -// Index file is corrupt or otherwise unreadable. Log the original failure -// before destroying the file so we can diagnose recurring corruption. -logger.LogError(ex, "Failed to load object index at \"{IndexFile}\"; deleting and recreating.", indexFile); -try -{ -File.Delete(indexFile); -} -catch (Exception deleteEx) when (deleteEx is IOException or UnauthorizedAccessException) -{ -logger.LogError(deleteEx, "Failed to delete corrupt index file \"{IndexFile}\".", indexFile); -throw; -} - -try -{ -ObjectIndex = ObjectIndex.LoadOrCreateIndex(indexFile, logger)!; -} -catch (Exception retryEx) when (retryEx is IOException or UnauthorizedAccessException or JsonException) -{ -logger.LogError(retryEx, "Failed to recreate object index at \"{IndexFile}\".", indexFile); -throw; -} -} - -EnsureDirectoryExists(ObjectsOriginalFolder); -EnsureDirectoryExists(ObjectsCustomFolder); - -EnsureDirectoryExists(GraphicsOriginalFolder); -EnsureDirectoryExists(GraphicsCustomFolder); - -EnsureDirectoryExists(MusicOriginalFolder); -EnsureDirectoryExists(MusicCustomFolder); - -EnsureDirectoryExists(SoundEffectsOriginalFolder); -EnsureDirectoryExists(SoundEffectsCustomFolder); - -EnsureDirectoryExists(TutorialsOriginalFolder); -EnsureDirectoryExists(TutorialsCustomFolder); - -EnsureDirectoryExists(ScenariosOriginalFolder); -EnsureDirectoryExists(ScenariosCustomFolder); -EnsureDirectoryExists(ScenariosGoGFolder); -EnsureDirectoryExists(ScenariosSteamFolder); -} - -private static void EnsureDirectoryExists(string path) -{ -if (!Directory.Exists(path)) -{ -throw new DirectoryNotFoundException($"Required server directory not found: {path}"); -} -} - -public ObjectIndex ObjectIndex { get; init; } - -public const string ObjectsFolderName = "Objects"; - -public const string LandscapesFolderName = "Landscapes"; -public const string ScenariosFolderName = "Scenarios"; -public const string GameDataFolderName = "GameData"; - -public const string GraphicsFolderName = "Graphics"; -public const string MusicFolderName = "Music"; -public const string SoundEffectsFolderName = "SoundEffects"; -public const string TutorialsFolderName = "Tutorials"; - -public const string OriginalFolderName = "Original"; -public const string CustomFolderName = "Custom"; - -public const string SteamFolderName = "Steam"; -public const string GoGFolderName = "GoG"; - -#region Objects - -public string IndexFile => Path.Combine(RootDirectory, ObjectsFolderName, ObjectIndex.DefaultIndexFileName); -public string ObjectsFolder => Path.Combine(RootDirectory, ObjectsFolderName); -public string ObjectsOriginalFolder => Path.Combine(ObjectsFolder, OriginalFolderName); -public string ObjectsCustomFolder => Path.Combine(ObjectsFolder, CustomFolderName); - -#endregion - -#region GameData - -public string GameDataFolder => Path.Combine(RootDirectory, GameDataFolderName); - -public string GraphicsOriginalFolder => Path.Combine(GameDataFolder, GraphicsFolderName, OriginalFolderName); -public string GraphicsCustomFolder => Path.Combine(GameDataFolder, GraphicsFolderName, CustomFolderName); -public string MusicOriginalFolder => Path.Combine(GameDataFolder, MusicFolderName, OriginalFolderName); -public string MusicCustomFolder => Path.Combine(GameDataFolder, MusicFolderName, CustomFolderName); -public string SoundEffectsOriginalFolder => Path.Combine(GameDataFolder, SoundEffectsFolderName, OriginalFolderName); -public string SoundEffectsCustomFolder => Path.Combine(GameDataFolder, SoundEffectsFolderName, CustomFolderName); -public string TutorialsOriginalFolder => Path.Combine(GameDataFolder, TutorialsFolderName, OriginalFolderName); -public string TutorialsCustomFolder => Path.Combine(GameDataFolder, TutorialsFolderName, CustomFolderName); - -#endregion - -#region SCV5 - -public string LandscapesFolder => Path.Combine(RootDirectory, LandscapesFolderName); -public string ScenariosFolder => Path.Combine(RootDirectory, ScenariosFolderName); - -public string ScenariosOriginalFolder => Path.Combine(ScenariosFolder, OriginalFolderName); -public string ScenariosCustomFolder => Path.Combine(ScenariosFolder, CustomFolderName); -public string ScenariosGoGFolder => Path.Combine(ScenariosOriginalFolder, GoGFolderName); -public string ScenariosSteamFolder => Path.Combine(ScenariosOriginalFolder, SteamFolderName); - -#endregion + #endregion } diff --git a/ObjectService/Services/CrudService.cs b/ObjectService/Services/CrudService.cs new file mode 100644 index 00000000..400a487a --- /dev/null +++ b/ObjectService/Services/CrudService.cs @@ -0,0 +1,84 @@ +using Definitions; +using Definitions.Database; +using Microsoft.EntityFrameworkCore; + +namespace ObjectService.Services; + +public class CrudService : ICrudService +where TDto : class, IHasId +where TRow : class, IHasId +{ + private readonly LocoDbContext _db; + private readonly Func> _tableSelector; + private readonly Func _toDto; + private readonly Func _toRow; + private readonly Action _updateRow; + private readonly Func _validate; + + public CrudService( + LocoDbContext db, + Func> tableSelector, + Func toDto, + Func toRow, + Action updateRow, + Func validate) + { + _db = db; + _tableSelector = tableSelector; + _toDto = toDto; + _toRow = toRow; + _updateRow = updateRow; + _validate = validate; + } + + private DbSet Table => _tableSelector(_db); + + public bool TryValidateCreate(TDto request, out string? errorMessage) + { + errorMessage = _validate(request); + return errorMessage == null; + } + + public async Task> ListAsync(HttpContext context, CancellationToken ct) + => await Table.Select(x => _toDto(x)).ToListAsync(ct); + + public async Task CreateAsync(TDto request, CancellationToken ct) + { + var row = _toRow(request); + _ = await Table.AddAsync(row, ct); + _ = await _db.SaveChangesAsync(ct); + return _toDto(row); + } + + public async Task ReadAsync(UniqueObjectId id, CancellationToken ct) + { + var row = await Table.FindAsync([id], ct); + return row != null ? _toDto(row) : null; + } + + public async Task UpdateAsync(UniqueObjectId id, TDto request, CancellationToken ct) + { + var row = await Table.FindAsync([id], ct); + if (row == null) + { + return null; + } + + _updateRow(request, row); + _ = await _db.SaveChangesAsync(ct); + return _toDto(row); + } + + public async Task DeleteAsync(UniqueObjectId id, CancellationToken ct) + { + var row = await Table.FindAsync([id], ct); + if (row == null) + { + return false; + } + + _ = Table.Remove(row); + _ = await _db.SaveChangesAsync(ct); + return true; + } +} diff --git a/ObjectService/Services/ICrudService.cs b/ObjectService/Services/ICrudService.cs new file mode 100644 index 00000000..20ccba3d --- /dev/null +++ b/ObjectService/Services/ICrudService.cs @@ -0,0 +1,16 @@ +using Definitions; + +namespace ObjectService.Services; + +public interface ICrudService +where TDto : class, IHasId +where TRow : class, IHasId +{ + Task> ListAsync(HttpContext context, CancellationToken ct); + Task CreateAsync(TDto request, CancellationToken ct); + Task ReadAsync(UniqueObjectId id, CancellationToken ct); + Task UpdateAsync(UniqueObjectId id, TDto request, CancellationToken ct); + Task DeleteAsync(UniqueObjectId id, CancellationToken ct); + + bool TryValidateCreate(TDto request, out string? errorMessage); +} diff --git a/ObjectService/Services/ObjectPackService.cs b/ObjectService/Services/ObjectPackService.cs new file mode 100644 index 00000000..e4d835ca --- /dev/null +++ b/ObjectService/Services/ObjectPackService.cs @@ -0,0 +1,131 @@ +using System.IO.Compression; +using Common; +using Definitions; +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Mappers; +using Definitions.ObjectModels.Types; +using Definitions.SourceData; +using Microsoft.EntityFrameworkCore; +using ObjectService.RouteHandlers; + +namespace ObjectService.Services; + +public interface IObjectPackService +{ + Task> ListPacksAsync(CancellationToken ct); + Task>> GetPackAsync(UniqueObjectId id, CancellationToken ct); + Task<(Stream? Stream, string FileName)> GetPackFileAsync(UniqueObjectId id, CancellationToken ct); + Task CreatePackAsync(DtoItemPackDescriptor request, UniqueObjectId ownerUserId, CancellationToken ct); +} + +public class ObjectPackService : IObjectPackService +{ + private readonly LocoDbContext _db; + private readonly ServerFolderManager _sfm; + + public ObjectPackService(LocoDbContext db, ServerFolderManager sfm) + { + _db = db; + _sfm = sfm; + } + + public async Task> ListPacksAsync(CancellationToken ct) + { + var packs = await _db.ObjectPacks.Include(l => l.Licence).ToListAsync(ct); + return packs.Select(x => x.ToDtoEntry()).OrderBy(x => x.Name); + } + + public async Task>> GetPackAsync(UniqueObjectId id, CancellationToken ct) + { + var packs = await _db.ObjectPacks.Where(x => x.Id == id).Include(l => l.Licence).Select(x => new ExpandedTblPack(x, x.Objects, x.Authors, x.Tags)).ToListAsync(ct); + return packs.Select(x => x.ToDtoDescriptor()).OrderBy(x => x.Name); + } + + public async Task<(Stream? Stream, string FileName)> GetPackFileAsync(UniqueObjectId id, CancellationToken ct) + { + var pack = await _db.ObjectPacks.Where(x => x.Id == id).Include(x => x.Objects).ThenInclude(o => o.DatObjects).SingleOrDefaultAsync(ct); + if (pack == null) + { + return (null, string.Empty); + } + + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.zip"); + var zipStream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.DeleteOnClose); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var obj in pack.Objects) + { + if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam) + { + continue; + } + + if (obj.Availability == ObjectAvailability.Unavailable) + { + continue; + } + + foreach (var dat in obj.DatObjects) + { + if (!_sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null) + { + continue; + } + + if (!RouteHelpers.TryGetSafeRelativePathUnderRoot(_sfm.ObjectsFolder, entry.FileName, out var fullPath, out var entryName)) + { + continue; + } + + if (!File.Exists(fullPath)) + { + continue; + } + + await using var fs = File.OpenRead(fullPath); + var ze = archive.CreateEntry(entryName, CompressionLevel.Optimal); + await using var es = ze.Open(); + await fs.CopyToAsync(es, ct); + } + } + } + zipStream.Position = 0; + var dn = DownloadNameHelper.MakeSafeDownloadFileName(pack.Name, ".zip", "object-pack"); + return (zipStream, dn); + } + public async Task CreatePackAsync(DtoItemPackDescriptor request, UniqueObjectId ownerUserId, CancellationToken ct) + { + var pack = new TblObjectPack + { + Name = request.Name, + Description = request.Description, + OwnerUserId = ownerUserId, + }; + + // Link the owner's associated author if available + var owner = await _db.Users.Include(u => u.AssociatedAuthor).FirstOrDefaultAsync(u => u.Id == ownerUserId, ct); + if (owner?.AssociatedAuthor != null) + { + pack.Authors.Add(owner.AssociatedAuthor); + } + + // Add requested objects if specified + if (request.Items?.Count > 0) + { + var objectIds = request.Items.Select(i => i.Id).ToList(); + var objects = await _db.Objects.Where(o => objectIds.Contains(o.Id)).ToListAsync(ct); + foreach (var obj in objects) + { + pack.Objects.Add(obj); + } + } + + _ = await _db.ObjectPacks.AddAsync(pack, ct); + _ = await _db.SaveChangesAsync(ct); + + // Reload to get computed columns like UploadedDate + var created = await _db.ObjectPacks.Include(p => p.Licence).FirstAsync(p => p.Id == pack.Id, ct); + return created.ToDtoEntry(); + } +} diff --git a/ObjectService/Services/ObjectQueryService.cs b/ObjectService/Services/ObjectQueryService.cs new file mode 100644 index 00000000..77f270f9 --- /dev/null +++ b/ObjectService/Services/ObjectQueryService.cs @@ -0,0 +1,266 @@ +using Dat.FileParsing; +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Mappers; +using Definitions.ObjectModels.Graphics; +using Definitions.ObjectModels.Types; +using Definitions.SourceData; +using Microsoft.EntityFrameworkCore; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.IO.Compression; + +namespace ObjectService.Services; + +public interface IObjectQueryService +{ + Task> ListAsync(HttpContext context, CancellationToken ct); + Task GetByIdAsync(UniqueObjectId id, CancellationToken ct); + Task UpdateAsync(UniqueObjectId id, DtoObjectPostResponse request, CancellationToken ct); + Task GetImagesZipAsync(UniqueObjectId id, CancellationToken ct); + Task GetFirstImagePngAsync(UniqueObjectId id, CancellationToken ct); + Task GetFilePathAsync(UniqueObjectId id, CancellationToken ct); +} + +public class ObjectQueryService : IObjectQueryService +{ + private readonly LocoDbContext _db; + private readonly ServerFolderManager _sfm; + private readonly ILogger _logger; + + public ObjectQueryService(LocoDbContext db, ServerFolderManager sfm, ILogger logger) + { + _db = db; + _sfm = sfm; + _logger = logger; + } + + public async Task> ListAsync(HttpContext context, CancellationToken ct) + { + return await _db.Objects.Include(x => x.DatObjects).Select(x => x.ToDtoEntry()).ToListAsync(ct); + } + + public async Task GetByIdAsync(UniqueObjectId id, CancellationToken ct) + { + var eObj = await _db.Objects.Where(x => x.Id == id).Include(x => x.Licence).Include(x => x.DatObjects).Include(x => x.StringTable).Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)).SingleOrDefaultAsync(ct); + return eObj?.ToDtoDescriptor(); + } + + public async Task UpdateAsync(UniqueObjectId id, DtoObjectPostResponse request, CancellationToken ct) + { + var obj = await _db.Objects.Include(x => x.Licence).Include(x => x.Authors).Include(x => x.Tags).Include(x => x.ObjectPacks).Include(x => x.DatObjects).Include(x => x.StringTable).Where(x => x.Id == id).SingleOrDefaultAsync(ct); + if (obj == null) + { + return null; + } + + // Vanilla game objects (original Locomotion assets) can NEVER be edited by anyone, + // regardless of role or ownership. This is a defense-in-depth check alongside + // the ObjectOwnershipHandler authorization. + if (obj.ObjectSource is ObjectSource.LocomotionSteam or ObjectSource.LocomotionGoG) + { + _logger.LogWarning("Attempt to update vanilla object {ObjectId} was blocked", id); + return null; + } + + obj.Description = request.Description; + obj.CreatedDate = request.CreatedDate; + obj.ModifiedDate = request.ModifiedDate; + obj.Availability = request.Availability; + if (request.Licence == null) + { + obj.Licence = null; + } + else + { + obj.Licence = await _db.Licences.SingleOrDefaultAsync(l => l.Id == request.Licence.Id, ct); + } + + if (request.Authors == null || request.Authors.Count == 0) + { + obj.Authors.Clear(); + } + else + { + var ids = request.Authors.Select(a => a.Id).ToList(); + var items = await _db.Authors.Where(x => ids.Contains(x.Id)).ToListAsync(ct); + obj.Authors.Clear(); + foreach (var i in items) + { + obj.Authors.Add(i); + } + } + + if (request.Tags == null || request.Tags.Count == 0) + { + obj.Tags.Clear(); + } + else + { + var ids = request.Tags.Select(t => t.Id).ToList(); + var items = await _db.Tags.Where(x => ids.Contains(x.Id)).ToListAsync(ct); + obj.Tags.Clear(); + foreach (var i in items) + { + obj.Tags.Add(i); + } + } + + if (request.ObjectPacks == null || request.ObjectPacks.Count == 0) + { + obj.ObjectPacks.Clear(); + } + else + { + var ids = request.ObjectPacks.Select(p => p.Id).ToList(); + var items = await _db.ObjectPacks.Where(x => ids.Contains(x.Id)).ToListAsync(ct); + obj.ObjectPacks.Clear(); + foreach (var i in items) + { + obj.ObjectPacks.Add(i); + } + } + + _ = await _db.SaveChangesAsync(ct); + var expandedObj = new ExpandedTbl(obj, obj.Authors, obj.Tags, obj.ObjectPacks); + return expandedObj.ToDtoDescriptor(); + } + + public async Task GetImagesZipAsync(UniqueObjectId id, CancellationToken ct) + { + var obj = await _db.Objects.AsNoTracking().Include(x => x.DatObjects).SingleOrDefaultAsync(x => x.Id == id, ct); + if (obj == null) + { + return null; + } + + var datEntry = obj.DatObjects.FirstOrDefault(); + if (datEntry == null || !_sfm.ObjectIndex.TryFind((datEntry.DatName, datEntry.DatChecksum), out var indexEntry) || indexEntry == null || string.IsNullOrEmpty(indexEntry.FileName)) + { + return null; + } + + var objectFilePath = Path.Combine(_sfm.ObjectsFolder, indexEntry.FileName); + if (!File.Exists(objectFilePath)) + { + return null; + } + + var datBytes = await File.ReadAllBytesAsync(objectFilePath, ct); + var result = SawyerStreamReader.LoadFullObject(datBytes, _logger); + if (result.LocoObject?.ImageTable == null) + { + return null; + } + + result.LocoObject.ImageTable.PaletteMap = PaletteMapLoader.LoadDefault(); + + var elements = result.LocoObject.ImageTable.GraphicsElements; + var tempZipPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".zip"); + using var zipStream = new FileStream(tempZipPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.DeleteOnClose); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + for (int i = 0; i < elements.Count; i++) + { + var element = elements[i]; + if (element.Image == null) + { + continue; + } + + var entry = archive.CreateEntry(i + ".png", CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await element.Image.SaveAsPngAsync(entryStream, ct); + } + } + zipStream.Position = 0; + using var ms = new MemoryStream(); + await zipStream.CopyToAsync(ms, ct); + return ms.ToArray(); + } + + public async Task GetFirstImagePngAsync(UniqueObjectId id, CancellationToken ct) + { + var obj = await _db.Objects.AsNoTracking().Include(x => x.DatObjects).SingleOrDefaultAsync(x => x.Id == id, ct); + if (obj == null) + { + return null; + } + + var datEntry = obj.DatObjects.FirstOrDefault(); + if (datEntry == null || !_sfm.ObjectIndex.TryFind((datEntry.DatName, datEntry.DatChecksum), out var indexEntry) || indexEntry == null || string.IsNullOrEmpty(indexEntry.FileName)) + { + return null; + } + + var objectFilePath = Path.Combine(_sfm.ObjectsFolder, indexEntry.FileName); + if (!File.Exists(objectFilePath)) + { + return null; + } + + var datBytes = await File.ReadAllBytesAsync(objectFilePath, ct); + var result = SawyerStreamReader.LoadFullObject(datBytes, _logger); + if (result.LocoObject?.ImageTable == null) + { + return null; + } + + result.LocoObject.ImageTable.PaletteMap = PaletteMapLoader.LoadDefault(); + + var elements = result.LocoObject.ImageTable.GraphicsElements; + var first = elements.FirstOrDefault(e => e.Image != null); + if (first == null) + { + return null; + } + + // Trim the image to its non-transparent bounding box, + // using the same FindCropRegion function used by the GUI's CropImage. + using var image = TrimImage(first.Image!); + using var ms = new MemoryStream(); + await image.SaveAsPngAsync(ms, ct); + return ms.ToArray(); + } + + /// + /// Returns a clone of cropped to the bounding box of all + /// non‑transparent pixels, using the same algorithm as the GUI's CropImage command. + /// + static Image TrimImage(Image image) + { + var cropRegion = GraphicsElementOperations.FindCropRegion(image); + + if (cropRegion.Width <= 0 || cropRegion.Height <= 0) + { + // Fully transparent image – return a 1×1 transparent pixel + // (same fallback the GUI uses). + return image.Clone(i => i.Crop(new Rectangle(0, 0, 1, 1))); + } + + return image.Clone(i => i.Crop(cropRegion)); + } + + public async Task GetFilePathAsync(UniqueObjectId id, CancellationToken ct) + { + var obj = await _db.Objects.Include(x => x.DatObjects).Where(x => x.Id == id).SingleOrDefaultAsync(ct); + if (obj == null) + { + return null; + } + + var dat = obj.DatObjects.First(); + if (!_sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null) + { + return null; + } + + if (string.IsNullOrEmpty(entry.FileName)) + { + return null; + } + + return Path.Combine(_sfm.ObjectsFolder, entry.FileName); + } +} \ No newline at end of file diff --git a/ObjectService/Services/ObjectUploadService.cs b/ObjectService/Services/ObjectUploadService.cs new file mode 100644 index 00000000..4eb67e8d --- /dev/null +++ b/ObjectService/Services/ObjectUploadService.cs @@ -0,0 +1,184 @@ +using Dat.Converters; +using Dat.FileParsing; +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Mappers; +using Definitions.ObjectModels; +using Definitions.ObjectModels.Objects.Vehicle; +using Definitions.ObjectModels.Types; +using Definitions.SourceData; +using Definitions.Web; +using Index; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using System.IO.Hashing; + +namespace ObjectService.Services; + +public record UploadResult(bool Success, DtoObjectPostResponse? Descriptor, string? ErrorMessage, int StatusCode); + +public interface IObjectUploadService +{ + Task UploadDatAsync(DtoObjectPost request, CancellationToken ct); +} + +public class ObjectUploadService : IObjectUploadService +{ + private readonly LocoDbContext _db; + private readonly ServerFolderManager _sfm; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserManager _userManager; + + public ObjectUploadService( + LocoDbContext db, + ServerFolderManager sfm, + ILoggerFactory loggerFactory, + ILogger logger, + IHttpContextAccessor httpContextAccessor, + UserManager userManager) + { + _db = db; + _sfm = sfm; + _loggerFactory = loggerFactory; + _logger = logger; + _httpContextAccessor = httpContextAccessor; + _userManager = userManager; + } + + public async Task UploadDatAsync(DtoObjectPost request, CancellationToken ct) + { + if (string.IsNullOrEmpty(request.DatBytesAsBase64)) + { + return new UploadResult(false, null, "DatBytesAsBase64 cannot be null", 400); + } + + byte[]? datFileBytes; + try + { + datFileBytes = Convert.FromBase64String(request.DatBytesAsBase64); + } + catch (FormatException ex) + { + return new UploadResult(false, null, ex.Message, 400); + } + + if (datFileBytes == null || datFileBytes.Length == 0) + { + return new UploadResult(false, null, "Decoded bytes are empty", 400); + } + + if (datFileBytes.Length > ServerLimits.MaximumUploadFileSize) + { + return new UploadResult(false, null, $"Uploads limited to {ServerLimits.MaximumUploadFileSize / (1024 * 1024)}MB", 413); + } + + var ssrLogger = _loggerFactory.CreateLogger("SawyerStreamReader"); + if (!SawyerStreamReader.TryGetHeadersFromBytes(datFileBytes, out var hdrs, ssrLogger)) + { + return new UploadResult(false, null, "Invalid dat file headers", 400); + } + + if (hdrs.S5.IsVanilla()) + { + return new UploadResult(false, null, "Uploading vanilla objects is not allowed", 400); + } + + if (!hdrs.S5.IsValid() || !hdrs.Obj.IsValid()) + { + return new UploadResult(false, null, "Invalid DAT file", 400); + } + + var objName = $"{hdrs.S5.Name}_{hdrs.S5.Checksum}"; + var existing = await _db.Objects.FirstOrDefaultAsync(x => x.Name == objName, ct); + if (existing != null) + { + return new UploadResult(false, null, $"Object already exists. UploadedDate={existing.UploadedDate}", 202); + } + + var missingEntry = await _db.ObjectsMissing.FirstOrDefaultAsync(x => x.DatName == hdrs.S5.Name && x.DatChecksum == hdrs.S5.Checksum, ct); + if (missingEntry != null) + { + _ = _db.ObjectsMissing.Remove(missingEntry); + _ = await _db.SaveChangesAsync(ct); + } + + if (_db.DoesObjectExist(hdrs.S5.Name, hdrs.S5.Checksum, out var existingObj)) + { + return new UploadResult(false, null, $"DatObject already exists. UploadedDate={existingObj!.UploadedDate}", 202); + } + + var (DatFileInfo, LocoObject) = SawyerStreamReader.LoadFullObject(datFileBytes, ssrLogger); + if (LocoObject == null) + { + return new UploadResult(false, null, "Could not parse DAT object", 400); + } + + var uuid = Guid.NewGuid(); + var saveFileName = Path.Combine(_sfm.ObjectsCustomFolder, $"{uuid}.dat"); + await File.WriteAllBytesAsync(saveFileName, datFileBytes, ct); + + VehicleType? vehicleType = null; + if (LocoObject.Object is VehicleObject veh) + { + vehicleType = veh.Type; + } + + // Determine the owner user if the request is authenticated + UniqueObjectId? ownerUserId = null; + var currentUser = _httpContextAccessor.HttpContext?.User; + if (currentUser?.Identity?.IsAuthenticated == true) + { + var user = await _userManager.GetUserAsync(currentUser); + if (user != null) + { + ownerUserId = user.Id; + } + } + + var tblObject = new TblObject() + { + Name = objName, + Description = string.Empty, + ObjectSource = ObjectSource.Custom, + ObjectType = hdrs.S5.ObjectType.Convert(), + VehicleType = vehicleType, + Availability = request.InitialAvailability, + CreatedDate = request.CreatedDate, + ModifiedDate = request.ModifiedDate, + UploadedDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Authors = [], + Tags = [], + ObjectPacks = [], + DatObjects = [], + StringTable = [], + SubObjectId = 0, + Licence = null, + OwnerUserId = ownerUserId, + }; + + _ = await _db.Objects.AddAsync(tblObject, ct); + _ = await _db.SaveChangesAsync(ct); + + foreach (var s in LocoObject.StringTable.Table) + { + foreach (var t in s.Value) + { + tblObject.StringTable.Add(new TblStringTableRow() { Name = s.Key, Language = t.Key, Text = t.Value, ObjectId = tblObject.Id, }); + } + } + + var xxHash3 = XxHash3.HashToUInt64(datFileBytes); + tblObject.DatObjects.Add(new TblDatObject() { ObjectId = tblObject.Id, DatName = DatFileInfo.S5Header.Name, DatChecksum = DatFileInfo.S5Header.Checksum, xxHash3 = xxHash3, Object = tblObject, }); + + _ = await DbSubObjectHelper.AddOrUpdate(_db, tblObject, LocoObject.Object); + _ = await _db.SaveChangesAsync(ct); + + _sfm.ObjectIndex.AddEntry(new ObjectIndexEntry(hdrs.S5.Name, saveFileName, tblObject.Id, hdrs.S5.Checksum, xxHash3, tblObject.ObjectType, tblObject.ObjectSource, tblObject.CreatedDate, tblObject.UploadedDate, tblObject.VehicleType)); + _ = _sfm.ObjectIndex.SaveIndexAsync(_sfm.IndexFile); + + var response = new ExpandedTbl(tblObject, [], [], []).ToDtoDescriptor(); + return new UploadResult(true, response, null, 201); + } +} \ No newline at end of file diff --git a/ObjectService/Services/SC5FilePackService.cs b/ObjectService/Services/SC5FilePackService.cs new file mode 100644 index 00000000..b986a02f --- /dev/null +++ b/ObjectService/Services/SC5FilePackService.cs @@ -0,0 +1,112 @@ +using System.IO.Compression; +using Common; +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Mappers; +using Definitions.SourceData; +using Microsoft.EntityFrameworkCore; +using ObjectService.RouteHandlers; + +namespace ObjectService.Services; + +public interface ISC5FilePackService +{ +Task>> ListPacksAsync(CancellationToken ct); +Task>> GetPackAsync(UniqueObjectId id, CancellationToken ct); +Task<(Stream? Stream, string FileName)> GetPackFileAsync(UniqueObjectId id, CancellationToken ct); +Task> CreatePackAsync(DtoItemPackDescriptor request, UniqueObjectId ownerUserId, CancellationToken ct); +} + +public class SC5FilePackService : ISC5FilePackService +{ +private readonly LocoDbContext _db; +private readonly ServerFolderManager _sfm; +public SC5FilePackService(LocoDbContext db, ServerFolderManager sfm) +{ +_db = db; +_sfm = sfm; +} + +public async Task>> ListPacksAsync(CancellationToken ct) +{ +var packs = await _db.SC5FilePacks.Include(l => l.Licence).ToListAsync(ct); +return packs.Select(x => x.ToDtoEntry()).OrderBy(x => x.Name); +} + +public async Task>> GetPackAsync(UniqueObjectId id, CancellationToken ct) +{ +var packs = await _db.SC5FilePacks.Where(x => x.Id == id).Include(l => l.Licence) +.Select(x => new ExpandedTblPack(x, x.SC5Files, x.Authors, x.Tags)).ToListAsync(ct); +return packs.Select(x => x.ToDtoDescriptor()).OrderBy(x => x.Name); +} + +public async Task<(Stream? Stream, string FileName)> GetPackFileAsync(UniqueObjectId id, CancellationToken ct) +{ +var pack = await _db.SC5FilePacks.Where(x => x.Id == id).Include(x => x.SC5Files).SingleOrDefaultAsync(ct); +if (pack == null) +{ +return (null, string.Empty); +} + +var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".zip"); +var zipStream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.DeleteOnClose); +using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) +{ +foreach (var sc5File in pack.SC5Files) +{ +if (!RouteHelpers.TryGetSafeRelativePathUnderRoot(_sfm.ScenariosFolder, sc5File.Name, out var fullPath, out var entryName)) +{ +continue; +} + +if (!File.Exists(fullPath)) +{ +continue; +} + +await using var fs = File.OpenRead(fullPath); +var ze = archive.CreateEntry(entryName, CompressionLevel.Optimal); +await using var es = ze.Open(); +await fs.CopyToAsync(es, ct); +} +} +zipStream.Position = 0; +var dn = DownloadNameHelper.MakeSafeDownloadFileName(pack.Name, ".zip", "scenario-pack"); +return (zipStream, dn); +} + +public async Task> CreatePackAsync(DtoItemPackDescriptor request, UniqueObjectId ownerUserId, CancellationToken ct) +{ +var pack = new TblSC5FilePack +{ +Name = request.Name, +Description = request.Description, +OwnerUserId = ownerUserId, +}; + +// Link the owner's associated author if available +var owner = await _db.Users.Include(u => u.AssociatedAuthor).FirstOrDefaultAsync(u => u.Id == ownerUserId, ct); +if (owner?.AssociatedAuthor != null) +{ +pack.Authors.Add(owner.AssociatedAuthor); +} + +// Add requested scenario files if specified +if (request.Items?.Count > 0) +{ +var fileIds = request.Items.Select(i => i.Id).ToList(); +var files = await _db.SC5Files.Where(f => fileIds.Contains(f.Id)).ToListAsync(ct); +foreach (var file in files) +{ +pack.SC5Files.Add(file); +} +} + +_ = await _db.SC5FilePacks.AddAsync(pack, ct); +_ = await _db.SaveChangesAsync(ct); + +// Reload to get computed columns like UploadedDate +var created = await _db.SC5FilePacks.Include(p => p.Licence).FirstAsync(p => p.Id == pack.Id, ct); +return created.ToDtoEntry(); +} +} \ No newline at end of file diff --git a/ObjectService/Services/ScenarioService.cs b/ObjectService/Services/ScenarioService.cs new file mode 100644 index 00000000..46dfe7a5 --- /dev/null +++ b/ObjectService/Services/ScenarioService.cs @@ -0,0 +1,38 @@ +using Definitions.DTO; + +namespace ObjectService.Services; + +public interface IScenarioService +{ + IEnumerable ListScenarios(); + string? GetScenarioFilePath(ulong index); +} + +public class ScenarioService : IScenarioService +{ + private readonly ServerFolderManager _sfm; + + public ScenarioService(ServerFolderManager sfm) + { + _sfm = sfm; + } + + public IEnumerable ListScenarios() + { + var files = GetSortedScenarioFiles(_sfm.ScenariosFolder); + return files + .Select((file, index) => new DtoScenarioEntry((ulong)index, Path.GetRelativePath(_sfm.ScenariosFolder, file))) + .ToArray(); + } + + public string? GetScenarioFilePath(ulong index) + { + var files = GetSortedScenarioFiles(_sfm.ScenariosFolder); + return index < (ulong)files.Length ? files[(int)index] : null; + } + + private static string[] GetSortedScenarioFiles(string folder) + => [.. Directory +.GetFiles(folder, "*.SC5", SearchOption.AllDirectories) +.OrderBy(x => Path.GetRelativePath(folder, x), StringComparer.Ordinal)]; +} diff --git a/ObjectService/Services/ServiceCollectionExtensions.cs b/ObjectService/Services/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ac5be3c1 --- /dev/null +++ b/ObjectService/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,105 @@ +using Definitions.Database; +using Definitions.DTO; +using Definitions.DTO.Identity; +using Definitions.DTO.Mappers; +using Microsoft.EntityFrameworkCore; +using ObjectService.RouteHandlers.TableHandlers; + +namespace ObjectService.Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddObjectEditorServices(this IServiceCollection services) + { + // CRUD services for Category A handlers + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.Authors, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => r.Name = d.Name, + d => string.IsNullOrWhiteSpace(d.Name) ? "Name required" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.Tags, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => r.Name = d.Name, + d => string.IsNullOrWhiteSpace(d.Name) ? "Name required" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.Licences, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => + { + r.Name = d.Name; + r.Text = d.Text; + }, + d => string.IsNullOrWhiteSpace(d.Name) ? "Name required" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.Users, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => r.UserName = d.UserName, + d => string.IsNullOrWhiteSpace(d.UserName) ? "UserName required" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.Roles, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => r.Name = d.Name, + d => string.IsNullOrWhiteSpace(d.Name) ? "Name required" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + _ = services.AddScoped(provider => + { + var db = provider.GetRequiredService(); + return new CrudService( + db, d => d.ObjectsMissing, r => r.ToDtoEntry(), d => d.ToTable(), + (d, r) => + { + r.DatName = d.DatName; + r.DatChecksum = d.DatChecksum; + r.ObjectType = d.ObjectType; + }, + d => string.IsNullOrWhiteSpace(d.DatName) ? "DatName required" : d.DatChecksum == 0 ? "DatChecksum cannot be 0" : !Enum.IsDefined(d.ObjectType) ? $"Invalid ObjectType: {d.ObjectType}" : null); + }); + _ = services.AddScoped>(p => p.GetRequiredService>()); + + // Domain services for Category B handlers + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + + // Route handlers + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + _ = services.AddScoped(); + + return services; + } +} diff --git a/ObjectService/appsettings.Development.json b/ObjectService/appsettings.Development.json index 2fbbd4c8..16ac8fc1 100644 --- a/ObjectService/appsettings.Development.json +++ b/ObjectService/appsettings.Development.json @@ -1,17 +1,19 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "JwtSettings": { + "Key": "ThisIsAVerySecretKeyThatShouldBeAtLeast32CharactersLongAndStoredSecurelyInProduction", + "Issuer": "https://openloco.leftofzen.dev", + "Audience": "https://openloco.leftofzen.dev", + "DurationInMinutes": 60 + }, + "ObjectService": { + "EnableWriteRoutes": true, + "DisableAuthentication": true, + "DeleteDatabaseOnStartup": false } - }, - "JwtSettings": { - "Key": "ThisIsAVerySecretKeyThatShouldBeAtLeast32CharactersLongAndStoredSecurelyInProduction", - "Issuer": "https://openloco.leftofzen.dev", - "Audience": "https://openloco.leftofzen.dev", - "DurationInMinutes": 60 - }, - "ObjectService": { - "EnableWriteRoutes": true - } -} \ No newline at end of file +} diff --git a/ObjectService/wwwroot/css/manage.css b/ObjectService/wwwroot/css/manage.css new file mode 100644 index 00000000..c23a71ef --- /dev/null +++ b/ObjectService/wwwroot/css/manage.css @@ -0,0 +1,321 @@ +/* ============================================================ + Management pages – shared styles + ============================================================ */ + +/* ── Layout container ── */ +.admin-page-container { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +/* ── Page header ── */ +.admin-page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.admin-page-header h1 { + margin: 0; +} + +/* ── Filter panel ── */ +.filter-panel { + background: var(--color-surface); + border: 1px solid var(--color-muted); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.filters { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-end; +} + +.filter-group { + flex: 1; + min-width: 150px; + display: flex; + flex-direction: column; +} + +.filter-group label { + font-size: 0.875rem; + color: var(--color-muted); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.filter-group input, +.filter-group select { + padding: 0.5rem; + background: var(--color-background); + color: var(--color-contrast); + border: 1px solid var(--color-muted); + border-radius: 0.25rem; +} + +/* ── Alerts ── */ +.alert { + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.25rem; +} + +.alert-success { + background-color: rgba(78, 141, 87, 0.1); + color: #4e8d57; + border: 1px solid rgba(78, 141, 87, 0.3); +} + +.alert-error { + background-color: rgba(244, 70, 70, 0.1); + color: #f44646; + border: 1px solid rgba(244, 70, 70, 0.3); +} + +/* ── Table ── */ +.table-container { + background: var(--color-surface); + border: 1px solid var(--color-muted); + border-radius: 0.5rem; + overflow: hidden; + margin-bottom: 2rem; +} + +.result-count { + padding: 1rem; + margin: 0; + border-bottom: 1px solid var(--color-muted); + color: var(--color-muted); + font-size: 0.875rem; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table thead { + background: var(--color-background); + border-bottom: 2px solid var(--color-muted); +} + +.data-table th { + padding: 1rem; + text-align: left; + font-weight: 500; + color: var(--color-muted); +} + +.data-table td { + padding: 1rem; + border-bottom: 1px solid var(--color-muted); + word-break: break-word; +} + +.data-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.actions-column { + width: 200px; + text-align: right; +} + +.data-table .actions-column { + padding-right: 1rem; +} + +/* ── Empty state ── */ +.empty-state { + padding: 2rem; + text-align: center; + color: var(--color-muted); + margin: 0; +} + +.empty-state a { + color: var(--color-accent); +} + +/* ── Buttons ── */ +.button { + display: inline-block; + padding: 0.5rem 1rem; + background: var(--color-accent); + color: var(--color-background); + border: none; + border-radius: 0.25rem; + cursor: pointer; + text-decoration: none; + font-size: 0.875rem; + transition: opacity 0.2s; + font-weight: 500; +} + +.button:hover { + opacity: 0.8; +} + +.button-primary { + background: var(--color-accent); + color: var(--color-background); +} + +.button-secondary { + background: transparent; + color: var(--color-accent); + border: 1px solid var(--color-accent); +} + +.button-danger { + background: #f64646; + color: white; + border: none; +} + +.button-small { + padding: 0.25rem 0.75rem; + font-size: 0.75rem; +} + +/* ── Pagination ── */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + padding: 2rem 1rem; +} + +.page-info { + color: var(--color-muted); + margin: 0 1rem; + font-size: 0.875rem; +} + +/* ── Forms (Create / Edit) ── */ +.form { + background: var(--color-surface); + border: 1px solid var(--color-muted); + border-radius: 0.5rem; + padding: 2rem; + margin-bottom: 2rem; +} + +.form-section { + padding: 2rem; + border-bottom: 1px solid var(--color-muted); + border: none; + margin: 0; +} + +.form-section:last-of-type { + border-bottom: none; +} + +.form-section legend { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-accent); + padding: 0; + margin: 0 0 1.5rem 0; + width: 100%; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-contrast); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.75rem; + background: var(--color-background); + color: var(--color-contrast); + border: 1px solid var(--color-muted); + border-radius: 0.25rem; + font-family: inherit; + box-sizing: border-box; +} + +.form-group input:disabled { + background: rgba(128, 128, 128, 0.1); + cursor: not-allowed; +} + +.form-group textarea { + resize: vertical; + min-height: 120px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.form-row .form-group { + margin-bottom: 0; +} + +.form-hint { + font-size: 0.875rem; + color: var(--color-muted); + margin-top: 0.25rem; + margin-bottom: 0; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +/* Multi-select checklist */ +.multi-select { + background: var(--color-background); + border: 1px solid var(--color-muted); + border-radius: 0.25rem; + padding: 1rem; + max-height: 300px; + overflow-y: auto; + display: grid; + gap: 0.75rem; +} + +.multi-select.large { + max-height: 500px; +} + +.checkbox { + display: flex; + align-items: center; + cursor: pointer; + gap: 0.5rem; + font-size: 0.9rem; +} + +.checkbox input[type="checkbox"] { + width: auto; + margin: 0; + cursor: pointer; +} + +.checkbox span { + flex: 1; +} \ No newline at end of file diff --git a/ObjectService/wwwroot/css/object-explorer.css b/ObjectService/wwwroot/css/object-explorer.css index 6716945e..7ebb8d94 100644 --- a/ObjectService/wwwroot/css/object-explorer.css +++ b/ObjectService/wwwroot/css/object-explorer.css @@ -1,47 +1,5 @@ :root { color-scheme: light; - --bg: #fff6e0; - --bg-accent: #f6c945; - --bg-end: #d9d9d6; - --panel: rgba(255, 246, 224, 0.84); - --panel-strong: #fff6e0; - --panel-glass: rgba(255, 246, 224, 0.74); - --surface-raised: rgba(255, 246, 224, 0.68); - --surface-subtle: rgba(217, 217, 214, 0.58); - --surface-table: rgba(217, 217, 214, 0.52); - --border: rgba(46, 47, 51, 0.16); - --border-soft: rgba(46, 47, 51, 0.08); - --field-border: rgba(46, 47, 51, 0.18); - --field-bg: rgba(255, 246, 224, 0.9); - --text: #2e2f33; - --muted: #8a8d8f; - --accent: #f6c945; - --accent-strong: #2e2f33; - --accent-soft: rgba(246, 201, 69, 0.18); - --accent-soft-strong: rgba(246, 201, 69, 0.12); - --accent-border: rgba(246, 201, 69, 0.32); - --accent-border-strong: rgba(246, 201, 69, 0.4); - --success: #4e8d57; - --success-soft: rgba(78, 141, 87, 0.14); - --success-border: rgba(78, 141, 87, 0.24); - --warning: #b97a1f; - --warning-soft: rgba(185, 122, 31, 0.14); - --warning-border: rgba(185, 122, 31, 0.24); - --error: #b44c43; - --error-soft: rgba(180, 76, 67, 0.12); - --error-border: rgba(180, 76, 67, 0.24); - --danger: #b44c43; - --danger-soft: rgba(180, 76, 67, 0.12); - --danger-border: rgba(180, 76, 67, 0.24); - --disabled-bg: rgba(217, 217, 214, 0.42); - --disabled-border: rgba(138, 141, 143, 0.22); - --button-primary-text: #2e2f33; - --page-glow-accent: rgba(246, 201, 69, 0.24); - --page-glow-warning: rgba(217, 217, 214, 0.32); - --image-grid-accent: rgba(246, 201, 69, 0.08); - --image-grid-warning: rgba(138, 141, 143, 0.08); - --image-shadow: rgba(46, 47, 51, 0.18); - --shadow: 0 18px 48px rgba(46, 47, 51, 0.14); --radius: 22px; --mono: "Cascadia Code", "Consolas", monospace; --sans: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif; @@ -49,48 +7,6 @@ html[data-theme="dark"] { color-scheme: dark; - --bg: #2e2f33; - --bg-accent: #8a8d8f; - --bg-end: #2e2f33; - --panel: rgba(46, 47, 51, 0.84); - --panel-strong: rgba(46, 47, 51, 0.96); - --panel-glass: rgba(46, 47, 51, 0.78); - --surface-raised: rgba(138, 141, 143, 0.18); - --surface-subtle: rgba(138, 141, 143, 0.12); - --surface-table: rgba(138, 141, 143, 0.16); - --border: rgba(217, 217, 214, 0.18); - --border-soft: rgba(217, 217, 214, 0.1); - --field-border: rgba(217, 217, 214, 0.22); - --field-bg: rgba(46, 47, 51, 0.94); - --text: #fff6e0; - --muted: #d9d9d6; - --accent: #f6c945; - --accent-strong: #fff6e0; - --accent-soft: rgba(246, 201, 69, 0.2); - --accent-soft-strong: rgba(246, 201, 69, 0.14); - --accent-border: rgba(246, 201, 69, 0.32); - --accent-border-strong: rgba(246, 201, 69, 0.4); - --success: #78c087; - --success-soft: rgba(120, 192, 135, 0.18); - --success-border: rgba(120, 192, 135, 0.3); - --warning: #f0b85f; - --warning-soft: rgba(240, 184, 95, 0.14); - --warning-border: rgba(240, 184, 95, 0.26); - --error: #e58f87; - --error-soft: rgba(229, 143, 135, 0.16); - --error-border: rgba(229, 143, 135, 0.28); - --danger: #e58f87; - --danger-soft: rgba(229, 143, 135, 0.16); - --danger-border: rgba(229, 143, 135, 0.28); - --disabled-bg: rgba(138, 141, 143, 0.12); - --disabled-border: rgba(138, 141, 143, 0.22); - --button-primary-text: #2e2f33; - --page-glow-accent: rgba(246, 201, 69, 0.18); - --page-glow-warning: rgba(217, 217, 214, 0.08); - --image-grid-accent: rgba(246, 201, 69, 0.08); - --image-grid-warning: rgba(217, 217, 214, 0.06); - --image-shadow: rgba(0, 0, 0, 0.32); - --shadow: 0 18px 48px rgba(0, 0, 0, 0.28); } * { @@ -134,7 +50,7 @@ a { } .site-shell { - max-width: 1400px; + width: 100%; margin: 0 auto; padding: 24px; } @@ -261,6 +177,20 @@ a { border-radius: var(--radius); box-shadow: var(--shadow); backdrop-filter: blur(10px); + display: block; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: + transform 180ms ease, + box-shadow 180ms ease, + border-color 180ms ease; +} + +.object-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); + border-color: var(--accent-border); } .detail-hero { @@ -363,6 +293,22 @@ dt { padding: 24px; } +.dev-stats { + display: grid; + gap: 16px; +} + +.dev-stats-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.dev-stats-header .eyebrow { + margin: 0; +} + .theme-studio { display: grid; gap: 20px; @@ -429,6 +375,49 @@ dt { box-shadow: 0 0 0 3px var(--danger-soft); } +/* ── Variant swatch preview ── */ +.variant-swatches { + display: flex; + gap: 4px; + margin-top: 8px; + padding: 0 2px; +} + +.variant-swatch { + flex: 1; + height: 18px; + border-radius: 6px; + border: 1px solid var(--border-soft); + position: relative; + transition: transform 120ms ease; +} + +.variant-swatch:hover { + transform: scaleY(1.5) translateY(-1px); + z-index: 1; +} + +.variant-swatch-label { + display: none; + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: var(--panel-strong); + color: var(--text); + font-size: 0.72rem; + font-weight: 600; + padding: 2px 7px; + border-radius: 5px; + white-space: nowrap; + pointer-events: none; + border: 1px solid var(--border); +} + +.variant-swatch:hover .variant-swatch-label { + display: block; +} + .theme-studio-status { margin: 0; } @@ -505,8 +494,23 @@ dt { padding: 22px; } +.card-img-wrap { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + background: var(--surface-subtle); + border: 1px solid var(--border-soft); +} + +.card-img { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; +} + .card-header, -.card-actions, .detail-actions, .pagination, .chip-row { @@ -589,6 +593,34 @@ dd { word-break: break-word; } +/* ---- card icon row ------------------------------------------- */ +.card-icons { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 8px 6px; + border-top: 1px solid var(--border-soft); +} + +.card-icon { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); +} + +.card-icon svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.card-icon-dimmed { + opacity: 0.45; +} + .empty-state { text-align: center; padding: 48px 24px; @@ -603,6 +635,211 @@ dd { font-weight: 600; color: var(--muted); } +/* ========================================================================== + Master tabs (primary category navigation) + ========================================================================== */ + +.master-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 24px; +} + +.master-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 24px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-raised); + font-weight: 700; + font-size: 1rem; + color: var(--muted); + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.master-tab:hover { + background: var(--accent-soft); + color: var(--accent-strong); + transform: translateY(-1px); +} + +.master-tab-active { + background: var(--accent); + color: var(--button-primary-text); + border-color: transparent; +} + +.master-tab-active:hover { + background: var(--accent); + color: var(--button-primary-text); +} + +.master-tab-separator { + display: inline-flex; + align-self: center; + width: 1px; + height: 28px; + margin: 0 8px; + background: var(--border); + border-radius: 1px; +} + +/* ========================================================================== + Master-detail layout (sidebar + content) + ========================================================================== */ + +.master-detail-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.object-type-sidebar { + position: sticky; + top: 24px; + max-height: calc(100vh - 48px); + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.sidebar-title { + margin: 0 0 14px; + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.object-type-list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.object-type-link { + display: block; + padding: 8px 14px; + border-radius: 10px; + font-size: 0.92rem; + font-weight: 500; + color: var(--text); + transition: + background-color 120ms ease, + color 120ms ease; +} + +.object-type-link:hover { + background: var(--surface-subtle); + color: var(--accent-strong); +} + +.object-type-link-active { + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 700; +} + +/* Treeview sub-list for Vehicle subtypes ─────────────────────────────── */ + +.object-type-sub-list { + margin: 2px 0 4px 12px; + padding: 0 0 0 14px; + border-left: 2px solid var(--border); + list-style: none; +} + +.object-type-sub-list .object-type-link { + font-size: 0.84rem; + padding: 5px 12px; +} + +/* Detail area inside master-detail */ +.detail-area { + display: grid; + gap: 24px; + min-width: 0; +} + +.filter-panel { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.25rem 1.5rem; + margin-bottom: 1.5rem; +} + +.filters { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-end; +} + +.filter-group { + flex: 1; + min-width: 150px; + display: flex; + flex-direction: column; +} + +.filter-group label { + font-size: 0.875rem; + color: var(--muted); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.filter-group input, +.filter-group select { + padding: 0.5rem 0.75rem; + background: var(--field-bg); + color: var(--text); + border: 1.5px solid var(--field-border); + border-radius: 0.5rem; + font-size: 0.95rem; +} + +/* ========================================================================== + Responsive: master-detail + ========================================================================== */ + +@media (max-width: 900px) { + .master-detail-layout { + grid-template-columns: 1fr; + } + + .object-type-sidebar { + position: static; + max-height: none; + } + + .object-type-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 4px; + } + + .object-type-link { + text-align: center; + } +} .back-link { display: inline-flex; @@ -617,6 +854,12 @@ dd { gap: 20px; } +.detail-grid-4col { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; +} + .panel-span { grid-column: 1 / -1; } @@ -843,3 +1086,143 @@ th { min-width: 640px; } } + +/* ========================================================================== + Auth / Account pages + ========================================================================== */ + +.auth-panel { + max-width: 520px; + margin-left: auto; + margin-right: auto; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.auth-form .field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-form .field span { + font-size: 0.9rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.auth-form input { + background: var(--field-bg); + border: 1.5px solid var(--field-border); + border-radius: 12px; + padding: 12px 16px; + font-size: 1rem; + color: var(--text); + outline: none; + transition: border-color 0.15s; +} + +.auth-form input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.auth-form .form-actions { + margin-top: 8px; +} + +.validation-summary { + background: var(--error-soft); + border: 1px solid var(--error-border); + border-radius: 12px; + padding: 14px 18px; + margin-bottom: 20px; + color: var(--error); + font-size: 0.95rem; +} + +.validation-summary ul { + margin: 0; + padding-left: 20px; +} + +.success-message { + background: var(--success-soft); + border: 1px solid var(--success-border); + border-radius: 12px; + padding: 18px 22px; + text-align: center; +} + +.success-message p { + margin: 0 0 14px; + color: var(--success); + font-weight: 500; +} + +.auth-alt { + text-align: center; + margin-top: 22px; + font-size: 0.95rem; +} + +.auth-alt a { + color: var(--accent); + font-weight: 600; +} + +/* Danger button */ +.button-danger { + background: var(--danger) !important; + border-color: var(--danger-border) !important; + color: #fff !important; +} + +.button-danger:hover { + background: var(--danger) !important; + filter: brightness(1.15); +} + +/* Danger panel */ +.panel-danger { + border-color: var(--danger-border); +} + +.panel-danger h2 { + color: var(--danger); +} + +/* Meta grid (for account details) */ +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 18px; +} + +.meta-grid div { + padding: 12px 16px; + background: var(--surface-subtle); + border-radius: 12px; +} + +.meta-grid dt { + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} + +.meta-grid dd { + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0; +} diff --git a/ObjectService/wwwroot/js/theme-toggle.js b/ObjectService/wwwroot/js/theme-toggle.js index 63da0ebe..188e13af 100644 --- a/ObjectService/wwwroot/js/theme-toggle.js +++ b/ObjectService/wwwroot/js/theme-toggle.js @@ -65,12 +65,8 @@ const applyTheme = (theme) => { root.dataset.theme = theme; - const storedPalette = getStoredPalette(); - if (storedPalette) { - themeTools.applyPalette(root, theme, storedPalette); - } else { - themeTools.clearPalette(root); - } + const palette = getStoredPalette() ?? themeTools.defaultPalette; + themeTools.applyPalette(root, theme, palette); if (!toggle) { return; @@ -114,6 +110,39 @@ field.picker.value = value; field.input.classList.remove("palette-input-invalid"); } + + updateVariantSwatches(palette); + }; + + const updateVariantSwatches = (palette) => { + for (const key of themeTools.paletteFields) { + const container = paletteForm?.querySelector( + `[data-variant-swatches="${key}"]`, + ); + if (!container) { + continue; + } + + const variants = themeTools.generateVariants(palette[key]); + + // Build swatch labels: [dark→bright, …, dark→dark] + // In light mode, positions 0=darkest, 4=lightest + // In dark mode, positions flip: 0=lightest (flipped from 4), 4=darkest (flipped from 0) + const lightLabels = ["Mid–Dk", "Mid–", "Mid", "Mid+", "Mid++"]; + const darkLabels = ["Mid++", "Mid+", "Mid", "Mid–", "Mid–Dk"]; + + container.innerHTML = variants + .map((hex, i) => { + const lightLabel = i === 2 ? "Both" : lightLabels[i]; + const darkLabel = i === 2 ? "Both" : darkLabels[i]; + return ( + `` + + `Light: ${lightLabel}
Dark: ${darkLabel}
${hex}
` + + `
` + ); + }) + .join(""); + } }; const readPaletteFromInputs = () => { @@ -261,9 +290,9 @@ paletteResetButton?.addEventListener("click", () => { clearStoredPalette(); - themeTools.clearPalette(root); + const theme = root.dataset.theme || resolveTheme(); + themeTools.applyPalette(root, theme, themeTools.defaultPalette); setPaletteInputs(themeTools.defaultPalette); - applyTheme(root.dataset.theme || resolveTheme()); setPaletteStatus("Reverted to the default palette."); }); })(); diff --git a/Tests/ObjectServiceIntegrationTests/Routes/SC5FilePackRoutesTests.cs b/Tests/ObjectServiceIntegrationTests/Routes/SC5FilePackRoutesTests.cs index 5e06bc97..a1d4a3e5 100644 --- a/Tests/ObjectServiceIntegrationTests/Routes/SC5FilePackRoutesTests.cs +++ b/Tests/ObjectServiceIntegrationTests/Routes/SC5FilePackRoutesTests.cs @@ -101,7 +101,7 @@ public override async Task PostAsync() using var response = await HttpClient!.PostAsJsonAsync($"{RoutesV2.Prefix}{BaseRoute}", request); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created)); } [Test] diff --git a/Tests/ObjectServiceIntegrationTests/Routes/ScenarioRoutesTest.cs b/Tests/ObjectServiceIntegrationTests/Routes/ScenarioRoutesTest.cs index af4c63d1..7d986f51 100644 --- a/Tests/ObjectServiceIntegrationTests/Routes/ScenarioRoutesTest.cs +++ b/Tests/ObjectServiceIntegrationTests/Routes/ScenarioRoutesTest.cs @@ -33,7 +33,7 @@ protected override async Task SeedDataCoreAsync(LocoDbContext db) var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory)) { - Directory.CreateDirectory(directory); + _ = Directory.CreateDirectory(directory); } await File.WriteAllBytesAsync(fullPath, bytes); diff --git a/Tests/ObjectServiceIntegrationTests/TestWebApplicationFactory.cs b/Tests/ObjectServiceIntegrationTests/TestWebApplicationFactory.cs index 30ff0129..d73319dd 100644 --- a/Tests/ObjectServiceIntegrationTests/TestWebApplicationFactory.cs +++ b/Tests/ObjectServiceIntegrationTests/TestWebApplicationFactory.cs @@ -10,107 +10,78 @@ namespace ObjectService.Tests.Integration; public class TestWebApplicationFactory - : WebApplicationFactory where TProgram : class +: WebApplicationFactory where TProgram : class { - static DirectoryInfo? MakeServerFolderManagerTestDirectories() - { - // parent dir - var testDirectory = Directory.CreateTempSubdirectory("ObjectServiceTest"); - - // sub dirs - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects//Original")); - - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original//GoG")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original//Steam")); - - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Landscapes")); - - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Graphics//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Graphics//Original")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Music//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Music//Original")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//SoundEffects//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//SoundEffects//Original")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Tutorials//Custom")); - _ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Tutorials//Original")); - - return testDirectory; - } - - static void CreateDummyPaletteFile(string path) - { - // Create a 16x16 pixel PNG file for testing (palette map expects 16x16) - using var image = new SixLabors.ImageSharp.Image(16, 16); - using var stream = File.Create(path); - image.Save(stream, new SixLabors.ImageSharp.Formats.Png.PngEncoder()); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - var testFolder = TestWebApplicationFactory.MakeServerFolderManagerTestDirectories(); - ArgumentNullException.ThrowIfNull(testFolder, nameof(testFolder)); - - // Create a dummy palette file for testing - var dummyPaletteFile = Path.Combine(testFolder.FullName, "palette.png"); - CreateDummyPaletteFile(dummyPaletteFile); - - var testConfigurationBuilder = - new ConfigurationBuilder() - .AddInMemoryCollection( - [ - new("ObjectService:RootFolder", testFolder.FullName), - new("ObjectService:PaletteMapFile", dummyPaletteFile), - new("ObjectService:ShowScalar", "False"), - ]) - .Build(); - - _ = builder - .UseConfiguration(testConfigurationBuilder) - .ConfigureServices(services => - { - // Remove the app's original DbContext registration - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); - - if (descriptor != null) - { - _ = services.Remove(descriptor); - } - - // Create a SQLite in-memory connection - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - // Add DbContext using an in-memory SQLite database - _ = services.AddDbContext(options => _ = options.UseSqlite(connection)); - - // Configure logging for tests --- - _ = services.AddLogging(loggingBuilder => - { - // Clear all existing providers (like console, debug, etc.) - _ = loggingBuilder.ClearProviders(); +static DirectoryInfo? MakeServerFolderManagerTestDirectories() +{ +var testDirectory = Directory.CreateTempSubdirectory("ObjectServiceTest"); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Objects//Original")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original//GoG")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Scenarios//Original//Steam")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "Landscapes")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Graphics//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Graphics//Original")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Music//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Music//Original")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//SoundEffects//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//SoundEffects//Original")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Tutorials//Custom")); +_ = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, "GameData//Tutorials//Original")); +return testDirectory; +} - // Optional: Add a specific provider if you want some logs, e.g., to a test output helper +static void CreateDummyPaletteFile(string path) +{ +using var image = new SixLabors.ImageSharp.Image(16, 16); +using var stream = File.Create(path); +image.Save(stream, new SixLabors.ImageSharp.Formats.Png.PngEncoder()); +} - _ = loggingBuilder.SetMinimumLevel(LogLevel.Critical); // Only log critical errors - }); +protected override void ConfigureWebHost(IWebHostBuilder builder) +{ +var testFolder = MakeServerFolderManagerTestDirectories(); +ArgumentNullException.ThrowIfNull(testFolder, nameof(testFolder)); +var dummyPaletteFile = Path.Combine(testFolder.FullName, "palette.png"); +CreateDummyPaletteFile(dummyPaletteFile); + +var testConfigurationBuilder = new ConfigurationBuilder() +.AddInMemoryCollection([ +new("ObjectService:RootFolder", testFolder.FullName), +new("ObjectService:PaletteMapFile", dummyPaletteFile), +new("ObjectService:ShowScalar", "False"), +new("ObjectService:DisableAuthentication", "True"), +new("ObjectService:EnableWriteRoutes", "True"), +]) +.Build(); + +_ = builder +.UseConfiguration(testConfigurationBuilder) +.ConfigureServices(services => +{ +var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); +if (descriptor != null) +_ = services.Remove(descriptor); - // Build the service provider. - var sp = services.BuildServiceProvider(); +var connection = new SqliteConnection("DataSource=:memory:"); +connection.Open(); +_ = services.AddDbContext(options => _ = options.UseSqlite(connection)); - // Create a scope to obtain a reference to the database contexts - using (var scope = sp.CreateScope()) - { - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); - _ = db.Database.EnsureCreated(); // Ensure the database is created for each test run - } - }); - } +_ = services.AddLogging(loggingBuilder => +{ +_ = loggingBuilder.ClearProviders(); +_ = loggingBuilder.SetMinimumLevel(LogLevel.Critical); +}); + +var sp = services.BuildServiceProvider(); +using var scope = sp.CreateScope(); +var db = scope.ServiceProvider.GetRequiredService(); +_ = db.Database.EnsureCreated(); +}); } +} \ No newline at end of file diff --git a/Tests/RouteHelpersTests.cs b/Tests/RouteHelpersTests.cs index 7b1e3c4d..73bd6268 100644 --- a/Tests/RouteHelpersTests.cs +++ b/Tests/RouteHelpersTests.cs @@ -13,7 +13,7 @@ public void TryGetSafeRelativePathUnderRoot_ReturnsTrue_ForValidRelativePath() try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, "packs/object-pack.zip", out var fullPath, out var normalizedRelativePath); @@ -41,7 +41,7 @@ public void TryGetSafeRelativePathUnderRoot_ReturnsFalse_ForUnsafeRelativePath(s try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, relativePath, out var fullPath, out var normalizedRelativePath); @@ -65,7 +65,7 @@ public void TryGetSafeRelativePathUnderRoot_ReturnsFalse_ForAbsolutePath() try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var absolutePath = Path.Combine(rootPath, "packs", "object-pack.zip"); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, absolutePath, out var fullPath, out var normalizedRelativePath); @@ -100,7 +100,7 @@ public void TryGetSafeRelativePathUnderRoot_ReturnsFalse_ForTraversalAttempts(st try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, relativePath, out var fullPath, out var normalizedRelativePath); @@ -130,7 +130,7 @@ public void TryGetSafeRelativePathUnderRoot_NormalizesSeparators(string input, s try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, input, out var fullPath, out var normalizedRelativePath); @@ -154,7 +154,7 @@ public void TryGetSafeRelativePathUnderRoot_ReturnsFalse_ForUncPath() try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, @"\\server\share\file.dat", out var fullPath, out var normalizedRelativePath); @@ -178,7 +178,7 @@ public void TryGetSafeRelativePathUnderRoot_FullPathStaysUnderRoot() try { - Directory.CreateDirectory(rootPath); + _ = Directory.CreateDirectory(rootPath); var result = RouteHelpers.TryGetSafeRelativePathUnderRoot(rootPath, "subfolder/file.dat", out var fullPath, out _);