Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/release-zip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 14 additions & 5 deletions StarMap.Index.API/ModRepository.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +27,8 @@ message GetModDetailsResponse

message GetModDownloadLocationRequest
{
Mod mod = 1;
string mod_id = 1;
string version_id = 2;
}

message GetModDownloadLocationResponse
Expand All @@ -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;
}
8 changes: 1 addition & 7 deletions StarMap.Index.API/ModRepositoryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@ public interface IModRespositoryClient : IDisposable
{
Task<Mod[]> GetMods();
Task<ModDetails?> GetModDetails(Guid id);
Task<string> GetModDownloadLocation(Mod mod);
}

public class ModRepositoryClient : IModRespositoryClient
{
private GrpcChannel _channel;
private ModRepositoryService.ModRepositoryServiceClient _client;
//Force build

public ModRepositoryClient(string repositoryUrl)
{
_channel = GrpcChannel.ForAddress(repositoryUrl);
Expand All @@ -46,11 +45,6 @@ public async Task<Mod[]> GetMods()
return response.Mod;
}

public Task<string> GetModDownloadLocation(Mod mod)
{
return Task.FromResult("");
}

public void Dispose()
{
_channel.Dispose();
Expand Down
25 changes: 19 additions & 6 deletions StarMap.Index/DB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ public AppDbContext(DbContextOptions<AppDbContext> opts) : base(opts) { }
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<User>().HasIndex(u => u.GithubId).IsUnique();
mb.Entity<User>().HasIndex(u => u.Id).IsUnique();
mb.Entity<Mod>().HasKey(u => u.Id);

mb.Entity<Mod>().HasIndex(m => m.Id).IsUnique();
mb.Entity<Mod>().HasKey(m => m.Id);

mb.Entity<Mod>()
Expand All @@ -28,9 +26,16 @@ protected override void OnModelCreating(ModelBuilder mb)
mb.Entity<Mod>()
.HasOne(m => m.LatestVersion)
.WithMany()
.HasForeignKey(m => m.LatestVersionId);
.HasForeignKey(m => m.LatestVersionId)
.OnDelete(DeleteBehavior.Restrict);

mb.Entity<ModVersion>().HasKey(v => v.Id);
mb.Entity<ModVersion>().HasIndex(v => v.ModId);
mb.Entity<ModVersion>()
.HasOne(v => v.Mod)
.WithMany()
.HasForeignKey(v => v.ModId)
.OnDelete(DeleteBehavior.Cascade);
}
}

Expand All @@ -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<ModVersion> Versions { get; set; } = [];
public ModVersion? LatestVersion { get; set; }
}

public class ModVersion
Expand All @@ -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)
{
Expand Down
174 changes: 140 additions & 34 deletions StarMap.Index/Endpoints/ModEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -72,53 +74,99 @@ public static void MapModEndpoints(this IEndpointRouteBuilder app)

// Show the API key once
var html = $@"
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods'>← Back</a>
<h2>Mod created: {System.Net.WebUtility.HtmlEncode(name)}</h2>
<p><strong>Save this API key now — you'll only see it once:</strong></p>
<pre style='background:#f3f3f3;padding:8px;border-radius:4px'>{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}</pre>
<p>API Key ID (useful in logs): <code>{System.Net.WebUtility.HtmlEncode(apiKeyId)}</code></p>
<p>To publish: POST to <code>/api/publish</code> with header <code>X-Api-Key: &lt;your key&gt;</code> and JSON body containing <code>name</code>, <code>version</code>, <code>downloadUrl</code>.</p>
</body></html>";
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods'>← Back</a>
<h2>Mod created: {System.Net.WebUtility.HtmlEncode(name)}</h2>
<p> Mod id: {System.Net.WebUtility.HtmlEncode(mod.Id.ToString())}</code></p>
<p><strong>Save this API key now — you'll only see it once:</strong></p>
<pre style='background:#f3f3f3;padding:8px;border-radius:4px'>{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}</pre>
<p>API Key ID (useful in logs): <code>{System.Net.WebUtility.HtmlEncode(apiKeyId)}</code></p>
<p>To publish: POST to <code>/api/publish</code> with header <code>X-Api-Key: &lt;your key&gt;</code> and JSON body containing <code>name</code>, <code>version</code>, <code>downloadUrl</code>.</p>
</body></html>";
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 = $@"
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods'>← Back</a>
<h2>{System.Net.WebUtility.HtmlEncode(mod.Name)}</h2>
<p>Description: {System.Net.WebUtility.HtmlEncode(mod.Description)}</p>
<p>API Key ID: <code>{System.Net.WebUtility.HtmlEncode(mod.ApiKeyId)}</code></p>
<h3>Versions</h3>
<ul>{string.Join("", (await db.Versions.Where(v => v.Id == mod.Id).OrderByDescending(v => v.Id).ToListAsync()).Select(v => $"<li>{System.Net.WebUtility.HtmlEncode(v.Version)} — <a href=\"{System.Net.WebUtility.HtmlEncode(v.DownloadUrl)}\">download</a> — {v.CreatedAt:O}</li>"))}</ul>
<hr/>
<h3>Rotate API key</h3>
<form method='post' action='/mods/{mod.Id}/rotate'>
<button type='submit'>Rotate (create new API key)</button>
</form>
</body></html>";
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods'>← Back</a>
<h2>{System.Net.WebUtility.HtmlEncode(mod.Name)}</h2>
<p>Description: {System.Net.WebUtility.HtmlEncode(mod.Description)}</p>
<p>API Key ID: <code>{System.Net.WebUtility.HtmlEncode(mod.ApiKeyId)}</code></p>
<h3>Versions</h3>
<ul>{string.Join("", (versions).Select(v => $"<li>{System.Net.WebUtility.HtmlEncode(v.Version)} — <a href=\"{System.Net.WebUtility.HtmlEncode(v.DownloadUrl)}\">download</a> — {v.CreatedAt:O}</li>"))}</ul>
<hr/>
<h3>Rotate API key</h3>
<form method='post' action='/mods/{mod.Id}/rotate'>
<button type='submit'>Rotate (create new API key)</button>
</form>
<hr/>
<h3>Danger zone!</h3>
<button id=""delete-btn"" data-id=""{mod.Id}"">Delete mod</button>
</body>
<script>
document.getElementById(""delete-btn"").addEventListener(""click"", async (e) => {{
const id = e.target.dataset.id;

const response = await fetch(`/mods/{id}`, {{
method: ""DELETE"",
headers: {{
""Content-Type"": ""application/json""
}}
}});
window.location.href = ""/mods""
}});
</script>
</html>";
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;
Expand All @@ -132,15 +180,73 @@ public static void MapModEndpoints(this IEndpointRouteBuilder app)
await db.SaveChangesAsync();

var html = $@"
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods/{mod.Id}'>← Back</a>
<h2>New API key for {System.Net.WebUtility.HtmlEncode(mod.Name)}</h2>
<p>Save this key now — it won't be shown again:</p>
<pre style='background:#f3f3f3;padding:8px;border-radius:4px'>{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}</pre>
</body></html>";
<!doctype html><html><body style='font-family:Segoe UI,Arial;padding:16px;'>
<a href='/mods/{mod.Id}'>← Back</a>
<h2>New API key for {System.Net.WebUtility.HtmlEncode(mod.Name)}</h2>
<p>Save this key now — it won't be shown again:</p>
<pre style='background:#f3f3f3;padding:8px;border-radius:4px'>{System.Net.WebUtility.HtmlEncode(apiKeyPlain)}</pre>
</body></html>";
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<AddVersionRequestBody>(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");
});
}
}
}
Loading
Loading