diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index ed245fb59e..32e2809a7c 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -211,3 +211,41 @@ func ServeWordPressJSON(ctx *context.Context) { ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(jsonData) } + +// ServeComposerJSON generates and serves a Composer packages.json feed. +func ServeComposerJSON(ctx *context.Context) { + platform := ctx.Data["RepoUpdatePlatform"] + if platform != "composer" { + ctx.NotFound(nil) + return + } + + _, ok, _ := validateUpdateKey(ctx) + if !ok { + ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(`{"packages":{}}`)) + return + } + + licenseKey := ctx.FormString("key") + if licenseKey == "" { + licenseKey = ctx.FormString("dlid") + } + + data, err := updateserver.GenerateComposerJSON(ctx, ctx.Repo.Repository, licenseKey) + if err != nil { + ctx.ServerError("GenerateComposerJSON", err) + return + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + ctx.ServerError("json.Marshal", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(jsonData) +} diff --git a/routers/web/web.go b/routers/web/web.go index a4a227e0ab..21d340fe8f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1519,6 +1519,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/updates.xml", repo.ServeUpdatesXML) m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON) m.Get("/updates/wordpress.json", repo.ServeWordPressJSON) + m.Get("/updates/packages.json", repo.ServeComposerJSON) m.Get("/changelog.xml", repo.ServeChangelogXML) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": update server diff --git a/services/updateserver/composer.go b/services/updateserver/composer.go new file mode 100644 index 0000000000..4353daa6d1 --- /dev/null +++ b/services/updateserver/composer.go @@ -0,0 +1,176 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "fmt" + "strings" + "time" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" +) + +// ComposerPackage represents the packages.json response for Composer/Packagist. +type ComposerPackage struct { + Packages map[string]map[string]ComposerVersion `json:"packages"` +} + +// ComposerVersion represents a single version entry. +type ComposerVersion struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + License []string `json:"license,omitempty"` + Authors []ComposerAuthor `json:"authors,omitempty"` + Dist *ComposerDist `json:"dist,omitempty"` + Require map[string]string `json:"require,omitempty"` + Time string `json:"time,omitempty"` +} + +// ComposerAuthor represents a package author. +type ComposerAuthor struct { + Name string `json:"name"` + Homepage string `json:"homepage,omitempty"` +} + +// ComposerDist represents a distribution archive. +type ComposerDist struct { + URL string `json:"url"` + Type string `json:"type"` + Shasum string `json:"shasum,omitempty"` + Reference string `json:"reference,omitempty"` +} + +// GenerateComposerJSON builds a Composer packages.json from repo releases. +func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*ComposerPackage, error) { + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + RepoID: repo.ID, + ListOptions: db.ListOptionsAll, + IncludeDrafts: false, + IncludeTags: false, + }) + if err != nil { + return nil, fmt.Errorf("FindReleases: %w", err) + } + + if err := repo.LoadOwner(ctx); err != nil { + return nil, fmt.Errorf("LoadOwner: %w", err) + } + + baseURL := strings.TrimSuffix(setting.AppURL, "/") + repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) + + cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) + + // Composer package name: vendor/package + packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name)) + if cfg != nil && cfg.ExtensionName != "" { + packageName = cfg.ExtensionName + } + + maintainer := repo.Owner.Name + maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) + if cfg != nil && cfg.Maintainer != "" { + maintainer = cfg.Maintainer + } + if cfg != nil && cfg.MaintainerURL != "" { + maintainerURL = cfg.MaintainerURL + } + + description := "" + if cfg != nil && cfg.Description != "" { + description = cfg.Description + } + + phpMin := "" + if cfg != nil && cfg.PHPMinimum != "" { + phpMin = ">=" + cfg.PHPMinimum + } + + streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + versions := make(map[string]ComposerVersion) + + for _, rel := range releases { + if rel.IsDraft || rel.IsTag { + continue + } + + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) + if ch != "stable" { + continue // Composer only serves stable versions + } + + version := extractVersion(rel.TagName) + if isStreamName(version, streams) { + version = extractVersion(rel.Title) + } + if version == "" { + continue + } + + if err := rel.LoadAttributes(ctx); err != nil { + continue + } + + var downloadURL string + var sha256Hash string + for _, att := range rel.Attachments { + if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") { + downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name) + break + } + } + if downloadURL == "" { + downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName) + } + if licenseKey != "" { + downloadURL += "?dlid=" + licenseKey + } + + // Look for SHA256 sidecar + for _, att := range rel.Attachments { + if strings.HasSuffix(att.Name, ".sha256") { + sha256Hash = readSHA256FromSidecar(ctx, att) + break + } + } + + require := make(map[string]string) + if phpMin != "" { + require["php"] = phpMin + } + + v := ComposerVersion{ + Name: packageName, + Version: version, + Description: description, + Homepage: repoLink, + Authors: []ComposerAuthor{ + {Name: maintainer, Homepage: maintainerURL}, + }, + Dist: &ComposerDist{ + URL: downloadURL, + Type: "zip", + Shasum: sha256Hash, + Reference: rel.TagName, + }, + Require: require, + Time: time.Unix(int64(rel.CreatedUnix), 0).Format(time.RFC3339), + } + + versions[version] = v + } + + return &ComposerPackage{ + Packages: map[string]map[string]ComposerVersion{ + packageName: versions, + }, + }, nil +} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 1cb6560e77..06bb146d95 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -113,7 +113,7 @@ {{end}} - {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}} + {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo) .IsSigned}} {{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} {{if .Repository.NumOpenActionRuns}} @@ -128,7 +128,7 @@ {{end}} - {{if .LicensingEnabled}} + {{if and .LicensingEnabled .IsSigned}} {{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}} {{if .NumLicensePackages}}