feat(updates): Composer feed (#354), hide Actions/Licenses tabs for guests #417

Merged
jmiller merged 1 commits from dev into main 2026-06-02 14:02:29 +00:00
4 changed files with 217 additions and 2 deletions
+38
View File
@@ -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)
}
+1
View File
@@ -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
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+2 -2
View File
@@ -113,7 +113,7 @@
</a>
{{end}}
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}}
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo) .IsSigned}}
<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
{{if .Repository.NumOpenActionRuns}}
@@ -128,7 +128,7 @@
</a>
{{end}}
{{if .LicensingEnabled}}
{{if and .LicensingEnabled .IsSigned}}
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumLicensePackages}}