Compare commits

..

3 Commits

13 changed files with 6 additions and 845 deletions
-159
View File
@@ -1,159 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(LicenseKey))
}
// LicenseKey represents an individual key issued from a LicensePackage.
type LicenseKey struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"` // FK to license_package
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that issued it
KeyHash string `xorm:"UNIQUE NOT NULL"` // SHA-256 of the raw key
KeyPrefix string `xorm:"NOT NULL"` // first 8 chars for display
LicenseeName string `xorm:""` // customer name
LicenseeEmail string `xorm:""` // customer email
DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default
IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (LicenseKey) TableName() string {
return "license_key"
}
// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format.
func GenerateKeyString() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
hex := strings.ToUpper(hex.EncodeToString(b))
return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil
}
// HashKey returns the SHA-256 hash of a raw key string.
func HashKey(rawKey string) string {
h := sha256.Sum256([]byte(rawKey))
return hex.EncodeToString(h[:])
}
// CreateLicenseKey generates a new key, hashes it, stores it, and returns the raw key.
// The raw key is only available at creation time.
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
rawKey, err = GenerateKeyString()
if err != nil {
return "", fmt.Errorf("GenerateKeyString: %w", err)
}
key.KeyHash = HashKey(rawKey)
key.KeyPrefix = rawKey[:12] + "..."
if _, err := db.GetEngine(ctx).Insert(key); err != nil {
return "", err
}
return rawKey, nil
}
// GetLicenseKeyByHash looks up a key by its SHA-256 hash.
func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) {
key := new(LicenseKey)
has, err := db.GetEngine(ctx).Where("key_hash = ?", hash).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicenseKey"}
}
return key, nil
}
// GetLicenseKeyByID returns a key by its ID.
func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
key := new(LicenseKey)
has, err := db.GetEngine(ctx).ID(id).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicenseKey", ID: id}
}
return key, nil
}
// ListLicenseKeys returns all keys for the given owner.
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
}
// ListLicenseKeysByPackage returns all keys for a specific package.
func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
}
// UpdateLicenseKey updates a license key.
func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
_, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key)
return err
}
// DeleteLicenseKey deletes a license key by ID.
func DeleteLicenseKey(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
return err
}
// ValidateLicenseKey validates a raw key string against the database.
// Returns the key record and its associated package, or an error.
func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, error) {
hash := HashKey(rawKey)
key, err := GetLicenseKeyByHash(ctx, hash)
if err != nil {
return nil, nil, fmt.Errorf("invalid license key")
}
if !key.IsActive {
return nil, nil, fmt.Errorf("license key is deactivated")
}
now := timeutil.TimeStampNow()
if key.StartsUnix > 0 && now < key.StartsUnix {
return nil, nil, fmt.Errorf("license key not yet active")
}
if key.ExpiresUnix > 0 && now > key.ExpiresUnix {
return nil, nil, fmt.Errorf("license key has expired")
}
pkg, err := GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
return nil, nil, fmt.Errorf("license package not found")
}
if !pkg.IsActive {
return nil, nil, fmt.Errorf("license package is deactivated")
}
return key, pkg, nil
}
-49
View File
@@ -1,49 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(LicenseKeyUsage))
}
// LicenseKeyUsage tracks update check activity for a license key.
type LicenseKeyUsage struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Domain string `xorm:""` // requesting domain from extra_query
IPAddress string `xorm:""`
UserAgent string `xorm:"TEXT"`
VersionFrom string `xorm:""` // version the client is updating from
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (LicenseKeyUsage) TableName() string {
return "license_key_usage"
}
// RecordUsage inserts a usage tracking entry.
func RecordUsage(ctx context.Context, usage *LicenseKeyUsage) error {
_, err := db.GetEngine(ctx).Insert(usage)
return err
}
// GetRecentUsage returns the most recent usage entries for a key.
func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyUsage, error) {
usages := make([]*LicenseKeyUsage, 0, limit)
return usages, db.GetEngine(ctx).Where("key_id = ?", keyID).
OrderBy("created_unix DESC").Limit(limit).Find(&usages)
}
// CountUsageByKey returns the total number of update checks for a key.
func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) {
return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage))
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(LicensePackage))
}
// LicensePackage defines a purchasable subscription tier that determines
// what update streams a group of license keys can access.
type LicensePackage struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that owns this package
Name string `xorm:"NOT NULL"` // e.g. "Pro Annual", "Lifetime"
Description string `xorm:"TEXT"`
DurationDays int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited/lifetime
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` // "all" = org-wide, or JSON array of repo IDs
// AllowedChannels defines which update streams keys from this package
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
AllowedChannels string `xorm:"TEXT"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (LicensePackage) TableName() string {
return "license_package"
}
// CreateLicensePackage creates a new license package.
func CreateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
_, err := db.GetEngine(ctx).Insert(pkg)
return err
}
// GetLicensePackageByID returns a license package by ID.
func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, error) {
pkg := new(LicensePackage)
has, err := db.GetEngine(ctx).ID(id).Get(pkg)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicensePackage", ID: id}
}
return pkg, nil
}
// ListLicensePackages returns all packages for the given owner.
func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
return pkgs, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&pkgs)
}
// UpdateLicensePackage updates a license package.
func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
_, err := db.GetEngine(ctx).ID(pkg.ID).AllCols().Update(pkg)
return err
}
// DeleteLicensePackage deletes a license package by ID.
func DeleteLicensePackage(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage))
return err
}
-1
View File
@@ -412,7 +412,6 @@ func prepareMigrationTasks() []*migration {
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch),
newMigration(335, "Add license key tables for update server", v1_27.AddLicenseKeyTables),
}
return preparedMigrations
}
-75
View File
@@ -1,75 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
type licensePackage335 struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"NOT NULL"`
Description string `xorm:"TEXT"`
DurationDays int `xorm:"NOT NULL DEFAULT 0"`
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"`
AllowedChannels string `xorm:"TEXT"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (licensePackage335) TableName() string {
return "license_package"
}
type licenseKey335 struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
KeyHash string `xorm:"UNIQUE NOT NULL"`
KeyPrefix string `xorm:"NOT NULL"`
LicenseeName string `xorm:""`
LicenseeEmail string `xorm:""`
DomainRestriction string `xorm:"TEXT"`
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
IsInternal bool `xorm:"NOT NULL DEFAULT false"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (licenseKey335) TableName() string {
return "license_key"
}
type licenseKeyUsage335 struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Domain string `xorm:""`
IPAddress string `xorm:""`
UserAgent string `xorm:"TEXT"`
VersionFrom string `xorm:""`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (licenseKeyUsage335) TableName() string {
return "license_key_usage"
}
// AddLicenseKeyTables creates the license_package, license_key, and
// license_key_usage tables for the update server license system.
func AddLicenseKeyTables(x *xorm.Engine) error {
return x.Sync(
new(licensePackage335),
new(licenseKey335),
new(licenseKeyUsage335),
)
}
-3
View File
@@ -405,11 +405,8 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
perm.units = repo.Units
// anonymous user visit private repo.
// Still process unit-level anonymous access so that units with
// AnonymousAccessMode (e.g. public wiki on a private repo) are visible.
if user == nil && repo.IsPrivate {
perm.AccessMode = perm_model.AccessModeNone
finalProcessRepoUnitPermission(user, &perm)
return perm, nil
}
-8
View File
@@ -673,14 +673,6 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu
cond = userAllPublicRepoCond(cond, orgVisibilityLimit)
}
// Include private repos that have at least one unit with public anonymous access.
// This enables discovery of repos where e.g. wiki or releases are public.
cond = cond.Or(builder.In("`repository`.id",
builder.Select("repo_id").From("repo_unit").Where(
builder.Gt{"anonymous_access_mode": 0},
),
))
if user != nil {
// 2. Be able to see all repositories that we have unit independent access to
// 3. Be able to see all repositories through team membership(s)
+1 -9
View File
@@ -128,15 +128,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
}
// Only public pull don't need auth.
// For private repos, also allow anonymous pull if the specific unit
// (code or wiki) has AnonymousAccessMode >= Read.
isPublicPull := repoExist && isPull && !repo.IsPrivate
if repoExist && isPull && repo.IsPrivate {
repoUnit := repo.MustGetUnit(ctx, unitType)
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
isPublicPull = true
}
}
isPublicPull := repoExist && !repo.IsPrivate && isPull
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
// don't allow anonymous pulls if organization is not public
-107
View File
@@ -1,107 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"net/http"
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/updateserver"
)
// validateUpdateKey checks for a license key in the request and validates it.
// Returns allowed channels (nil = all channels) and whether access is granted.
func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) {
rawKey := ctx.FormString("key")
if rawKey == "" {
rawKey = ctx.FormString("download_key")
}
if rawKey == "" {
// No key provided — allow public access (all channels).
return nil, true
}
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey)
if err != nil {
log.Debug("License key validation failed: %v", err)
return nil, false
}
// Record usage.
_ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{
KeyID: key.ID,
RepoID: ctx.Repo.Repository.ID,
Domain: ctx.FormString("domain"),
IPAddress: ctx.RemoteAddr(),
UserAgent: ctx.Req.UserAgent(),
VersionFrom: ctx.FormString("version"),
})
// Parse allowed channels from the package.
if pkg.AllowedChannels != "" {
channels := strings.Split(pkg.AllowedChannels, ",")
for i := range channels {
channels[i] = strings.TrimSpace(channels[i])
}
// Also try JSON array format.
if strings.HasPrefix(pkg.AllowedChannels, "[") {
var parsed []string
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil {
channels = parsed
}
}
return channels, true
}
// Master/internal keys or packages with no channel restriction — all channels.
return nil, true
}
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
// from the repository's releases.
func ServeUpdatesXML(ctx *context.Context) {
allowedChannels, ok := validateUpdateKey(ctx)
if !ok {
// Return empty updates XML for invalid keys (Joomla-compatible).
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><updates></updates>`))
return
}
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateJoomlaXML", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(xmlData)
}
// ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed
// from the repository's releases.
func ServeDolibarrJSON(ctx *context.Context) {
data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GenerateDolibarrJSON", 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)
}
-7
View File
@@ -1494,13 +1494,6 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
// end "/{username}/{reponame}": repo releases
// "/{username}/{reponame}": update server endpoints
m.Group("/{username}/{reponame}", func() {
m.Get("/updates.xml", repo.ServeUpdatesXML)
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": update server
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"strings"
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// DolibarrUpdate represents a single module update entry in Dolibarr format.
type DolibarrUpdate struct {
Name string `json:"name"`
Version string `json:"version"`
Channel string `json:"channel"`
DownloadURL string `json:"url"`
ChangelogURL string `json:"changelog"`
ReleaseURL string `json:"release_url"`
Requires string `json:"requires,omitempty"`
Date string `json:"date"`
SHA256 string `json:"sha256,omitempty"`
}
// DolibarrUpdates holds the full update feed response.
type DolibarrUpdates struct {
Module string `json:"module"`
Updates []DolibarrUpdate `json:"updates"`
}
// GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases.
func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, 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)
result := &DolibarrUpdates{
Module: repo.Name,
}
// Track best release per channel.
bestByChannel := make(map[string]*repo_model.Release)
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := channelFromTag(rel.TagName, rel.IsPrerelease)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} {
rel, ok := bestByChannel[ch]
if !ok {
continue
}
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
var downloadURL string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
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)
}
version := extractVersion(rel.TagName)
suffix := channelSuffix(ch)
if suffix != "" {
version = version + suffix
}
result.Updates = append(result.Updates, DolibarrUpdate{
Name: repo.Name,
Version: version,
Channel: ch,
DownloadURL: downloadURL,
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
ReleaseURL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
})
}
return result, nil
}
-237
View File
@@ -1,237 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"encoding/xml"
"fmt"
"strings"
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// Joomla-compatible updates.xml structures for XML marshaling.
type xmlUpdates struct {
XMLName xml.Name `xml:"updates"`
Updates []xmlUpdate `xml:"update"`
}
type xmlUpdate struct {
Name string `xml:"name"`
Description string `xml:"description"`
Element string `xml:"element"`
Type string `xml:"type"`
Client string `xml:"client"`
Version string `xml:"version"`
CreationDate string `xml:"creationDate"`
InfoURL xmlInfoURL `xml:"infourl"`
Downloads xmlDownloads `xml:"downloads"`
SHA256 string `xml:"sha256,omitempty"`
Tags xmlTags `xml:"tags"`
ChangelogURL string `xml:"changelogurl,omitempty"`
Maintainer string `xml:"maintainer,omitempty"`
MaintainerURL string `xml:"maintainerurl,omitempty"`
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
}
type xmlInfoURL struct {
Title string `xml:"title,attr"`
URL string `xml:",chardata"`
}
type xmlDownloads struct {
DownloadURL []xmlDownloadURL `xml:"downloadurl"`
}
type xmlDownloadURL struct {
Type string `xml:"type,attr"`
Format string `xml:"format,attr"`
URL string `xml:",chardata"`
}
type xmlTags struct {
Tag string `xml:"tag"`
}
type xmlTargetPlat struct {
Name string `xml:"name,attr"`
Version string `xml:"version,attr"`
}
// channelFromTag maps a release tag name to a Joomla update channel.
func channelFromTag(tagName string, isPrerelease bool) string {
lower := strings.ToLower(tagName)
switch {
case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"):
return "dev"
case strings.Contains(lower, "-alpha") || strings.Contains(lower, "alpha"):
return "alpha"
case strings.Contains(lower, "-beta") || strings.Contains(lower, "beta"):
return "beta"
case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"):
return "rc"
case isPrerelease:
return "rc"
default:
return "stable"
}
}
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
// It returns the raw XML bytes. The element, maintainer, and target platform
// are derived from the repo name and owner.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, 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("GetReleasesByRepoID: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := setting.AppURL
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
element := strings.ToLower(repo.Name)
// Track best (latest) release per channel to emit one entry per channel.
bestByChannel := make(map[string]*repo_model.Release)
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := channelFromTag(rel.TagName, rel.IsPrerelease)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
// Build allowed channel set for filtering.
channelAllowed := make(map[string]bool)
if len(allowedChannels) > 0 {
for _, c := range allowedChannels {
channelAllowed[strings.ToLower(c)] = true
}
}
var updates xmlUpdates
for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} {
// Skip channels not in the allowed set (when filtering is active).
if len(channelAllowed) > 0 && !channelAllowed[ch] {
continue
}
rel, ok := bestByChannel[ch]
if !ok {
continue
}
// Load attachments for download URLs.
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
// Find the first .zip attachment as the download URL.
var downloadURL string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
// Fall back to the release tag archive if no zip attachment.
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
version := extractVersion(rel.TagName)
suffix := channelSuffix(ch)
if suffix != "" {
version = version + suffix
}
u := xmlUpdate{
Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch),
Element: element,
Type: "component",
Client: "site",
Version: version,
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
InfoURL: xmlInfoURL{
Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
},
Downloads: xmlDownloads{
DownloadURL: []xmlDownloadURL{
{Type: "full", Format: "zip", URL: downloadURL},
},
},
Tags: xmlTags{Tag: ch},
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
Maintainer: repo.Owner.Name,
MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name),
TargetPlatform: xmlTargetPlat{
Name: "joomla",
Version: ".*",
},
}
updates.Updates = append(updates.Updates, u)
}
output, err := xml.MarshalIndent(updates, "", " ")
if err != nil {
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
}
return append([]byte(xml.Header), output...), nil
}
// extractVersion strips common tag prefixes (v, release-, etc.) to get the version.
func extractVersion(tagName string) string {
v := tagName
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "release-")
v = strings.TrimPrefix(v, "release/")
// Strip channel suffixes to get base version.
for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
v = v[:idx]
break
}
}
return v
}
// channelSuffix returns the version suffix for a channel.
func channelSuffix(channel string) string {
switch channel {
case "dev":
return "-dev"
case "alpha":
return "-alpha"
case "beta":
return "-beta"
case "rc":
return "-rc"
default:
return ""
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 05.11.00
VERSION: 05.05.00
-->
<updates>
@@ -87,13 +87,13 @@
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.11.00</version>
<creationDate>2026-05-31</creationDate>
<version>05.05.00</version>
<creationDate>2026-05-30</creationDate>
<infourl title='MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.11.00.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.05.00.zip</downloadurl>
</downloads>
<sha256>af8484a184da809ad6954c05a3a51f218490bc2520bebe03c2b9654f028a4416</sha256>
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>