// Copyright 2026 Moko Consulting // 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"] = "

" + html.EscapeString(cfg.Description) + "

" } // 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("

%s - %s

\n", version, date)) // Convert markdown list items to HTML list. lines := strings.Split(rel.Note, "\n") b.WriteString("\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), } }