diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml index 2782b78..cf2e603 100644 --- a/.github/workflows/release-zip.yml +++ b/.github/workflows/release-zip.yml @@ -122,6 +122,12 @@ jobs: if: steps.api_check.outputs.changed == 'true' run: | dotnet restore StarMap.Index.API/StarMap.Index.API.csproj - dotnet pack StarMap.Index.API/StarMap.Index.API.csproj -c Release -o ./nupkg /p:PackageVersion=${{ steps.version.outputs.new }} + dotnet pack StarMap.Index.API/StarMap.Index.API.csproj \ + -c Release \ + -o ./nupkg \ + /p:PackageVersion=${{ steps.version.outputs.new }} \ + /p:Version=${{ steps.version.outputs.new }} \ + /p:AssemblyVersion=${{ steps.version.outputs.new }} \ + /p:FileVersion=${{ steps.version.outputs.new }} dotnet nuget add source --username "${{ github.actor }}" --password "${{ secrets.GITHUB_TOKEN }}" --store-password-in-clear-text --name github "${{ env.NUGET_SOURCE }}" dotnet nuget push ./nupkg/*.nupkg --source github --api-key "${{ secrets.GITHUB_TOKEN }}" --skip-duplicate diff --git a/StarMap.Index.API/ModRepository.proto b/StarMap.Index.API/ModRepository.proto index 3b897fa..eb3290d 100644 --- a/StarMap.Index.API/ModRepository.proto +++ b/StarMap.Index.API/ModRepository.proto @@ -5,10 +5,12 @@ package StarMap.Index.API; service ModRepositoryService { rpc GetMods (GetModsRequest) returns (GetModsResponse); rpc GetModDetails (GetModDetailsRequest) returns (GetModDetailsResponse); - rpc GetModDownloadLocation (GetModDownloadLocationRequest) returns (GetModDownloadLocationResponse); } -message GetModsRequest {} +message GetModsRequest +{ + string search = 1; +} message GetModsResponse { repeated Mod mods = 1; @@ -25,7 +27,8 @@ message GetModDetailsResponse message GetModDownloadLocationRequest { - Mod mod = 1; + string mod_id = 1; + string version_id = 2; } message GetModDownloadLocationResponse @@ -36,13 +39,19 @@ message GetModDownloadLocationResponse message Mod { string id = 1; string name = 2; - string version = 3; string author = 4; } message ModDetails { Mod mod = 1; - repeated string versions = 2; + repeated ModVersion versions = 2; string description = 3; +} + +message ModVersion +{ + string id = 1; + string version = 2; + string download_location = 3; } \ No newline at end of file diff --git a/StarMap.Index.API/ModRepositoryClient.cs b/StarMap.Index.API/ModRepositoryClient.cs index 0ef4388..37c354f 100644 --- a/StarMap.Index.API/ModRepositoryClient.cs +++ b/StarMap.Index.API/ModRepositoryClient.cs @@ -15,14 +15,13 @@ public interface IModRespositoryClient : IDisposable { Task GetMods(); Task GetModDetails(Guid id); - Task GetModDownloadLocation(Mod mod); } public class ModRepositoryClient : IModRespositoryClient { private GrpcChannel _channel; private ModRepositoryService.ModRepositoryServiceClient _client; - //Force build + public ModRepositoryClient(string repositoryUrl) { _channel = GrpcChannel.ForAddress(repositoryUrl); @@ -46,11 +45,6 @@ public async Task GetMods() return response.Mod; } - public Task GetModDownloadLocation(Mod mod) - { - return Task.FromResult(""); - } - public void Dispose() { _channel.Dispose(); diff --git a/StarMap.Index/DB.cs b/StarMap.Index/DB.cs index 9963c06..494f7f6 100644 --- a/StarMap.Index/DB.cs +++ b/StarMap.Index/DB.cs @@ -14,10 +14,8 @@ public AppDbContext(DbContextOptions opts) : base(opts) { } protected override void OnModelCreating(ModelBuilder mb) { mb.Entity().HasIndex(u => u.GithubId).IsUnique(); - mb.Entity().HasIndex(u => u.Id).IsUnique(); mb.Entity().HasKey(u => u.Id); - mb.Entity().HasIndex(m => m.Id).IsUnique(); mb.Entity().HasKey(m => m.Id); mb.Entity() @@ -28,9 +26,16 @@ protected override void OnModelCreating(ModelBuilder mb) mb.Entity() .HasOne(m => m.LatestVersion) .WithMany() - .HasForeignKey(m => m.LatestVersionId); + .HasForeignKey(m => m.LatestVersionId) + .OnDelete(DeleteBehavior.Restrict); mb.Entity().HasKey(v => v.Id); + mb.Entity().HasIndex(v => v.ModId); + mb.Entity() + .HasOne(v => v.Mod) + .WithMany() + .HasForeignKey(v => v.ModId) + .OnDelete(DeleteBehavior.Cascade); } } @@ -57,8 +62,7 @@ public class Mod public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public Guid? LatestVersionId { get; set; } - public ModVersion? LatestVersion { get; set; } = null!; - public List Versions { get; set; } = []; + public ModVersion? LatestVersion { get; set; } } public class ModVersion @@ -67,9 +71,18 @@ public class ModVersion public string Version { get; set; } = ""; public string DownloadUrl { get; set; } = ""; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public Guid ModId { get; set; } + public Mod Mod { get; set; } = null!; + } + + public class AddVersionRequestBody + { + public string Version { get; set; } = ""; + public string DownloadUrl { get; set; } = ""; } - public static class CryptoHelpers + public static class CryptoHelpers { public static string GenerateApiKey(int bytes = 32) { diff --git a/StarMap.Index/Endpoints/ModEndpoints.cs b/StarMap.Index/Endpoints/ModEndpoints.cs index bbb35ce..764dea5 100644 --- a/StarMap.Index/Endpoints/ModEndpoints.cs +++ b/StarMap.Index/Endpoints/ModEndpoints.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Text.Json; namespace StarMapIndex.Endpoints { @@ -72,53 +74,99 @@ public static void MapModEndpoints(this IEndpointRouteBuilder app) // Show the API key once var html = $@" - - ← Back -

Mod created: {System.Net.WebUtility.HtmlEncode(name)}

-

Save this API key now — you'll only see it once:

-
{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}
-

API Key ID (useful in logs): {System.Net.WebUtility.HtmlEncode(apiKeyId)}

-

To publish: POST to /api/publish with header X-Api-Key: <your key> and JSON body containing name, version, downloadUrl.

-"; + + ← Back +

Mod created: {System.Net.WebUtility.HtmlEncode(name)}

+

Mod id: {System.Net.WebUtility.HtmlEncode(mod.Id.ToString())}

+

Save this API key now — you'll only see it once:

+
{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}
+

API Key ID (useful in logs): {System.Net.WebUtility.HtmlEncode(apiKeyId)}

+

To publish: POST to /api/publish with header X-Api-Key: <your key> and JSON body containing name, version, downloadUrl.

+ "; http.Response.ContentType = "text/html; charset=utf-8"; await http.Response.WriteAsync(html); }); - // Show single mod manage page (owner only) - app.MapGet("/mods/{id:int}", [Authorize] async (int id, HttpContext http, AppDbContext db) => + app.MapGet("/mods/{id:minlength(36)}", [Authorize] async (string id, HttpContext http, AppDbContext db) => { var userId = Guid.Parse(http.User.FindFirst(ClaimTypes.NameIdentifier)!.Value); - var mod = await db.Mods.FindAsync(id); - if (mod == null || mod.AuthorId != userId) + var mod = await db.Mods.FindAsync(Guid.Parse(id)); + if (mod == null) { http.Response.StatusCode = 404; await http.Response.WriteAsync("Not found or not permitted"); return; } + var versions = await db.Versions + .Where(v => v.ModId == mod.Id) + .OrderByDescending(v => v.CreatedAt) + .ToListAsync(); + var html = $@" - - ← Back -

{System.Net.WebUtility.HtmlEncode(mod.Name)}

-

Description: {System.Net.WebUtility.HtmlEncode(mod.Description)}

-

API Key ID: {System.Net.WebUtility.HtmlEncode(mod.ApiKeyId)}

-

Versions

-
    {string.Join("", (await db.Versions.Where(v => v.Id == mod.Id).OrderByDescending(v => v.Id).ToListAsync()).Select(v => $"
  • {System.Net.WebUtility.HtmlEncode(v.Version)} — download — {v.CreatedAt:O}
  • "))}
-
-

Rotate API key

-
- -
-"; + + ← Back +

{System.Net.WebUtility.HtmlEncode(mod.Name)}

+

Description: {System.Net.WebUtility.HtmlEncode(mod.Description)}

+

API Key ID: {System.Net.WebUtility.HtmlEncode(mod.ApiKeyId)}

+

Versions

+
    {string.Join("", (versions).Select(v => $"
  • {System.Net.WebUtility.HtmlEncode(v.Version)} — download — {v.CreatedAt:O}
  • "))}
+
+

Rotate API key

+
+ +
+
+

Danger zone!

+ + + + "; http.Response.ContentType = "text/html; charset=utf-8"; await http.Response.WriteAsync(html); }); - // rotate API key (show new once) - app.MapPost("/mods/{id:int}/rotate", [Authorize] async (int id, HttpContext http, AppDbContext db) => + app.MapDelete("/mods/{id:minlength(36)}", [Authorize] async (string id, HttpContext http, AppDbContext db) => { var userId = Guid.Parse(http.User.FindFirst(ClaimTypes.NameIdentifier)!.Value); - var mod = await db.Mods.FindAsync(id); + var mod = await db.Mods.FindAsync(Guid.Parse(id)); + + if (mod == null) + { + http.Response.StatusCode = 404; + await http.Response.WriteAsync("Not found"); + return; + } + + if (mod.AuthorId != userId) + { + http.Response.StatusCode = 403; + await http.Response.WriteAsync("Forbidden"); + return; + } + + db.Mods.Remove(mod); + await db.SaveChangesAsync(); + + http.Response.StatusCode = 200; + }); + + app.MapPost("/mods/{id:minlength(36)}/rotate", [Authorize] async (string id, HttpContext http, AppDbContext db) => + { + var userId = Guid.Parse(http.User.FindFirst(ClaimTypes.NameIdentifier)!.Value); + var mod = await db.Mods.FindAsync(Guid.Parse(id)); if (mod == null || mod.AuthorId != userId) { http.Response.StatusCode = 404; @@ -132,15 +180,73 @@ public static void MapModEndpoints(this IEndpointRouteBuilder app) await db.SaveChangesAsync(); var html = $@" - - ← Back -

New API key for {System.Net.WebUtility.HtmlEncode(mod.Name)}

-

Save this key now — it won't be shown again:

-
{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}
-"; + + ← Back +

New API key for {System.Net.WebUtility.HtmlEncode(mod.Name)}

+

Save this key now — it won't be shown again:

+
{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}
+ "; http.Response.ContentType = "text/html; charset=utf-8"; await http.Response.WriteAsync(html); }); + + app.MapPost("/mods/{id:minlength(36)}/version", async (string id, HttpContext http, AppDbContext db) => + { + var mod = await db.Mods.FindAsync(Guid.Parse(id)); + + if (mod == null) + { + http.Response.StatusCode = 404; + await http.Response.WriteAsync("Not found"); + return; + } + + if (!http.Request.Headers.TryGetValue("X-Api-Key", out var providedApiKey) || providedApiKey.FirstOrDefault() is not string apiKey) + { + http.Response.StatusCode = 401; + await http.Response.WriteAsync("Missing or malformed API key"); + return; + } + var providedApiKeyHash = CryptoHelpers.HashString(apiKey); + + if (providedApiKeyHash != mod.ApiKeyHash) + { + http.Response.StatusCode = 403; + await http.Response.WriteAsync("Invalid API key"); + return; + } + + var newVersion = JsonSerializer.Deserialize(await new StreamReader(http.Request.Body).ReadToEndAsync()); + + if (newVersion == null || string.IsNullOrEmpty(newVersion.Version) || string.IsNullOrEmpty(newVersion.DownloadUrl)) + { + http.Response.StatusCode = 400; + await http.Response.WriteAsync("Invalid request body"); + return; + } + + await db.Entry(mod).Reference(m => m.LatestVersion).LoadAsync(); + + if (!Version.TryParse(newVersion.Version, out var parsedNewVersion) || (mod.LatestVersion is not null && Version.TryParse(mod.LatestVersion.Version, out var lastVersion) && parsedNewVersion <= lastVersion)) + { + http.Response.StatusCode = 400; + await http.Response.WriteAsync("New version cannot be a lower or equal version than last version"); + return; + } + + var version = new ModVersion + { + Mod = mod, + Version = newVersion.Version, + DownloadUrl = newVersion.DownloadUrl + }; + db.Versions.Add(version); + mod.LatestVersion = version; + await db.SaveChangesAsync(); + + http.Response.StatusCode = 200; + await http.Response.WriteAsync("Mod version updated successfully"); + }); } } } diff --git a/StarMap.Index/Endpoints/ModRepositoryApiEndpoints.cs b/StarMap.Index/Endpoints/ModRepositoryApiEndpoints.cs deleted file mode 100644 index e7e4e0e..0000000 --- a/StarMap.Index/Endpoints/ModRepositoryApiEndpoints.cs +++ /dev/null @@ -1,79 +0,0 @@ -/*using Microsoft.EntityFrameworkCore; -using System.Text.Json; - -namespace StarMapIndex.Endpoints -{ - public static class ModRepositoryApiEndpoints - { - public static void MapModRepositoryApiEndpoints(this IEndpointRouteBuilder app) - { - app.MapPost("/api/publish", async (HttpContext http, AppDbContext db) => - { - var apiKey = http.Request.Headers["X-Api-Key"].FirstOrDefault(); - var mod = await ValidateApiKeyAsync(apiKey ?? "", db); - if (mod == null) - { - http.Response.StatusCode = 401; - await http.Response.WriteAsync("Invalid API key"); - return; - } - - // read JSON body { name, version, downloadUrl } - var body = await new StreamReader(http.Request.Body).ReadToEndAsync(); - if (string.IsNullOrWhiteSpace(body)) - { - http.Response.StatusCode = 400; - await http.Response.WriteAsync("Empty body"); - return; - } - var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - var version = root.GetProperty("version").GetString() ?? ""; - var downloadUrl = root.GetProperty("downloadUrl").GetString() ?? ""; - var name = root.TryGetProperty("name", out var n) ? n.GetString() : mod.Name; - - if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(downloadUrl)) - { - http.Response.StatusCode = 400; - await http.Response.WriteAsync("Missing fields"); - return; - } - - var v = new ModVersion - { - ModId = mod.Id, - Version = version, - DownloadUrl = downloadUrl, - CreatedAt = DateTime.UtcNow - }; - db.Versions.Add(v); - await db.SaveChangesAsync(); - - http.Response.ContentType = "application/json"; - await http.Response.WriteAsync(JsonSerializer.Serialize(new { success = true, mod = mod.Name, version = version })); - }); - - app.MapGet("/api/mods", async (AppDbContext db) => - { - var list = await db.Mods.Select(m => new { m.Id, m.Name, m.Description }).ToListAsync(); - return Results.Json(list); - }); - - app.MapGet("/api/mods/{id:int}/versions", async (int id, AppDbContext db) => - { - var versions = await db.Versions.Where(v => v.ModId == id).OrderByDescending(v => v.CreatedAt) - .Select(v => new { v.Id, v.Version, v.DownloadUrl, v.CreatedAt }).ToListAsync(); - return Results.Json(versions); - }); - } - - static async Task ValidateApiKeyAsync(string apiKey, AppDbContext db) - { - if (string.IsNullOrWhiteSpace(apiKey)) return null; - var hash = CryptoHelpers.HashString(apiKey); - // find mod by hash. In production consider allowing multiple active keys per mod (key records). - return await db.Mods.FirstOrDefaultAsync(m => m.ApiKeyHash == hash); - } - } -} -*/ \ No newline at end of file diff --git a/StarMap.Index/Endpoints/RootEndpoints.cs b/StarMap.Index/Endpoints/RootEndpoints.cs index b23b5c6..3ce94af 100644 --- a/StarMap.Index/Endpoints/RootEndpoints.cs +++ b/StarMap.Index/Endpoints/RootEndpoints.cs @@ -7,7 +7,7 @@ public static void MapRootEndpoints(this IEndpointRouteBuilder app) app.MapGet("/", async (HttpContext http) => { var user = http.User?.Identity?.IsAuthenticated == true ? http.User.Identity.Name : null; - var loginUrl = "/signin"; // endpoint below starts the OAuth flow + var loginUrl = "/signin"; var logoutUrl = "/signout"; var html = $@" diff --git a/StarMap.Index/ModRepository/GrpcModRepository.cs b/StarMap.Index/ModRepository/GrpcModRepository.cs index bcbb391..1ae4a8e 100644 --- a/StarMap.Index/ModRepository/GrpcModRepository.cs +++ b/StarMap.Index/ModRepository/GrpcModRepository.cs @@ -18,21 +18,34 @@ public GrpcModRepository(AppDbContext db) public async override Task GetMods(GetModsRequest request, ServerCallContext serverCallContext) { - var mods = await _db.Mods.Select(mod => mod.ToModProto()).ToListAsync(); + var mods = await _db.Mods.Include(m => m.Author).Select(mod => mod.ToModProto()).ToListAsync(); var response = new GetModsResponse(); response.Mods.AddRange(mods); return response; } - public override Task GetModDetails(GetModDetailsRequest request, ServerCallContext context) + public async override Task GetModDetails(GetModDetailsRequest request, ServerCallContext context) { - return base.GetModDetails(request, context); - } + var mod = await _db.Mods.Include(m => m.Author).Where(mod => mod.Id == Guid.Parse(request.Id)).FirstOrDefaultAsync(); - public override Task GetModDownloadLocation(GetModDownloadLocationRequest request, ServerCallContext context) - { - return base.GetModDownloadLocation(request, context); + if (mod == null) + { + return new GetModDetailsResponse(); + } + + var versions = await _db.Versions + .Where(v => v.ModId == mod.Id) + .OrderByDescending(v => v.CreatedAt) + .ToListAsync(); + + var protoMod = mod.ToModDetailsProto(); + protoMod.Versions.AddRange(versions.Select(v => v.ToProto())); + + return new GetModDetailsResponse() + { + Mod = protoMod + }; } } } diff --git a/StarMap.Index/ModRepository/GrpcModRepositoryConvertor.cs b/StarMap.Index/ModRepository/GrpcModRepositoryConvertor.cs index ef50f0c..0336bb4 100644 --- a/StarMap.Index/ModRepository/GrpcModRepositoryConvertor.cs +++ b/StarMap.Index/ModRepository/GrpcModRepositoryConvertor.cs @@ -10,7 +10,7 @@ public static StarMap.Index.API.Mod ToModProto(this Mod mod) { Id = mod.Id.ToString(), Name = mod.Name, - Version = "", + Author = mod.Author.DisplayName }; } @@ -22,12 +22,22 @@ public static StarMap.Index.API.ModDetails ToModDetailsProto(this Mod mod) { Id = mod.Id.ToString(), Name = mod.Name, - Version = "", + Author = mod.Author.DisplayName }, Description = mod.Description ?? "" }; - modDetails.Versions.AddRange(mod.Versions.Select(v => v.Version)); + return modDetails; } + + public static StarMap.Index.API.ModVersion ToProto(this ModVersion version) + { + return new StarMap.Index.API.ModVersion + { + Id = version.Id.ToString(), + Version = version.Version, + DownloadLocation = version.DownloadUrl + }; + } } }