From b77da17f381ab493bc22e167fd2d636d2fdf19ad Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 01:31:51 -0500 Subject: [PATCH] feat(licenses): implement full commercial license management system Add key editing, domain enforcement, purchase webhooks, public validation API, channels multiselect, Joomla downloadkey element, licensing feature toggle, unified update system, release tag enforcement, heartbeat tracking, and improved settings UX. Phase 1: Full key display with AbsoluteShort dates, master package protection (hide edit/delete in UI, reject in handlers). Phase 2: Key edit page with template, handlers, and routes for both repo and org levels. Master keys redirect away. Phase 3: Domain restriction checking against CSV allowlist, MaxSites enforcement via CountUniqueDomainsByKey and IsDomainKnownForKey, dlid query param support for Joomla. Phase 4: Purchase webhook (POST /license-keys/purchase) with PaymentRef idempotency. Public validation endpoint (POST /license-keys/validate) outside auth middleware. PATCH /license-keys/{id} for API key editing. Phase 5: Channels multiselect using org UpdateStreamConfig streams rendered as checkboxes, stored as JSON arrays. Additional: downloadkey XML element, LicensingEnabled toggle on UpdateStreamConfig, Dolibarr endpoint unified with key validation, release tag suffix enforcement, LastHeartbeatUnix field with TouchHeartbeat, and cleaned-up settings pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 63 +++++- models/licenses/license_key_usage.go | 16 ++ models/licenses/update_stream_config.go | 1 + modules/structs/license_key.go | 28 +++ options/locale/locale_en-US.json | 40 +++- routers/api/v1/api.go | 3 + routers/api/v1/repo/license_key.go | 136 +++++++++++++ routers/web/org/home.go | 4 + routers/web/org/licenses.go | 216 ++++++++++++++++++++- routers/web/org/update_streams.go | 10 +- routers/web/repo/licenses.go | 162 +++++++++++++++- routers/web/repo/setting/setting.go | 11 +- routers/web/repo/updateserver.go | 31 ++- routers/web/web.go | 7 + services/context/repo.go | 11 +- services/forms/repo_form.go | 1 + services/release/release.go | 64 ++++++ services/updateserver/dolibarr.go | 14 +- services/updateserver/joomla.go | 42 ++-- templates/org/licenses.tmpl | 41 +++- templates/org/licenses_edit_package.tmpl | 59 ++++++ templates/org/menu.tmpl | 2 +- templates/org/settings/update_streams.tmpl | 35 +++- templates/repo/licenses.tmpl | 24 ++- templates/repo/licenses_edit_key.tmpl | 55 ++++++ templates/repo/licenses_edit_package.tmpl | 9 +- templates/repo/settings/options.tmpl | 16 ++ 27 files changed, 1036 insertions(+), 65 deletions(-) create mode 100644 templates/org/licenses_edit_package.tmpl create mode 100644 templates/repo/licenses_edit_key.tmpl diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index d004427fb5..44f3cddcaa 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -30,10 +30,12 @@ type LicenseKey struct { LicenseeEmail string `xorm:""` // customer email DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default + PaymentRef string `xorm:"UNIQUE"` // idempotency key from payment system 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 + LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // last successful validation CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` } @@ -113,6 +115,22 @@ func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseK return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys) } +// GetLicenseKeyByPaymentRef looks up a key by its payment reference (idempotency). +func GetLicenseKeyByPaymentRef(ctx context.Context, paymentRef string) (*LicenseKey, error) { + if paymentRef == "" { + return nil, db.ErrNotExist{Resource: "LicenseKey"} + } + key := new(LicenseKey) + has, err := db.GetEngine(ctx).Where("payment_ref = ?", paymentRef).Get(key) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "LicenseKey"} + } + return key, nil +} + // CountKeysByPackage returns the number of keys for a package. func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) { return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey)) @@ -124,6 +142,14 @@ func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error { return err } +// TouchHeartbeat updates the last heartbeat timestamp for a key. +func TouchHeartbeat(ctx context.Context, keyID int64) error { + _, err := db.GetEngine(ctx).ID(keyID). + Cols("last_heartbeat_unix"). + Update(&LicenseKey{LastHeartbeatUnix: timeutil.TimeStampNow()}) + 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)) @@ -132,7 +158,9 @@ func DeleteLicenseKey(ctx context.Context, id int64) error { // 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) { +// The domain parameter is optional — when provided, it is checked against +// the key's DomainRestriction list and the MaxSites limit. +func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey, *LicensePackage, error) { hash := HashKey(rawKey) key, err := GetLicenseKeyByHash(ctx, hash) if err != nil { @@ -160,5 +188,38 @@ func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *Licen return nil, nil, fmt.Errorf("license package is deactivated") } + // Domain restriction check — skip for internal/master keys. + if domain != "" && !key.IsInternal { + if key.DomainRestriction != "" { + allowed := false + for _, d := range strings.Split(key.DomainRestriction, ",") { + if strings.EqualFold(strings.TrimSpace(d), domain) { + allowed = true + break + } + } + if !allowed { + return nil, nil, fmt.Errorf("domain not allowed for this license key") + } + } + + // Site limit check: use key's MaxSites, fall back to package default. + maxSites := key.MaxSites + if maxSites == 0 { + maxSites = pkg.MaxSites + } + if maxSites > 0 { + uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID) + if err != nil { + return nil, nil, fmt.Errorf("failed to count domains: %w", err) + } + // Allow if this domain is already recorded, or if under the limit. + domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain) + if !domainKnown && uniqueDomains >= int64(maxSites) { + return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites) + } + } + } + return key, pkg, nil } diff --git a/models/licenses/license_key_usage.go b/models/licenses/license_key_usage.go index 57b8acf6e9..85e128325f 100644 --- a/models/licenses/license_key_usage.go +++ b/models/licenses/license_key_usage.go @@ -47,3 +47,19 @@ func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyU func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) { return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage)) } + +// CountUniqueDomainsByKey returns the number of distinct domains that have used a key. +func CountUniqueDomainsByKey(ctx context.Context, keyID int64) (int64, error) { + count, err := db.GetEngine(ctx). + Where("key_id = ? AND domain != ''", keyID). + Distinct("domain"). + Count(new(LicenseKeyUsage)) + return count, err +} + +// IsDomainKnownForKey checks whether a specific domain has already been recorded for a key. +func IsDomainKnownForKey(ctx context.Context, keyID int64, domain string) (bool, error) { + return db.GetEngine(ctx). + Where("key_id = ? AND domain = ?", keyID, domain). + Exist(new(LicenseKeyUsage)) +} diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go index 32d2e67601..0b01dfd081 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -25,6 +25,7 @@ type UpdateStreamConfig struct { RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both + LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed // CustomStreams is a JSON array of stream definitions. // Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"} diff --git a/modules/structs/license_key.go b/modules/structs/license_key.go index 3d9852d3e2..25602dd080 100644 --- a/modules/structs/license_key.go +++ b/modules/structs/license_key.go @@ -60,6 +60,8 @@ type LicenseKey struct { // swagger:strfmt date-time ExpiresAt *time.Time `json:"expires_at"` // swagger:strfmt date-time + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` + // swagger:strfmt date-time Created time.Time `json:"created_at"` } @@ -93,6 +95,32 @@ type EditLicenseKeyOption struct { ExpiresAt *time.Time `json:"expires_at"` } +// PurchaseLicenseKeyOption options for purchasing a license key via webhook. +type PurchaseLicenseKeyOption struct { + PackageID int64 `json:"package_id" binding:"Required"` + LicenseeName string `json:"licensee_name"` + LicenseeEmail string `json:"licensee_email"` + Domain string `json:"domain"` + PaymentRef string `json:"payment_ref"` +} + +// ValidateLicenseKeyOption options for validating a license key. +type ValidateLicenseKeyOption struct { + Key string `json:"key" binding:"Required"` + Domain string `json:"domain"` +} + +// ValidateLicenseKeyResponse is the response from license key validation. +type ValidateLicenseKeyResponse struct { + Valid bool `json:"valid"` + Message string `json:"message,omitempty"` + PackageName string `json:"package_name,omitempty"` + Channels string `json:"channels,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + SitesUsed int64 `json:"sites_used"` + MaxSites int `json:"max_sites"` +} + // LicenseKeyUsage represents a usage tracking entry. type LicenseKeyUsage struct { ID int64 `json:"id"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 85ac417fa7..5d6d905fda 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2148,9 +2148,15 @@ "repo.settings.unit_visibility_private": "Private (follow repo visibility)", "repo.settings.unit_visibility_public": "Public (anyone can read)", "repo.settings.unit_visibility_releases_help": "Controls whether the releases page is visible to anonymous visitors.", - "repo.settings.update_platform": "Update Server Platform", + "repo.settings.licensing_section": "Licensing & Updates", + "repo.settings.licensing_section_desc": "Manage commercial license keys and gated update feeds for this repository. When enabled, the Licenses tab appears and release tags must follow update stream naming.", + "repo.settings.update_platform": "Update Feed Format", "repo.settings.update_platform_both": "Both (Joomla + Dolibarr)", - "repo.settings.require_update_key": "Require license key for update feed access", + "repo.settings.update_platform_help": "Choose which update feed format to generate. All formats support license key validation.", + "repo.settings.require_update_key": "Require license key for update feeds", + "repo.settings.require_update_key_help": "When enabled, update feeds return empty results unless a valid license key is provided. Joomla clients will see a Download Key field in Update Sites.", + "repo.settings.enable_licensing": "Enable licensing for this repository", + "repo.settings.enable_licensing_help": "Show the Licenses tab and enable license key management for this repository.", "repo.settings.packages_desc": "Enable Repository Packages Registry", "repo.settings.projects_desc": "Enable Projects", "repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)", @@ -2638,7 +2644,7 @@ "repo.licenses.new_package": "New Package", "repo.licenses.description": "Description", "repo.licenses.max_sites": "Max Sites", - "repo.licenses.channels_help": "Comma-separated channel names (e.g. stable,release-candidate). Leave empty for all channels.", + "repo.licenses.channels_help": "Select which update channels this package grants access to. Leave all unchecked for all channels.", "repo.licenses.create_package": "Create License Package", "repo.licenses.create_new_package": "Create New License Package", "repo.licenses.package_created": "License package created successfully.", @@ -2654,6 +2660,16 @@ "repo.licenses.master_key_created": "Master License Key Created", "repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.", "repo.licenses.update_feeds": "Update Feed URLs", + "repo.licenses.edit_key": "Edit License Key", + "repo.licenses.licensee_name": "Licensee Name", + "repo.licenses.licensee_email": "Licensee Email", + "repo.licenses.domain_restriction": "Domain Restriction", + "repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty for no restriction.", + "repo.licenses.use_package_default": "use package default", + "repo.licenses.expires_at": "Expires At", + "repo.licenses.expires_at_help": "Leave empty for no expiry (lifetime).", + "repo.licenses.key_updated": "License key updated.", + "repo.licenses.last_seen": "Last Seen", "repo.release.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", @@ -2795,18 +2811,26 @@ "org.form.create_org_not_allowed": "You are not allowed to create an organization.", "org.settings": "Settings", "org.settings.options": "Organization", - "org.settings.update_streams": "Licenses & Update Streams", - "org.settings.update_streams_desc": "Configure the default update streams for all repositories in this organization. Repos can override with their own settings.", + "org.settings.update_streams": "Licensing & Update Streams", + "org.settings.licensing": "Licensing", + "org.settings.licensing_desc": "Control commercial license key management and gated update feeds across all repositories in this organization.", + "org.settings.enable_licensing": "Enable licensing for this organization", + "org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.", + "org.settings.require_key": "Require license key for all update feeds", + "org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.", + "org.settings.update_streams_heading": "Update Streams", + "org.settings.update_streams_desc": "Configure the default update streams for all repositories. Release tags are matched to streams by their suffix. Repos can override with per-repo settings.", "org.settings.stream_mode": "Stream Mode", "org.settings.stream_mode_joomla": "Standard Joomla streams (stable, release-candidate, beta, alpha, development)", "org.settings.stream_mode_custom": "Custom streams (define your own channels and tag patterns)", "org.settings.default_streams": "Active Streams", - "org.settings.default_streams_joomla": "These are the currently active update streams. Release tags are matched to streams by their suffix.", - "org.settings.stream_name": "Stream Name", + "org.settings.stream_name": "Channel", "org.settings.stream_suffix": "Tag Suffix", + "org.settings.no_suffix": "none (stable)", + "org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).", "org.settings.custom_streams": "Custom Stream Definitions (JSON)", "org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]", - "org.settings.update_streams_saved": "Update stream settings saved.", + "org.settings.update_streams_saved": "Settings saved.", "org.settings.full_name": "Full Name", "org.settings.email": "Contact Email Address", "org.settings.website": "Website", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1df15a7465..575a2325f9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1351,11 +1351,14 @@ func Routes() *web.Router { m.Combo("").Get(repo.ListLicensePackages). Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage) }, reqToken(), reqAdmin()) + m.Post("/license-keys/validate", bind(api.ValidateLicenseKeyOption{}), repo.ValidateLicenseKey) m.Group("/license-keys", func() { m.Combo("").Get(repo.ListLicenseKeys). Post(bind(api.CreateLicenseKeyOption{}), repo.CreateLicenseKey) + m.Post("/purchase", bind(api.PurchaseLicenseKeyOption{}), repo.PurchaseLicenseKey) m.Group("/{id}", func() { m.Delete("", repo.DeleteLicenseKey) + m.Patch("", bind(api.EditLicenseKeyOption{}), repo.EditLicenseKey) m.Get("/usage", repo.GetLicenseKeyUsage) }) }, reqToken(), reqAdmin()) diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index 77c83fe796..56e14d2ad1 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -52,6 +52,10 @@ func toLicenseKeyAPI(key *licenses.LicenseKey) *structs.LicenseKey { t := time.Unix(int64(key.ExpiresUnix), 0) lk.ExpiresAt = &t } + if key.LastHeartbeatUnix > 0 { + t := time.Unix(int64(key.LastHeartbeatUnix), 0) + lk.LastHeartbeat = &t + } return lk } @@ -161,6 +165,100 @@ func CreateLicenseKey(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, resp) } +// EditLicenseKey edits a license key via API. +func EditLicenseKey(ctx *context.APIContext) { + form := web.GetForm(ctx).(*structs.EditLicenseKeyOption) + keyID := ctx.PathParamInt64("id") + + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.APIErrorNotFound(err) + return + } + + if key.IsInternal { + ctx.APIError(http.StatusForbidden, "master keys cannot be edited") + return + } + + if form.LicenseeName != nil { + key.LicenseeName = *form.LicenseeName + } + if form.LicenseeEmail != nil { + key.LicenseeEmail = *form.LicenseeEmail + } + if form.DomainRestriction != nil { + key.DomainRestriction = *form.DomainRestriction + } + if form.MaxSites != nil { + key.MaxSites = *form.MaxSites + } + if form.IsActive != nil { + key.IsActive = *form.IsActive + } + if form.ExpiresAt != nil { + key.ExpiresUnix = timeutil.TimeStamp(form.ExpiresAt.Unix()) + } + + if err := licenses.UpdateLicenseKey(ctx, key); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toLicenseKeyAPI(key)) +} + +// PurchaseLicenseKey handles purchase webhook — creates a key from a payment event. +func PurchaseLicenseKey(ctx *context.APIContext) { + form := web.GetForm(ctx).(*structs.PurchaseLicenseKeyOption) + + // Idempotency check: if payment_ref already exists, return existing key. + if form.PaymentRef != "" { + existing, err := licenses.GetLicenseKeyByPaymentRef(ctx, form.PaymentRef) + if err == nil { + resp := &structs.LicenseKeyCreated{ + LicenseKey: *toLicenseKeyAPI(existing), + RawKey: "", // raw key not available after creation + } + ctx.JSON(http.StatusOK, resp) + return + } + } + + pkg, err := licenses.GetLicensePackageByID(ctx, form.PackageID) + if err != nil { + ctx.APIErrorNotFound(err) + return + } + + key := &licenses.LicenseKey{ + PackageID: form.PackageID, + OwnerID: ctx.Repo.Repository.OwnerID, + LicenseeName: form.LicenseeName, + LicenseeEmail: form.LicenseeEmail, + DomainRestriction: form.Domain, + PaymentRef: form.PaymentRef, + IsActive: true, + } + + if pkg.DurationDays > 0 { + expires := time.Now().AddDate(0, 0, pkg.DurationDays) + key.ExpiresUnix = timeutil.TimeStamp(expires.Unix()) + } + + rawKey, err := licenses.CreateLicenseKey(ctx, key) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + resp := &structs.LicenseKeyCreated{ + LicenseKey: *toLicenseKeyAPI(key), + RawKey: rawKey, + } + ctx.JSON(http.StatusCreated, resp) +} + // DeleteLicenseKey deletes a license key. func DeleteLicenseKey(ctx *context.APIContext) { if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil { @@ -170,6 +268,44 @@ func DeleteLicenseKey(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// ValidateLicenseKey validates a license key — public endpoint (no auth required). +func ValidateLicenseKey(ctx *context.APIContext) { + form := web.GetForm(ctx).(*structs.ValidateLicenseKeyOption) + + key, pkg, err := licenses.ValidateLicenseKey(ctx, form.Key, form.Domain) + if err != nil { + ctx.JSON(http.StatusOK, &structs.ValidateLicenseKeyResponse{ + Valid: false, + Message: err.Error(), + }) + return + } + + _ = licenses.TouchHeartbeat(ctx, key.ID) + + var expiresAt *time.Time + if key.ExpiresUnix > 0 { + t := time.Unix(int64(key.ExpiresUnix), 0) + expiresAt = &t + } + + maxSites := key.MaxSites + if maxSites == 0 { + maxSites = pkg.MaxSites + } + + sitesUsed, _ := licenses.CountUniqueDomainsByKey(ctx, key.ID) + + ctx.JSON(http.StatusOK, &structs.ValidateLicenseKeyResponse{ + Valid: true, + PackageName: pkg.Name, + Channels: pkg.AllowedChannels, + ExpiresAt: expiresAt, + SitesUsed: sitesUsed, + MaxSites: maxSites, + }) +} + // GetLicenseKeyUsage returns usage logs for a license key. func GetLicenseKeyUsage(ctx *context.APIContext) { usages, err := licenses.GetRecentUsage(ctx, ctx.PathParamInt64("id"), 100) diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 3ea7c72c65..fd25172ab0 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -9,6 +9,7 @@ import ( "strings" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" @@ -107,6 +108,9 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.Data["Teams"] = ctx.Org.Teams ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner + + orgCfg, _ := licenses_model.GetOrgConfig(ctx, ctx.Org.Organization.ID) + ctx.Data["OrgLicensingEnabled"] = orgCfg != nil && orgCfg.LicensingEnabled ctx.Data["IsPublicMember"] = func(uid int64) bool { return membersIsPublic[uid] } diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 3587b66620..1feb9bc988 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -6,9 +6,11 @@ package org import ( "net/http" "strconv" + "strings" "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" @@ -16,6 +18,27 @@ import ( const tplOrgLicenses templates.TplName = "org/licenses" +// parseOrgAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice. +func parseOrgAllowedChannels(s string) []string { + if s == "" { + return nil + } + if strings.HasPrefix(s, "[") { + var parsed []string + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + return parsed + } + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + result = append(result, t) + } + } + return result +} + // LicensePackageDisplay is used in templates. type LicensePackageDisplay struct { *licenses.LicensePackage @@ -68,6 +91,14 @@ func Licenses(ctx *context.Context) { ctx.Data["LicenseKeys"] = keys ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() + ctx.Data["OrgLicensingEnabled"] = true + + orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } ctx.HTML(http.StatusOK, tplOrgLicenses) } @@ -84,13 +115,20 @@ func LicensesCreatePackage(ctx *context.Context) { durationDays, _ := strconv.Atoi(ctx.FormString("duration_days")) maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + channels := ctx.Req.Form["allowed_channels"] + var allowedChannels string + if len(channels) > 0 { + data, _ := json.Marshal(channels) + allowedChannels = string(data) + } + pkg := &licenses.LicensePackage{ OwnerID: ctx.Org.Organization.ID, Name: name, Description: ctx.FormString("description"), DurationDays: durationDays, MaxSites: maxSites, - AllowedChannels: ctx.FormString("allowed_channels"), + AllowedChannels: allowedChannels, RepoScope: "all", IsActive: true, } @@ -157,9 +195,185 @@ func LicensesGenerateKey(ctx *context.Context) { keys, _ := licenses.ListLicenseKeys(ctx, ownerID) ctx.Data["LicenseKeys"] = keys + orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } + ctx.HTML(http.StatusOK, tplOrgLicenses) } +const tplOrgLicensesEditPackage templates.TplName = "org/licenses_edit_package" +const tplOrgLicensesEditKey templates.TplName = "repo/licenses_edit_key" + +// LicensesEditPackage shows the edit form for an org license package. +func LicensesEditPackage(ctx *context.Context) { + pkgID := ctx.PathParamInt64("id") + pkg, err := licenses.GetLicensePackageByID(ctx, pkgID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be edited") + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") + return + } + + ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package") + ctx.Data["IsLicensesPage"] = true + ctx.Data["Package"] = pkg + ctx.Data["SelectedChannels"] = parseOrgAllowedChannels(pkg.AllowedChannels) + + orgCfg, _ := licenses.GetOrgConfig(ctx, ctx.Org.Organization.ID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } + + ctx.HTML(http.StatusOK, tplOrgLicensesEditPackage) +} + +// LicensesEditPackagePost saves edits to an org license package. +func LicensesEditPackagePost(ctx *context.Context) { + pkgID := ctx.PathParamInt64("id") + pkg, err := licenses.GetLicensePackageByID(ctx, pkgID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be edited") + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") + return + } + + pkg.Name = ctx.FormString("name") + pkg.Description = ctx.FormString("description") + durationDays, _ := strconv.Atoi(ctx.FormString("duration_days")) + pkg.DurationDays = durationDays + maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + pkg.MaxSites = maxSites + + channels := ctx.Req.Form["allowed_channels"] + if len(channels) > 0 { + data, _ := json.Marshal(channels) + pkg.AllowedChannels = string(data) + } else { + pkg.AllowedChannels = "" + } + + pkg.IsActive = ctx.FormString("is_active") == "on" + + if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil { + ctx.ServerError("UpdateLicensePackage", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.package_updated")) + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") +} + +// LicensesDeletePackage deletes an org license package. Site admin only. +func LicensesDeletePackage(ctx *context.Context) { + if !ctx.IsUserSiteAdmin() { + ctx.NotFound(nil) + return + } + pkgID := ctx.PathParamInt64("id") + pkg, err := licenses.GetLicensePackageByID(ctx, pkgID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be deleted") + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") + return + } + if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil { + ctx.ServerError("DeleteLicensePackage", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.package_deleted")) + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") +} + +// LicensesEditKey shows the edit form for an org license key. +func LicensesEditKey(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + if key.IsInternal { + ctx.Flash.Error("Master keys cannot be edited") + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") + return + } + + ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key") + ctx.Data["IsLicensesPage"] = true + ctx.Data["Key"] = key + ctx.Data["FormAction"] = ctx.Org.OrgLink + "/-/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit" + ctx.Data["BackLink"] = ctx.Org.OrgLink + "/-/licenses" + + if key.ExpiresUnix > 0 { + ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02") + } + + ctx.HTML(http.StatusOK, tplOrgLicensesEditKey) +} + +// LicensesEditKeyPost saves edits to an org license key. +func LicensesEditKeyPost(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + if key.IsInternal { + ctx.Flash.Error("Master keys cannot be edited") + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") + return + } + + key.LicenseeName = ctx.FormString("licensee_name") + key.LicenseeEmail = ctx.FormString("licensee_email") + key.DomainRestriction = ctx.FormString("domain_restriction") + maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + key.MaxSites = maxSites + key.IsActive = ctx.FormString("is_active") == "on" + + expiresStr := ctx.FormString("expires_at") + if expiresStr != "" { + t, err := time.Parse("2006-01-02", expiresStr) + if err == nil { + key.ExpiresUnix = timeutil.TimeStamp(t.Unix()) + } + } else { + key.ExpiresUnix = 0 + } + + if err := licenses.UpdateLicenseKey(ctx, key); err != nil { + ctx.ServerError("UpdateLicenseKey", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated")) + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") +} + // LicensesRevokeKey handles POST to revoke an org license key. func LicensesRevokeKey(ctx *context.Context) { keyID := ctx.PathParamInt64("id") diff --git a/routers/web/org/update_streams.go b/routers/web/org/update_streams.go index c4240ed489..1a7ae9bad1 100644 --- a/routers/web/org/update_streams.go +++ b/routers/web/org/update_streams.go @@ -37,10 +37,12 @@ func SettingsUpdateStreamsPost(ctx *context.Context) { orgID := ctx.Org.Organization.ID cfg := &licenses.UpdateStreamConfig{ - OwnerID: orgID, - RepoID: 0, - StreamMode: ctx.FormString("stream_mode"), - CustomStreams: ctx.FormString("custom_streams"), + OwnerID: orgID, + RepoID: 0, + StreamMode: ctx.FormString("stream_mode"), + CustomStreams: ctx.FormString("custom_streams"), + LicensingEnabled: ctx.FormString("licensing_enabled") == "on", + RequireKey: ctx.FormString("require_key") == "on", } if cfg.StreamMode == "" { diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 8a36f9de3a..7602342a3a 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -6,9 +6,11 @@ package repo import ( "net/http" "strconv" + "strings" "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" @@ -16,6 +18,27 @@ import ( const tplLicenses templates.TplName = "repo/licenses" +// parseAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice. +func parseAllowedChannels(s string) []string { + if s == "" { + return nil + } + if strings.HasPrefix(s, "[") { + var parsed []string + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + return parsed + } + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + result = append(result, t) + } + } + return result +} + // LicensePackageDisplay is used in templates. type LicensePackageDisplay struct { *licenses.LicensePackage @@ -29,6 +52,7 @@ func Licenses(ctx *context.Context) { ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() + ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() ownerID := ctx.Repo.Repository.OwnerID @@ -68,6 +92,14 @@ func Licenses(ctx *context.Context) { } ctx.Data["LicenseKeys"] = keys + // Load available streams for the channels multiselect. + orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } + ctx.HTML(http.StatusOK, tplLicenses) } @@ -83,13 +115,20 @@ func LicensesCreatePackage(ctx *context.Context) { durationDays, _ := strconv.Atoi(ctx.FormString("duration_days")) maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + channels := ctx.Req.Form["allowed_channels"] + var allowedChannels string + if len(channels) > 0 { + data, _ := json.Marshal(channels) + allowedChannels = string(data) + } + pkg := &licenses.LicensePackage{ OwnerID: ctx.Repo.Repository.OwnerID, Name: name, Description: ctx.FormString("description"), DurationDays: durationDays, MaxSites: maxSites, - AllowedChannels: ctx.FormString("allowed_channels"), + AllowedChannels: allowedChannels, RepoScope: "all", IsActive: true, } @@ -140,6 +179,7 @@ func LicensesGenerateKey(ctx *context.Context) { ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() + ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() ctx.Data["NewKeyCreated"] = rawKey // Re-render the page with the new key displayed. @@ -158,6 +198,13 @@ func LicensesGenerateKey(ctx *context.Context) { keys, _ := licenses.ListLicenseKeys(ctx, ownerID) ctx.Data["LicenseKeys"] = keys + orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } + ctx.HTML(http.StatusOK, tplLicenses) } @@ -181,6 +228,77 @@ func LicensesRevokeKey(ctx *context.Context) { } const tplLicensesEditPackage templates.TplName = "repo/licenses_edit_package" +const tplLicensesEditKey templates.TplName = "repo/licenses_edit_key" + +// LicensesEditKey shows the edit form for a license key. +func LicensesEditKey(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + if key.IsInternal { + ctx.Flash.Error("Master keys cannot be edited") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + + ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key") + ctx.Data["PageIsLicenses"] = true + ctx.Data["IsLicensesPage"] = true + ctx.Data["Key"] = key + ctx.Data["FormAction"] = ctx.Repo.RepoLink + "/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit" + ctx.Data["BackLink"] = ctx.Repo.RepoLink + "/licenses" + + if key.ExpiresUnix > 0 { + ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02") + } + + ctx.HTML(http.StatusOK, tplLicensesEditKey) +} + +// LicensesEditKeyPost saves edits to a license key. +func LicensesEditKeyPost(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + if key.IsInternal { + ctx.Flash.Error("Master keys cannot be edited") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + + key.LicenseeName = ctx.FormString("licensee_name") + key.LicenseeEmail = ctx.FormString("licensee_email") + key.DomainRestriction = ctx.FormString("domain_restriction") + maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + key.MaxSites = maxSites + key.IsActive = ctx.FormString("is_active") == "on" + + expiresStr := ctx.FormString("expires_at") + if expiresStr != "" { + t, err := time.Parse("2006-01-02", expiresStr) + if err == nil { + key.ExpiresUnix = timeutil.TimeStamp(t.Unix()) + } + } else { + key.ExpiresUnix = 0 + } + + if err := licenses.UpdateLicenseKey(ctx, key); err != nil { + ctx.ServerError("UpdateLicenseKey", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated")) + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") +} // LicensesEditPackage shows the edit form for a license package. func LicensesEditPackage(ctx *context.Context) { @@ -191,10 +309,26 @@ func LicensesEditPackage(ctx *context.Context) { return } + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be edited") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true ctx.Data["Package"] = pkg + ctx.Data["SelectedChannels"] = parseAllowedChannels(pkg.AllowedChannels) + + ownerID := ctx.Repo.Repository.OwnerID + orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + if orgCfg != nil { + ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + } else { + ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + } + ctx.HTML(http.StatusOK, tplLicensesEditPackage) } @@ -207,13 +341,27 @@ func LicensesEditPackagePost(ctx *context.Context) { return } + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be edited") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + pkg.Name = ctx.FormString("name") pkg.Description = ctx.FormString("description") durationDays, _ := strconv.Atoi(ctx.FormString("duration_days")) pkg.DurationDays = durationDays maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) pkg.MaxSites = maxSites - pkg.AllowedChannels = ctx.FormString("allowed_channels") + + channels := ctx.Req.Form["allowed_channels"] + if len(channels) > 0 { + data, _ := json.Marshal(channels) + pkg.AllowedChannels = string(data) + } else { + pkg.AllowedChannels = "" + } + pkg.IsActive = ctx.FormString("is_active") == "on" if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil { @@ -232,6 +380,16 @@ func LicensesDeletePackage(ctx *context.Context) { return } pkgID := ctx.PathParamInt64("id") + pkg, err := licenses.GetLicensePackageByID(ctx, pkgID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + if pkg.Name == licenses.MasterPackageName { + ctx.Flash.Error("Master package cannot be deleted") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil { ctx.ServerError("DeleteLicensePackage", err) return diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 408eb5960a..cbb78eca83 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -681,11 +681,12 @@ func handleSettingsPostAdvanced(ctx *context.Context) { updatePlatform = "joomla" } updateCfg := &licenses_model.UpdateStreamConfig{ - OwnerID: repo.OwnerID, - RepoID: repo.ID, - Platform: updatePlatform, - RequireKey: form.RequireUpdateKey, - StreamMode: "joomla", // inherit org default + OwnerID: repo.OwnerID, + RepoID: repo.ID, + Platform: updatePlatform, + LicensingEnabled: form.EnableLicensing, + RequireKey: form.RequireUpdateKey, + StreamMode: "joomla", // inherit org default } if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil { log.Error("SaveConfig: %v", err) diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 121112ddc5..21cac850f4 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -21,6 +21,9 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) if rawKey == "" { rawKey = ctx.FormString("download_key") } + if rawKey == "" { + rawKey = ctx.FormString("dlid") + } if rawKey == "" { // Check if this repo requires a key for update feed access. @@ -33,17 +36,19 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) return nil, true } - key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey) + domain := ctx.FormString("domain") + key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey, domain) if err != nil { log.Debug("License key validation failed: %v", err) return nil, false } - // Record usage. + // Update heartbeat and record usage. + _ = licenses.TouchHeartbeat(ctx, key.ID) _ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{ KeyID: key.ID, RepoID: ctx.Repo.Repository.ID, - Domain: ctx.FormString("domain"), + Domain: domain, IPAddress: ctx.RemoteAddr(), UserAgent: ctx.Req.UserAgent(), VersionFrom: ctx.FormString("version"), @@ -85,7 +90,11 @@ func ServeUpdatesXML(ctx *context.Context) { return } - xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...) + // Check if this repo requires a license key for update feed access. + repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID) + requireKey := repoCfg != nil && repoCfg.RequireKey + + xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, allowedChannels...) if err != nil { ctx.ServerError("GenerateJoomlaXML", err) return @@ -97,9 +106,19 @@ func ServeUpdatesXML(ctx *context.Context) { } // ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed -// from the repository's releases. +// from the repository's releases. Uses the same license key validation as the +// Joomla XML feed — all platforms share the same licensing system. func ServeDolibarrJSON(ctx *context.Context) { - data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository) + allowedChannels, ok := validateUpdateKey(ctx) + if !ok { + // Return empty updates for invalid keys. + ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(`{"module":"","updates":[]}`)) + return + } + + data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository, allowedChannels...) if err != nil { ctx.ServerError("GenerateDolibarrJSON", err) return diff --git a/routers/web/web.go b/routers/web/web.go index 2a9868596e..a1cb3cb32c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1107,7 +1107,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/licenses", func() { m.Get("", org.Licenses) m.Post("/packages", org.LicensesCreatePackage) + m.Get("/packages/{id}/edit", org.LicensesEditPackage) + m.Post("/packages/{id}/edit", org.LicensesEditPackagePost) + m.Post("/packages/{id}/delete", org.LicensesDeletePackage) m.Post("/keys/generate", org.LicensesGenerateKey) + m.Get("/keys/{id}/edit", org.LicensesEditKey) + m.Post("/keys/{id}/edit", org.LicensesEditKeyPost) m.Post("/keys/{id}/revoke", org.LicensesRevokeKey) }) @@ -1521,6 +1526,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/packages/{id}/edit", repo.LicensesEditPackagePost) m.Post("/packages/{id}/delete", repo.LicensesDeletePackage) m.Post("/keys/generate", repo.LicensesGenerateKey) + m.Get("/keys/{id}/edit", repo.LicensesEditKey) + m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost) m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": licenses diff --git a/services/context/repo.go b/services/context/repo.go index a7079af052..16cc38347b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -606,17 +606,22 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare return } - // Check if license packages exist for this repo's owner (enables Licenses tab). + // Check if licensing is enabled for this repo/org. + orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID) + repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID) + licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) || + (repoUpdateCfg != nil && repoUpdateCfg.LicensingEnabled) + numLicensePackages, _ := db.Count[licenses_model.LicensePackage](ctx, licenses_model.FindLicensePackageOptions{ OwnerID: repo.OwnerID, }) ctx.Data["NumLicensePackages"] = numLicensePackages - ctx.Data["EnableLicenses"] = numLicensePackages > 0 + ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0 + ctx.Data["LicensingEnabled"] = licensingEnabled ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() // Load repo update config for platform-aware UI. - repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID) if repoUpdateCfg != nil { ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform } else { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 651718a473..c3ffa63301 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -135,6 +135,7 @@ type RepoSettingForm struct { ReleasesVisibility string UpdatePlatform string RequireUpdateKey bool + EnableLicensing bool EnablePackages bool diff --git a/services/release/release.go b/services/release/release.go index c54a06f7e6..79fa2e3b4c 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -11,6 +11,7 @@ import ( "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" + licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container" @@ -166,6 +167,64 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel } // CreateRelease creates a new release of repository. +// ErrTagDoesNotMatchStream indicates a tag doesn't match any configured update stream. +type ErrTagDoesNotMatchStream struct { + TagName string +} + +func (e ErrTagDoesNotMatchStream) Error() string { + return fmt.Sprintf("tag %q does not match any configured update stream", e.TagName) +} + +// validateTagAgainstStreams checks that a release tag follows the update stream +// naming convention when licensing is active. Tags must start with a version +// prefix (v1.0.0) and any suffix must match a configured stream (e.g. -rc, -beta). +// When licensing is disabled, any tag is allowed. +func validateTagAgainstStreams(ctx context.Context, rel *repo_model.Release) error { + if rel.IsDraft || rel.IsTag { + return nil // drafts and lightweight tags are not validated + } + + // Load the repo to get the owner ID. + repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID) + if err != nil { + return nil // non-fatal, skip validation + } + + // Check if licensing is enabled at org or repo level. + orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID) + repoCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID) + licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) || + (repoCfg != nil && repoCfg.LicensingEnabled) + + if !licensingEnabled { + return nil // licensing off — any tag is fine + } + + // Check that the tag contains a stream-compatible suffix. + // Any prerelease suffix in the tag must match a configured stream suffix. + streams := licenses_model.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + lower := strings.ToLower(rel.TagName) + for _, s := range streams { + if s.Suffix == "" { + continue // stable stream matches everything + } + if strings.Contains(lower, s.Suffix) { + return nil // matches a configured stream + } + } + + // If the tag has a prerelease-looking suffix but it doesn't match any stream, reject. + for _, indicator := range []string{"-rc", "-beta", "-alpha", "-dev"} { + if strings.Contains(lower, indicator) { + return ErrTagDoesNotMatchStream{TagName: rel.TagName} + } + } + + // No prerelease suffix — this is a stable release, always allowed. + return nil +} + func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentUUIDs []string, msg string) error { has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName) if err != nil { @@ -176,6 +235,11 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU } } + // When licensing is enabled, validate that the tag matches a configured update stream. + if err := validateTagAgainstStreams(gitRepo.Ctx, rel); err != nil { + return err + } + if _, err = createTag(gitRepo.Ctx, gitRepo, rel, msg); err != nil { return err } diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go index 4b61544658..3146d0736e 100644 --- a/services/updateserver/dolibarr.go +++ b/services/updateserver/dolibarr.go @@ -35,7 +35,8 @@ type DolibarrUpdates struct { } // GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases. -func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, error) { +// allowedChannels optionally restricts output to specific channels (nil = all). +func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) (*DolibarrUpdates, error) { releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ RepoID: repo.ID, ListOptions: db.ListOptionsAll, @@ -73,8 +74,19 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do } } + // Build allowed channel set for filtering. + channelAllowed := make(map[string]bool) + if len(allowedChannels) > 0 { + for _, c := range allowedChannels { + channelAllowed[NormalizeChannel(c)] = true + } + } + for _, stream := range streams { ch := stream.Name + if len(channelAllowed) > 0 && !channelAllowed[ch] { + continue + } rel, ok := bestByChannel[ch] if !ok { continue diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 0846d4f91e..28ae7236c9 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -24,21 +24,27 @@ type xmlUpdates struct { } 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"` + 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"` + DownloadKey *xmlDownloadKey `xml:"downloadkey,omitempty"` +} + +type xmlDownloadKey struct { + Prefix string `xml:"prefix,attr"` + Suffix string `xml:"suffix,attr"` } type xmlInfoURL struct { @@ -120,7 +126,7 @@ func NormalizeChannel(ch string) string { // 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) { +func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey bool, allowedChannels ...string) ([]byte, error) { releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ RepoID: repo.ID, ListOptions: db.ListOptionsAll, @@ -234,6 +240,10 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed }, } + if requireKey { + u.DownloadKey = &xmlDownloadKey{Prefix: "&dlid=", Suffix: ""} + } + updates.Updates = append(updates.Updates, u) } diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index e76059b082..cb709441f1 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -52,7 +52,14 @@
- + {{if $.AvailableStreams}} + {{range $.AvailableStreams}} +
+ + +
+ {{end}} + {{end}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

@@ -82,14 +89,27 @@ {{.KeyCount}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} {{if $.IsRepoAdmin}} - +
{{$.CsrfTokenHtml}} -
+ {{if ne .Name "Master (Internal)"}} + + {{svg "octicon-pencil" 14}} + + {{if $.IsSiteAdmin}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} + {{end}} {{end}} @@ -116,6 +136,7 @@ {{ctx.Locale.Tr "repo.licenses.key_prefix"}} {{ctx.Locale.Tr "repo.licenses.licensee"}} {{ctx.Locale.Tr "repo.licenses.expires"}} + {{ctx.Locale.Tr "repo.licenses.last_seen"}} {{ctx.Locale.Tr "repo.licenses.status"}} {{if .IsRepoAdmin}}{{end}} @@ -125,13 +146,19 @@ {{.KeyPrefix}}{{if .IsInternal}} Master{{end}} {{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}} - {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}} + {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}} + {{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} {{if $.IsRepoAdmin}} - + + {{if not .IsInternal}} + + {{svg "octicon-pencil" 14}} + + {{end}}
{{$.CsrfTokenHtml}} -
diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl new file mode 100644 index 0000000000..9074f6d283 --- /dev/null +++ b/templates/org/licenses_edit_package.tmpl @@ -0,0 +1,59 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+

+ {{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.licenses.edit_package"}} +

+
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+
+
+ + +

0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}

+
+
+ + +

0 = unlimited

+
+
+ + {{if .AvailableStreams}} + {{range .AvailableStreams}} +
+ + +
+ {{end}} + {{end}} +

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

+
+
+
+
+ + +
+
+
+ + {{ctx.Locale.Tr "cancel"}} +
+
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index bcbb6ae323..36e3deac0f 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -25,7 +25,7 @@ {{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}} {{end}} - {{if .IsOrganizationMember}} + {{if and .IsOrganizationMember (or .OrgLicensingEnabled .IsLicensesPage)}} {{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}} diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index f8fd823bba..16bcad18c3 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -1,13 +1,38 @@ {{template "org/settings/layout_head" (dict "pageClass" "organization settings")}}
+ + {{/* ── Section 1: Licensing ── */}}

- {{ctx.Locale.Tr "org.settings.update_streams"}} + {{svg "octicon-key" 16}} {{ctx.Locale.Tr "org.settings.licensing"}}

-

{{ctx.Locale.Tr "org.settings.update_streams_desc"}}

{{.CsrfTokenHtml}} +

{{ctx.Locale.Tr "org.settings.licensing_desc"}}

+ +
+
+ + +
+

{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}

+
+ +
+
+ + +
+

{{ctx.Locale.Tr "org.settings.require_key_help"}}

+
+ +
+ + {{/* ── Section 2: Update Streams ── */}} +
{{svg "octicon-rss" 14}} {{ctx.Locale.Tr "org.settings.update_streams_heading"}}
+

{{ctx.Locale.Tr "org.settings.update_streams_desc"}}

+
@@ -26,8 +51,7 @@
-

{{ctx.Locale.Tr "org.settings.default_streams_joomla"}}

- +
@@ -39,12 +63,13 @@ {{range .EffectiveStreams}} - + {{end}}
{{ctx.Locale.Tr "org.settings.stream_name"}}
{{.Name}}{{if .Suffix}}{{.Suffix}}{{else}}(no suffix){{end}}{{if .Suffix}}{{.Suffix}}{{else}}{{ctx.Locale.Tr "org.settings.no_suffix"}}{{end}} {{.Description}}
+

{{ctx.Locale.Tr "org.settings.streams_tag_help"}}

diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index c4fa9207b3..631d4d536a 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -53,6 +53,7 @@ {{svg "octicon-plus" 14}} + {{if ne .Name "Master (Internal)"}} {{svg "octicon-pencil" 14}} @@ -64,6 +65,7 @@ {{end}} + {{end}} {{end}} @@ -110,7 +112,14 @@
- + {{if .AvailableStreams}} + {{range .AvailableStreams}} +
+ + +
+ {{end}} + {{end}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

@@ -133,6 +142,7 @@ {{ctx.Locale.Tr "repo.licenses.key_prefix"}} {{ctx.Locale.Tr "repo.licenses.licensee"}} {{ctx.Locale.Tr "repo.licenses.expires"}} + {{ctx.Locale.Tr "repo.licenses.last_seen"}} {{ctx.Locale.Tr "repo.licenses.status"}} {{if .IsRepoAdmin}}{{end}} @@ -140,12 +150,18 @@ {{range .LicenseKeys}} - {{.KeyPrefix}} + {{.KeyPrefix}}{{if .IsInternal}} Master{{end}} {{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}} - {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}} + {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}} + {{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} {{if $.IsRepoAdmin}} - + + {{if not .IsInternal}} + + {{svg "octicon-pencil" 14}} + + {{end}}
{{$.CsrfTokenHtml}} + {{ctx.Locale.Tr "cancel"}} +
+ +
+
+ +{{template "base/footer" .}} diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl index 5260e6216a..f6843e5a09 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -31,7 +31,14 @@
- + {{if .AvailableStreams}} + {{range .AvailableStreams}} +
+ + +
+ {{end}} + {{end}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index b9c79de630..d147f5d439 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -514,6 +514,20 @@

{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}

+ + +
+ + {{/* ── Licensing & Update Feeds ── */}} +
+ +
+ + +
+
+
+

{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}

+

{{ctx.Locale.Tr "repo.settings.update_platform_help"}}

+

{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}