1935889f6b
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Add resolveExtensionMetadata() with cascading priority: org-level repo-scoped custom fields → update_stream_config table → repo-derived defaults. All six feed generators (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS) now use this unified resolver. Repos can be migrated to custom fields gradually since the config table remains as fallback. Ref #492 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
225 lines
7.5 KiB
Go
225 lines
7.5 KiB
Go
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package updateserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html"
|
|
"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"
|
|
)
|
|
|
|
// WordPressPluginInfo is the JSON response format compatible with the
|
|
// YahnisElsts plugin-update-checker (PUC) library — the standard for
|
|
// commercial WordPress plugin self-hosted updates.
|
|
type WordPressPluginInfo struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Version string `json:"version"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
Homepage string `json:"homepage,omitempty"`
|
|
Requires string `json:"requires,omitempty"`
|
|
Tested string `json:"tested,omitempty"`
|
|
RequiresPHP string `json:"requires_php,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
AuthorHomepage string `json:"author_homepage,omitempty"`
|
|
LastUpdated string `json:"last_updated,omitempty"`
|
|
UpgradeNotice string `json:"upgrade_notice,omitempty"`
|
|
Sections map[string]string `json:"sections,omitempty"`
|
|
Icons map[string]string `json:"icons,omitempty"`
|
|
Banners map[string]string `json:"banners,omitempty"`
|
|
}
|
|
|
|
// GenerateWordPressJSON builds a PUC-compatible JSON response for the latest
|
|
// stable release. The license key (if provided) is embedded in the download URL.
|
|
func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WordPressPluginInfo, 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)
|
|
|
|
// Load extension metadata with cascading fallback:
|
|
// custom fields → config table → repo-derived defaults.
|
|
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
|
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
|
|
|
slug := meta.Element
|
|
displayName := meta.DisplayName
|
|
requiresPHP := meta.PHPMinimum
|
|
homepage := repoLink
|
|
if meta.SupportURL != "" {
|
|
homepage = meta.SupportURL
|
|
}
|
|
maintainer := repo.Owner.Name
|
|
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
|
if cfg != nil {
|
|
if cfg.Maintainer != "" {
|
|
maintainer = cfg.Maintainer
|
|
}
|
|
if cfg.MaintainerURL != "" {
|
|
maintainerURL = cfg.MaintainerURL
|
|
}
|
|
if homepage == repoLink && cfg.InfoURL != "" {
|
|
homepage = cfg.InfoURL
|
|
}
|
|
}
|
|
|
|
// Resolve streams and find the latest stable release.
|
|
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
|
var latestStable *repo_model.Release
|
|
for _, rel := range releases {
|
|
if rel.IsDraft || rel.IsTag {
|
|
continue
|
|
}
|
|
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
|
if ch == "stable" {
|
|
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
|
|
latestStable = rel
|
|
}
|
|
}
|
|
}
|
|
|
|
if latestStable == nil {
|
|
return &WordPressPluginInfo{
|
|
Name: displayName,
|
|
Slug: slug,
|
|
Version: "0.0.0",
|
|
}, nil
|
|
}
|
|
|
|
// Load attachments.
|
|
if err := latestStable.LoadAttributes(ctx); err != nil {
|
|
return nil, fmt.Errorf("LoadAttributes: %w", err)
|
|
}
|
|
|
|
// Find zip download URL.
|
|
var downloadURL string
|
|
for _, att := range latestStable.Attachments {
|
|
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
|
|
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name)
|
|
break
|
|
}
|
|
}
|
|
if downloadURL == "" {
|
|
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName)
|
|
}
|
|
|
|
// Append license key to download URL if provided.
|
|
if licenseKey != "" {
|
|
if strings.Contains(downloadURL, "?") {
|
|
downloadURL += "&dlid=" + licenseKey
|
|
} else {
|
|
downloadURL += "?dlid=" + licenseKey
|
|
}
|
|
}
|
|
|
|
version := extractVersion(latestStable.TagName)
|
|
if version == "" || isStreamName(latestStable.TagName, streams) {
|
|
version = extractVersion(latestStable.Title)
|
|
}
|
|
if version == "" {
|
|
version = latestStable.TagName
|
|
}
|
|
lastUpdated := time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02 3:04pm MST")
|
|
|
|
// Build sections from release notes.
|
|
sections := map[string]string{}
|
|
if latestStable.Note != "" {
|
|
sections["changelog"] = buildWordPressChangelog(ctx, releases, streams)
|
|
}
|
|
if cfg != nil && cfg.Description != "" {
|
|
sections["description"] = "<p>" + html.EscapeString(cfg.Description) + "</p>"
|
|
}
|
|
|
|
// Build icon/banner URLs from repo assets.
|
|
icons := buildAssetURLs(repoLink, repo.DefaultBranch, "icon", []string{"128x128", "256x256"})
|
|
banners := buildBannerURLs(repoLink, repo.DefaultBranch)
|
|
|
|
return &WordPressPluginInfo{
|
|
Name: displayName,
|
|
Slug: slug,
|
|
Version: version,
|
|
DownloadURL: downloadURL,
|
|
Homepage: homepage,
|
|
RequiresPHP: requiresPHP,
|
|
Author: maintainer,
|
|
AuthorHomepage: maintainerURL,
|
|
LastUpdated: lastUpdated,
|
|
Sections: sections,
|
|
Icons: icons,
|
|
Banners: banners,
|
|
}, nil
|
|
}
|
|
|
|
// buildWordPressChangelog builds an HTML changelog from multiple releases.
|
|
func buildWordPressChangelog(ctx context.Context, releases []*repo_model.Release, streams []licenses.StreamDef) string {
|
|
var b strings.Builder
|
|
count := 0
|
|
for _, rel := range releases {
|
|
if rel.IsDraft || rel.IsTag || rel.Note == "" {
|
|
continue
|
|
}
|
|
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
|
|
if ch != "stable" {
|
|
continue
|
|
}
|
|
version := extractVersion(rel.TagName)
|
|
date := time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02")
|
|
b.WriteString(fmt.Sprintf("<h4>%s - %s</h4>\n", version, date))
|
|
|
|
// Convert markdown list items to HTML list.
|
|
lines := strings.Split(rel.Note, "\n")
|
|
b.WriteString("<ul>\n")
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
|
b.WriteString(fmt.Sprintf("<li>%s</li>\n", html.EscapeString(strings.TrimLeft(trimmed[2:], " "))))
|
|
}
|
|
}
|
|
b.WriteString("</ul>\n")
|
|
|
|
count++
|
|
if count >= 10 {
|
|
break // limit to last 10 releases
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// buildAssetURLs generates icon URLs from conventional paths in the repo.
|
|
func buildAssetURLs(repoLink, branch, prefix string, sizes []string) map[string]string {
|
|
icons := make(map[string]string)
|
|
for i, size := range sizes {
|
|
key := fmt.Sprintf("%dx", i+1)
|
|
icons[key] = fmt.Sprintf("%s/raw/branch/%s/assets/%s-%s.png", repoLink, branch, prefix, size)
|
|
}
|
|
return icons
|
|
}
|
|
|
|
// buildBannerURLs generates banner URLs from conventional paths.
|
|
func buildBannerURLs(repoLink, branch string) map[string]string {
|
|
return map[string]string{
|
|
"low": fmt.Sprintf("%s/raw/branch/%s/assets/banner-772x250.png", repoLink, branch),
|
|
"high": fmt.Sprintf("%s/raw/branch/%s/assets/banner-1544x500.png", repoLink, branch),
|
|
}
|
|
}
|