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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ kubeconfig-*
# Claude Code local state
.claude/settings.local.json
.claude/scheduled_tasks.lock
cosign.key
25 changes: 14 additions & 11 deletions api/internal/handlers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,12 @@ func (h *registryHandler) resolve(ctx context.Context, ns, name, providerName st
return nil, "", "", errNoRegistry
}
p, ok := h.reg.For(ctx, registry.Config{
Provider: prov.provider,
Community: prov.community,
SteamAppID: prov.steamAppID,
GitHubOwner: prov.githubOwner,
GitHubRepo: prov.githubRepo,
Provider: prov.provider,
Community: prov.community,
SteamAppID: prov.steamAppID,
CurseForgeGameID: prov.curseforgeGameID,
GitHubOwner: prov.githubOwner,
GitHubRepo: prov.githubRepo,
})
if !ok {
return nil, "", "", errNoRegistry
Expand All @@ -254,12 +255,13 @@ func (h *registryHandler) writeResolveErr(w http.ResponseWriter, req *http.Reque
}

type providerCfg struct {
provider string
community string
steamAppID int32
githubOwner string
githubRepo string
modpacks *modpackCfg
provider string
community string
steamAppID int32
curseforgeGameID int32
githubOwner string
githubRepo string
modpacks *modpackCfg
}

type modpackCfg struct {
Expand Down Expand Up @@ -289,6 +291,7 @@ func registryProviders(tmpl *unstructured.Unstructured) []providerCfg {
cfg := providerCfg{provider: name}
cfg.community, _ = m["community"].(string)
cfg.steamAppID = nestedInt32(m["steamAppID"])
cfg.curseforgeGameID = nestedInt32(m["curseforgeGameID"])
if gh, ok := m["github"].(map[string]any); ok {
cfg.githubOwner, _ = gh["owner"].(string)
cfg.githubRepo, _ = gh["repo"].(string)
Expand Down
35 changes: 35 additions & 0 deletions api/internal/handlers/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,41 @@ func TestRegistrySearch_ThreadsSteamAppID(t *testing.T) {
}
}

// TestRegistrySearch_ThreadsCurseForgeGameID is the handler-level regression
// guard for the "ARK's mod browser shows Minecraft mods" bug:
// registryProviders() must read a template's declared curseforgeGameID and
// thread it through to registry.Config, never a hardcoded Minecraft id —
// mirrors TestRegistrySearch_ThreadsSteamAppID above.
func TestRegistrySearch_ThreadsCurseForgeGameID(t *testing.T) {
versions := []any{map[string]any{"id": "v", "loader": "", "gameVersion": "", "default": true}}
for _, tc := range []struct {
name string
gameID any // matches whatever type an unstructured numeric field may decode as
wantVal int32
}{
{"int64", int64(83374), 83374},
{"float64", float64(83374), 83374},
{"json.Number", json.Number("83374"), 83374},
} {
t.Run(tc.name, func(t *testing.T) {
k := fakeKubeClient(
newTemplateObj("ark", map[string]any{"provider": "curseforge", "curseforgeGameID": tc.gameID}, versions),
serverWithVersion("gameplane-games", "alpha", "ark", ""),
)
var gotCfg registry.Config
r := mountRegistryRouter(k, fakeSet{p: &fakeProvider{}, cfgOut: &gotCfg})

rr := do(t, r, "GET", "/servers/alpha/mods/registry/search?q=ark-mod", nil)
if rr.Code != 200 {
t.Fatalf("got %d %s", rr.Code, rr.Body)
}
if gotCfg.Provider != "curseforge" || gotCfg.CurseForgeGameID != tc.wantVal {
t.Errorf("cfg = %+v, want provider=curseforge curseforgeGameID=%d (not the hardcoded Minecraft 432)", gotCfg, tc.wantVal)
}
})
}
}

// TestNestedInt32 covers every numeric shape nestedInt32 accepts, including
// the bare int case (a defensive branch for a hand-built test fixture; a
// real unstructured decode never produces it) that TestRegistrySearch_
Expand Down
64 changes: 54 additions & 10 deletions api/internal/registry/curseforge.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import (
)

// Curseforge searches the CurseForge v1 API (api.curseforge.com), which
// requires an x-api-key. Suits Minecraft mods + modpacks. Loader filtering
// maps Gameplane loader ids to CurseForge's numeric modLoaderType; the clean
// game-version token is the gameVersion filter.
// requires an x-api-key. CurseForge hosts mods for many games, not just
// Minecraft — Search must be faceted to exactly one numeric CurseForge game
// id (see curseforgeGame below — a template picks the game the way
// Thunderstore picks a community or Steam picks an app id). Loader
// filtering maps Gameplane loader ids to CurseForge's numeric
// modLoaderType; the clean game-version token is the gameVersion filter.
type Curseforge struct {
client *http.Client
userAgent string
Expand All @@ -25,7 +28,32 @@ func newCurseforge(client *http.Client, userAgent, apiKey string) *Curseforge {
return &Curseforge{client: client, userAgent: userAgent, apiKey: apiKey, baseURL: "https://api.curseforge.com/v1"}
}

// curseforgeGame binds the CurseForge engine to one template's numeric
// CurseForge game id, mirroring steamApp's per-config wrapper. CurseForge
// has no single "browse everything" search — a template must declare
// curseforgeGameID the same way it declares steamAppID.
type curseforgeGame struct {
cf *Curseforge
gameID int32
}

func (g *curseforgeGame) Search(ctx context.Context, q SearchQuery) ([]Project, error) {
return g.cf.search(ctx, q, g.gameID)
}

func (g *curseforgeGame) Versions(ctx context.Context, projectID string, f Filter) ([]Version, error) {
return g.cf.Versions(ctx, projectID, f)
}

// ModpackDeps is a no-op: the games wired to CurseForge install content as
// single mods (or via idList), not via dependency resolution.
func (g *curseforgeGame) ModpackDeps(ctx context.Context, projectID string) ([]File, error) {
return g.cf.ModpackDeps(ctx, projectID)
}

// CurseForge numeric constants (stable, from the official API schema).
// cfGameMinecraft also marks the one game whose classId taxonomy this
// package knows — see classID below.
const (
cfGameMinecraft = 432
cfClassMods = 6
Expand Down Expand Up @@ -64,18 +92,32 @@ func cfSortField(sort string) int {
}
}

func (c *Curseforge) classID(q SearchQuery) int {
// classID returns the CurseForge classId to filter results by, or 0 for "no
// class filter". CurseForge's class ids are per-game — 6 (mods) and 4471
// (modpacks) are Minecraft's ids specifically, not a general CurseForge
// concept. Sending them for a different game would filter by classes that
// don't exist there and silently return zero results, so that split is
// kept ONLY for gameId 432 (Minecraft); every other game gets no classId
// filter, i.e. its search isn't narrowed to mods vs. modpacks.
func (c *Curseforge) classID(q SearchQuery, gameID int32) int {
if gameID != cfGameMinecraft {
return 0
}
if q.modpack() {
return cfClassModpacks
}
return cfClassMods
}

// Search runs a CurseForge mod/modpack search scoped to Minecraft.
func (c *Curseforge) Search(ctx context.Context, q SearchQuery) ([]Project, error) {
// search runs a CurseForge mod/modpack search scoped to gameID (see
// curseforgeGame — the exported Provider surface is that per-config
// wrapper, not this method).
func (c *Curseforge) search(ctx context.Context, q SearchQuery, gameID int32) ([]Project, error) {
params := url.Values{}
params.Set("gameId", strconv.Itoa(cfGameMinecraft))
params.Set("classId", strconv.Itoa(c.classID(q)))
params.Set("gameId", strconv.Itoa(int(gameID)))
if classID := c.classID(q, gameID); classID != 0 {
params.Set("classId", strconv.Itoa(classID))
}
if q.Term != "" {
params.Set("searchFilter", q.Term)
}
Expand Down Expand Up @@ -194,8 +236,10 @@ type cfFile struct {
GameVersions []string `json:"gameVersions"`
}

// ModpackDeps is a no-op: the Minecraft modules install CurseForge content
// as single mods, not via dependency resolution.
// ModpackDeps is a no-op: the games wired to CurseForge install content as
// single mods (or via idList), not via dependency resolution — see
// curseforgeGame.ModpackDeps, the Provider surface that actually gets
// called.
func (c *Curseforge) ModpackDeps(_ context.Context, _ string) ([]File, error) {
return nil, nil
}
Expand Down
68 changes: 64 additions & 4 deletions api/internal/registry/curseforge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ func testCurseforge(url string) *Curseforge {
return c
}

// testCurseforgeGame wraps testCurseforge with a configured game id,
// mirroring testSteamApp — the way tests exercise the Provider surface a
// template's registry config actually resolves to.
func testCurseforgeGame(url string, gameID int32) *curseforgeGame {
return &curseforgeGame{cf: testCurseforge(url), gameID: gameID}
}

func TestCurseforgeSearch(t *testing.T) {
var gotKey, gotClass, gotLoader, gotSort, gotFilter, gotIndex, gotGV string
var gotKey, gotGameID, gotClass, gotLoader, gotSort, gotFilter, gotIndex, gotGV string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mods/search" {
t.Errorf("path = %q, want /mods/search", r.URL.Path)
}
gotKey = r.Header.Get("x-api-key")
gotGameID = r.URL.Query().Get("gameId")
gotClass = r.URL.Query().Get("classId")
gotLoader = r.URL.Query().Get("modLoaderType")
gotSort = r.URL.Query().Get("sortField")
Expand All @@ -33,7 +41,7 @@ func TestCurseforgeSearch(t *testing.T) {
}))
defer srv.Close()

got, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{
got, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{
Term: "jei", Loader: "fabric", GameVersion: "1.21.4", Sort: "downloads", Offset: 50, Limit: 20,
})
if err != nil {
Expand All @@ -42,6 +50,9 @@ func TestCurseforgeSearch(t *testing.T) {
if gotKey != "secret-key" {
t.Errorf("x-api-key = %q", gotKey)
}
if gotGameID != "432" {
t.Errorf("gameId = %q, want 432 (minecraft)", gotGameID)
}
if gotClass != "6" {
t.Errorf("classId = %q, want 6 (mods)", gotClass)
}
Expand Down Expand Up @@ -70,14 +81,42 @@ func TestCurseforgeModpackClass(t *testing.T) {
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
if _, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{ProjectType: "modpack", Limit: 20}); err != nil {
if _, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{ProjectType: "modpack", Limit: 20}); err != nil {
t.Fatalf("Search: %v", err)
}
if gotClass != "4471" {
t.Errorf("classId = %q, want 4471 (modpacks)", gotClass)
}
}

// TestCurseforgeSearchUsesConfiguredGameID is the regression guard for the
// "ARK's mod browser shows Minecraft mods" bug: the game id sent upstream
// must be the one configured on the template's provider (curseforgeGameID),
// never the hardcoded Minecraft id. It also proves a non-Minecraft game
// gets no classId filter, since CurseForge's mod/modpack class ids (6,
// 4471) are Minecraft-specific and would silently zero out another game's
// results.
func TestCurseforgeSearchUsesConfiguredGameID(t *testing.T) {
const arkGameID = 83374 // ARK: Survival Ascended, from CurseForge's /v1/games.
var gotGameID, gotClass string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotGameID = r.URL.Query().Get("gameId")
gotClass = r.URL.Query().Get("classId")
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()

if _, err := testCurseforgeGame(srv.URL, arkGameID).Search(context.Background(), SearchQuery{Term: "s+"}); err != nil {
t.Fatalf("Search: %v", err)
}
if gotGameID != "83374" {
t.Errorf("gameId = %q, want 83374 (ARK: Survival Ascended), not the hardcoded Minecraft id", gotGameID)
}
if gotClass != "" {
t.Errorf("classId = %q, want unset for a non-Minecraft game", gotClass)
}
}

func TestCurseforgeVersionsSkipsUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mods/238222/files" {
Expand Down Expand Up @@ -109,12 +148,33 @@ func TestCurseforgeModpackDepsNil(t *testing.T) {
}
}

// TestCurseforgeGameDelegates proves curseforgeGame's Versions and
// ModpackDeps just forward to the underlying engine (game-agnostic, unlike
// Search) — mirroring steamApp's equivalent wrapper-delegation coverage.
func TestCurseforgeGameDelegates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mods/238222/files" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()

game := testCurseforgeGame(srv.URL, 83374)
if _, err := game.Versions(context.Background(), "238222", Filter{}); err != nil {
t.Fatalf("Versions: %v", err)
}
if files, err := game.ModpackDeps(context.Background(), "238222"); err != nil || files != nil {
t.Fatalf("ModpackDeps = %v, %v; want nil, nil", files, err)
}
}

func TestCurseforgeUpstreamError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
if _, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{Term: "x"}); err == nil {
if _, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{Term: "x"}); err == nil {
t.Fatal("expected error on 403")
}
}
2 changes: 1 addition & 1 deletion api/internal/registry/keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func TestSet_AvailableTogglesWithSecret(t *testing.T) {
if !set.Available(ctx, "curseforge") {
t.Fatal("expected curseforge available once the key secret exists")
}
if _, ok := set.For(ctx, Config{Provider: "curseforge"}); !ok {
if _, ok := set.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); !ok {
t.Fatal("expected For to resolve curseforge once the key secret exists")
}

Expand Down
20 changes: 14 additions & 6 deletions api/internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ type Config struct {
// SteamAppID facets Steam Workshop browse/search to one app. Required
// by the steam provider; ignored by others.
SteamAppID int32
// CurseForgeGameID facets CurseForge browse/search to one numeric
// CurseForge game (e.g. 432 for Minecraft, 83374 for ARK: Survival
// Ascended). Required by the curseforge provider; ignored by others.
CurseForgeGameID int32
// GitHubOwner and GitHubRepo bind the "github" provider to one
// repository's Releases — GitHub has no cross-repo mod search, so
// (unlike every keyless engine above) a template must pick exactly one
Expand Down Expand Up @@ -221,10 +225,11 @@ func NewSet(version string, keyFunc KeyFunc) *Set {

// For returns the provider for a module's registry config. ok is false
// when the provider is unknown/unset, a required parameter is missing
// (Thunderstore/Nexus need a community/domain, Steam needs an app id), or
// the engine isn't configured (a keyed provider without a key) — the
// handler maps that to 501 so the dashboard hides the provider. For keyed
// providers, the key is resolved lazily via context and cached with a TTL.
// (Thunderstore/Nexus need a community/domain, Steam needs an app id,
// CurseForge needs a game id), or the engine isn't configured (a keyed
// provider without a key) — the handler maps that to 501 so the dashboard
// hides the provider. For keyed providers, the key is resolved lazily via
// context and cached with a TTL.
func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) {
switch cfg.Provider {
case "modrinth":
Expand All @@ -235,11 +240,14 @@ func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) {
}
return &thunderstoreCommunity{ts: s.thunderstore, community: cfg.Community}, true
case "curseforge":
if cfg.CurseForgeGameID <= 0 {
return nil, false
}
cf, err := s.curseforgeLazy(ctx)
if err != nil || cf == nil {
return nil, false
}
return cf, true
return &curseforgeGame{cf: cf, gameID: cfg.CurseForgeGameID}, true
case "hangar":
return s.hangar, true
case "factorio":
Expand Down Expand Up @@ -280,7 +288,7 @@ func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) {
// specific server — the providers listing uses it to mark the dashboard's
// provider switch. Keyed providers' availability depends on whether an API
// key is currently configured; it does not check per-server parameters
// (SteamAppID/Community), which For validates.
// (SteamAppID/CurseForgeGameID/Community), which For validates.
func (s *Set) Available(ctx context.Context, provider string) bool {
switch provider {
case "modrinth", "thunderstore", "hangar", "factorio", "spigot", "github", "umod":
Expand Down
11 changes: 8 additions & 3 deletions api/internal/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestSetFor(t *testing.T) {
t.Error("github without owner should not be selectable")
}
// CurseForge is key-gated: not selectable without a key.
if _, ok := s.For(ctx, Config{Provider: "curseforge"}); ok {
if _, ok := s.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); ok {
t.Error("curseforge without a key should not be selectable")
}
if _, ok := s.For(ctx, Config{Provider: "nope"}); ok {
Expand Down Expand Up @@ -78,8 +78,13 @@ func TestSetAvailable(t *testing.T) {
if !withKey.Available(ctx, "curseforge") {
t.Error("curseforge should be available with a key")
}
if p, ok := withKey.For(ctx, Config{Provider: "curseforge"}); !ok || p == nil {
t.Errorf("curseforge with key: ok=%v p=%v", ok, p)
if p, ok := withKey.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); !ok || p == nil {
t.Errorf("curseforge with key and gameID: ok=%v p=%v", ok, p)
}
// CurseForge is also gameID-gated: a key alone isn't enough, the same
// way Steam needs both a key and an appID.
if _, ok := withKey.For(ctx, Config{Provider: "curseforge"}); ok {
t.Error("curseforge without a curseforgeGameID should not be selectable")
}
}

Expand Down
Loading
Loading