From b77da17f381ab493bc22e167fd2d636d2fdf19ad Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 01:31:51 -0500 Subject: [PATCH 1/4] 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"}}

From ed79a481194ac1be68f21ead803328f836305b4e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 08:54:29 -0500 Subject: [PATCH 2/4] feat(licenses): UI/UX cleanup, permissions, renew, auto-domain, custom keys - Replace confirm() with Gitea modal system (link-action + data-modal-confirm) - Add confirmation modal to revoke key action - Fix clipboard copy to use data-clipboard-target with tooltip feedback - Localize all hardcoded English strings (feed labels, "unlimited", "Master") - Improve key creation flash with security-focused message + copy button - Add count badge to org licenses nav tab - Add icon to org settings navbar for update streams - Add help text to "Active" checkboxes explaining deactivation impact - Fix empty state message to reference UI creation (not just API) - Compact tables for denser license data display - Add orange "Master" label to master package rows - Conditional feed buttons on release page (only when licensing enabled) - Add TypeLicenses unit type with Read/Write/Admin team permissions - Route-level permission enforcement via RequireUnitReader/Writer - Add "Renew" action for license keys (extends by package duration) - Auto-associate domain on first heartbeat (lock-on-first-use) - Enforce max_sites limit during domain auto-association - Allow site admins and org owners to set custom license key values Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 80 +++++++++++++++++++++++ models/licenses/license_package.go | 5 ++ models/unit/unit.go | 15 ++++- options/locale/locale_en-US.json | 20 +++++- routers/web/org/home.go | 4 ++ routers/web/org/licenses.go | 58 ++++++++++++++-- routers/web/repo/licenses.go | 56 ++++++++++++++-- routers/web/web.go | 42 +++++++----- templates/org/licenses.tmpl | 46 +++++++------ templates/org/licenses_edit_package.tmpl | 3 +- templates/org/menu.tmpl | 3 + templates/org/settings/navbar.tmpl | 2 +- templates/repo/licenses.tmpl | 58 ++++++++-------- templates/repo/licenses_edit_key.tmpl | 1 + templates/repo/licenses_edit_package.tmpl | 3 +- templates/repo/release_tag_header.tmpl | 6 +- 16 files changed, 313 insertions(+), 89 deletions(-) diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 44f3cddcaa..827ff5ddf2 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -77,6 +77,19 @@ func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err return rawKey, nil } +// CreateLicenseKeyCustom stores a key with a user-provided raw key string. +// The raw key is hashed and stored — it will not be recoverable after this. +func CreateLicenseKeyCustom(ctx context.Context, key *LicenseKey, rawKey string) error { + key.KeyHash = HashKey(rawKey) + if len(rawKey) > 12 { + key.KeyPrefix = rawKey[:12] + "..." + } else { + key.KeyPrefix = rawKey + } + _, err := db.GetEngine(ctx).Insert(key) + return err +} + // GetLicenseKeyByHash looks up a key by its SHA-256 hash. func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) { key := new(LicenseKey) @@ -160,6 +173,8 @@ func DeleteLicenseKey(ctx context.Context, id int64) error { // Returns the key record and its associated package, or an error. // The domain parameter is optional — when provided, it is checked against // the key's DomainRestriction list and the MaxSites limit. +// On first heartbeat with a domain, if no DomainRestriction is set, the domain +// is automatically associated as the key's restriction (lock-on-first-use). func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey, *LicensePackage, error) { hash := HashKey(rawKey) key, err := GetLicenseKeyByHash(ctx, hash) @@ -201,6 +216,32 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey if !allowed { return nil, nil, fmt.Errorf("domain not allowed for this license key") } + } else { + // No domain restriction set — auto-associate on first heartbeat. + // Append this domain to the restriction list, enforcing max_sites. + maxSites := key.MaxSites + if maxSites == 0 { + maxSites = pkg.MaxSites + } + domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain) + if !domainKnown { + if maxSites > 0 { + uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID) + if err != nil { + return nil, nil, fmt.Errorf("failed to count domains: %w", err) + } + if uniqueDomains >= int64(maxSites) { + return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites) + } + } + // Append this domain to the key's restriction list. + _ = updateDomainRestriction(ctx, key.ID, domain) + if key.DomainRestriction == "" { + key.DomainRestriction = domain + } else { + key.DomainRestriction = key.DomainRestriction + "," + domain + } + } } // Site limit check: use key's MaxSites, fall back to package default. @@ -223,3 +264,42 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey return key, pkg, nil } + +// updateDomainRestriction appends a domain to a key's DomainRestriction field in the DB. +func updateDomainRestriction(ctx context.Context, keyID int64, domain string) error { + key, err := GetLicenseKeyByID(ctx, keyID) + if err != nil { + return err + } + if key.DomainRestriction == "" { + key.DomainRestriction = domain + } else { + key.DomainRestriction = key.DomainRestriction + "," + domain + } + _, err = db.GetEngine(ctx).ID(keyID).Cols("domain_restriction").Update(key) + return err +} + +// RenewLicenseKey extends the expiration of a key by the given number of days +// from the current expiry (or from now if already expired/no expiry set). +func RenewLicenseKey(ctx context.Context, keyID int64, days int) error { + key, err := GetLicenseKeyByID(ctx, keyID) + if err != nil { + return err + } + + now := timeutil.TimeStampNow() + var base timeutil.TimeStamp + if key.ExpiresUnix > 0 && key.ExpiresUnix > now { + // Key still valid — extend from current expiry. + base = key.ExpiresUnix + } else { + // Key expired or has no expiry — extend from now. + base = now + } + + key.ExpiresUnix = base + timeutil.TimeStamp(int64(days)*86400) + key.IsActive = true + _, err = db.GetEngine(ctx).ID(keyID).Cols("expires_unix", "is_active").Update(key) + return err +} diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index 24135b9b7c..52941d5956 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -83,6 +83,11 @@ func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error { return err } +// CountOrgPackages returns the number of license packages for an organization. +func CountOrgPackages(ctx context.Context, orgID int64) (int64, error) { + return db.GetEngine(ctx).Where("owner_id = ?", orgID).Count(new(LicensePackage)) +} + // DeleteLicensePackage deletes a license package by ID. func DeleteLicensePackage(ctx context.Context, id int64) error { _, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage)) diff --git a/models/unit/unit.go b/models/unit/unit.go index 1dd0013d19..0c555b0d7d 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -33,9 +33,7 @@ const ( TypeProjects // 8 Projects TypePackages // 9 Packages TypeActions // 10 Actions - - // FIXME: TEAM-UNIT-PERMISSION: the team unit "admin" permission's design is not right, when a new unit is added in the future, - // admin team won't inherit the correct admin permission for the new unit, need to have a complete fix before adding any new unit. + TypeLicenses // 11 Licenses ) // Value returns integer value for unit type (used by template) @@ -65,6 +63,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeLicenses, } // DefaultRepoUnits contains the default unit types @@ -328,6 +327,15 @@ var ( perm.AccessModeOwner, } + UnitLicenses = Unit{ + TypeLicenses, + "repo.licenses", + "/licenses", + "repo.licenses.desc", + 8, + perm.AccessModeOwner, + } + // Units contains all the units Units = map[Type]Unit{ TypeCode: UnitCode, @@ -340,6 +348,7 @@ var ( TypeProjects: UnitProjects, TypePackages: UnitPackages, TypeActions: UnitActions, + TypeLicenses: UnitLicenses, } ) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5d6d905fda..1ee6f8c4b5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2635,7 +2635,7 @@ "repo.licenses.active": "Active", "repo.licenses.inactive": "Inactive", "repo.licenses.none": "No License Packages", - "repo.licenses.none_desc": "License packages can be created via the API to gate access to update streams.", + "repo.licenses.none_desc": "Create a license package to start managing keys and gating update feeds.", "repo.licenses.issued_keys": "Issued Keys", "repo.licenses.key_prefix": "Key", "repo.licenses.licensee": "Licensee", @@ -2650,7 +2650,7 @@ "repo.licenses.package_created": "License package created successfully.", "repo.licenses.generate_key": "Generate Key", "repo.licenses.key_created": "License Key Created", - "repo.licenses.key_created_copy": "Copy this key now. It will not be shown again.", + "repo.licenses.key_created_copy": "This key is hashed before storage and cannot be retrieved later. Copy and store it securely now.", "repo.licenses.revoke": "Revoke", "repo.licenses.edit_package": "Edit License Package", "repo.licenses.delete_package": "Delete Package", @@ -2670,6 +2670,22 @@ "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.licenses.confirm_delete_package": "Delete this package? This action cannot be undone.", + "repo.licenses.confirm_revoke_key": "Revoke this license key? The licensee will immediately lose access to update feeds.", + "repo.licenses.feed_joomla_xml": "Joomla XML", + "repo.licenses.feed_dolibarr_json": "Dolibarr JSON", + "repo.licenses.feed_joomla_updates": "Joomla updates.xml", + "repo.licenses.feed_dolibarr_updates": "Dolibarr JSON", + "repo.licenses.master_label": "Master", + "repo.licenses.unlimited": "unlimited", + "repo.licenses.active_help_package": "Deactivating blocks new key creation and disables all issued keys.", + "repo.licenses.active_help_key": "Deactivating immediately blocks update feed access for this licensee.", + "repo.licenses.renew": "Renew", + "repo.licenses.key_renewed": "License key renewed for %d days.", + "repo.licenses.confirm_renew_key": "Renew this license key? The expiration will be extended by the package duration.", + "repo.licenses.desc": "License packages and keys for gating update feeds.", + "repo.licenses.custom_key_placeholder": "Custom key (optional)", + "repo.licenses.custom_key_help": "Leave empty to auto-generate. Site admins and org owners can set a custom key value.", "repo.release.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", diff --git a/routers/web/org/home.go b/routers/web/org/home.go index fd25172ab0..d91927df29 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -111,6 +111,10 @@ func home(ctx *context.Context, viewRepositories bool) { orgCfg, _ := licenses_model.GetOrgConfig(ctx, ctx.Org.Organization.ID) ctx.Data["OrgLicensingEnabled"] = orgCfg != nil && orgCfg.LicensingEnabled + if orgCfg != nil && orgCfg.LicensingEnabled { + numPkgs, _ := licenses_model.CountOrgPackages(ctx, ctx.Org.Organization.ID) + ctx.Data["NumOrgLicensePackages"] = numPkgs + } 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 1feb9bc988..1ab78a5dac 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -10,6 +10,8 @@ import ( "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm" + unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" @@ -54,8 +56,10 @@ func Licenses(ctx *context.Context) { org := ctx.Org.Organization ownerID := org.ID - // Auto-create master key if org owner. - if ctx.Org.IsOwner { + canWriteLicenses := ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unit_model.TypeLicenses) >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + + // Auto-create master key if has write access. + if canWriteLicenses { newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID) if err != nil { ctx.ServerError("EnsureMasterKey", err) @@ -89,7 +93,7 @@ func Licenses(ctx *context.Context) { return } ctx.Data["LicenseKeys"] = keys - ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner + ctx.Data["IsRepoAdmin"] = canWriteLicenses ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() ctx.Data["OrgLicensingEnabled"] = true @@ -168,10 +172,21 @@ func LicensesGenerateKey(ctx *context.Context) { key.ExpiresUnix = timeutil.TimeStamp(expires.Unix()) } - rawKey, err := licenses.CreateLicenseKey(ctx, key) - if err != nil { - ctx.ServerError("CreateLicenseKey", err) - return + // Site admins and org owners can manually set a custom key. + var rawKey string + customKey := strings.TrimSpace(ctx.FormString("custom_key")) + if customKey != "" && (ctx.IsUserSiteAdmin() || ctx.Org.IsOwner) { + if err := licenses.CreateLicenseKeyCustom(ctx, key, customKey); err != nil { + ctx.ServerError("CreateLicenseKeyCustom", err) + return + } + rawKey = customKey + } else { + rawKey, err = licenses.CreateLicenseKey(ctx, key) + if err != nil { + ctx.ServerError("CreateLicenseKey", err) + return + } } // Re-render with the new key shown. @@ -392,3 +407,32 @@ func LicensesRevokeKey(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked")) ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") } + +// LicensesRenewKey extends a license key's expiration by the package's duration. +func LicensesRenewKey(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + + days := pkg.DurationDays + if days == 0 { + days = 365 // default to 1 year for lifetime packages + } + + if err := licenses.RenewLicenseKey(ctx, keyID, days); err != nil { + ctx.ServerError("RenewLicenseKey", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.key_renewed", days)) + ctx.Redirect(ctx.Org.OrgLink + "/-/licenses") +} diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 7602342a3a..d9d32dfe9f 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -10,6 +10,7 @@ import ( "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" @@ -51,13 +52,14 @@ func Licenses(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.licenses") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true - ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() + canWriteLicenses := ctx.Repo.Permission.CanWrite(unit_model.TypeLicenses) || ctx.IsUserSiteAdmin() + ctx.Data["IsRepoAdmin"] = canWriteLicenses ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() ownerID := ctx.Repo.Repository.OwnerID // Auto-create master package + key if admin and none exist. - if ctx.Repo.Permission.IsAdmin() { + if canWriteLicenses { newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID) if err != nil { ctx.ServerError("EnsureMasterKey", err) @@ -169,16 +171,27 @@ func LicensesGenerateKey(ctx *context.Context) { key.ExpiresUnix = timeutil.TimeStamp(expires.Unix()) } - rawKey, err := licenses.CreateLicenseKey(ctx, key) - if err != nil { - ctx.ServerError("CreateLicenseKey", err) - return + // Site admins and org owners can manually set a custom key. + var rawKey string + customKey := strings.TrimSpace(ctx.FormString("custom_key")) + if customKey != "" && (ctx.IsUserSiteAdmin() || ctx.Repo.Permission.IsOwner()) { + if err := licenses.CreateLicenseKeyCustom(ctx, key, customKey); err != nil { + ctx.ServerError("CreateLicenseKeyCustom", err) + return + } + rawKey = customKey + } else { + rawKey, err = licenses.CreateLicenseKey(ctx, key) + if err != nil { + ctx.ServerError("CreateLicenseKey", err) + return + } } ctx.Data["Title"] = ctx.Tr("repo.licenses") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true - ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() + ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.CanWrite(unit_model.TypeLicenses) || ctx.IsUserSiteAdmin() ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() ctx.Data["NewKeyCreated"] = rawKey @@ -398,3 +411,32 @@ func LicensesDeletePackage(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.licenses.package_deleted")) ctx.Redirect(ctx.Repo.RepoLink + "/licenses") } + +// LicensesRenewKey extends a license key's expiration by the package's duration. +func LicensesRenewKey(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + + days := pkg.DurationDays + if days == 0 { + days = 365 // default to 1 year for lifetime packages + } + + if err := licenses.RenewLicenseKey(ctx, keyID, days); err != nil { + ctx.ServerError("RenewLicenseKey", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.key_renewed", days)) + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") +} diff --git a/routers/web/web.go b/routers/web/web.go index a1cb3cb32c..6c2ce3907c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1106,15 +1106,18 @@ 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) - }) + m.Group("", func() { + 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) + m.Post("/keys/{id}/renew", org.LicensesRenewKey) + }, reqUnitAccess(unit.TypeLicenses, perm.AccessModeWrite, true)) + }, reqUnitAccess(unit.TypeLicenses, perm.AccessModeRead, true)) m.Get("/repositories", org.Repositories) m.Get("/heatmap", user.DashboardHeatmap) @@ -1521,15 +1524,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // "/{username}/{reponame}": licenses page m.Group("/{username}/{reponame}/licenses", func() { m.Get("", repo.Licenses) - m.Post("/packages", repo.LicensesCreatePackage) - m.Get("/packages/{id}/edit", repo.LicensesEditPackage) - 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) + m.Group("", func() { + m.Post("/packages", repo.LicensesCreatePackage) + m.Get("/packages/{id}/edit", repo.LicensesEditPackage) + 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) + m.Post("/keys/{id}/renew", repo.LicensesRenewKey) + }, context.RequireUnitWriter(unit.TypeLicenses)) + }, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeLicenses)) // end "/{username}/{reponame}": licenses m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index cb709441f1..4f19318e7f 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -7,7 +7,10 @@
{{ctx.Locale.Tr "repo.licenses.master_key_created"}}

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

- {{.NewMasterKey}} +
+ + +
{{end}} @@ -15,7 +18,10 @@
{{ctx.Locale.Tr "repo.licenses.key_created"}}

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

- {{.NewKeyCreated}} +
+ + +
{{end}} @@ -48,7 +54,7 @@
-

0 = unlimited

+

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

@@ -69,7 +75,7 @@ {{end}} {{if .LicensePackages}} - +
@@ -83,16 +89,19 @@ {{range .LicensePackages}} - + {{if $.IsRepoAdmin}} @@ -130,7 +136,7 @@ {{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
-
{{ctx.Locale.Tr "repo.licenses.package_name"}}
{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{.Name}}{{if eq .Name "Master (Internal)"}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}}{{if .Description}}
{{.Description}}{{end}}
{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}} {{if .AllowedChannels}}{{.AllowedChannels}}{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}} {{.KeyCount}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} -
+ {{$.CsrfTokenHtml}} + {{if or $.IsSiteAdmin $.IsOrganizationOwner}} + + {{end}} @@ -102,12 +111,9 @@ {{svg "octicon-pencil" 14}} {{if $.IsSiteAdmin}} - - {{$.CsrfTokenHtml}} - -
+ {{end}} {{end}}
+
@@ -144,7 +150,7 @@ {{range .LicenseKeys}} - + @@ -155,13 +161,13 @@ {{svg "octicon-pencil" 14}} + {{end}} - - {{$.CsrfTokenHtml}} - - + {{end}} diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl index 9074f6d283..fe0ea15e02 100644 --- a/templates/org/licenses_edit_package.tmpl +++ b/templates/org/licenses_edit_package.tmpl @@ -27,7 +27,7 @@
-

0 = unlimited

+

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

@@ -47,6 +47,7 @@
+

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

diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 36e3deac0f..1199957581 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -28,6 +28,9 @@ {{if and .IsOrganizationMember (or .OrgLicensingEnabled .IsLicensesPage)}} {{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}} + {{if .NumOrgLicensePackages}} +
{{.NumOrgLicensePackages}}
+ {{end}}
{{end}} {{if and .IsRepoIndexerEnabled .CanReadCode}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 14d3bec42b..62464f51fa 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -26,7 +26,7 @@ {{end}} - {{ctx.Locale.Tr "org.settings.update_streams"}} + {{svg "octicon-key"}} {{ctx.Locale.Tr "org.settings.update_streams"}} {{if .EnableActions}}
diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 631d4d536a..e03d5dad11 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -7,7 +7,10 @@
{{ctx.Locale.Tr "repo.licenses.master_key_created"}}

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

- {{.NewMasterKey}} +
+ + +
{{end}} @@ -15,7 +18,10 @@
{{ctx.Locale.Tr "repo.licenses.key_created"}}

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

- {{.NewKeyCreated}} +
+ + +
{{end}} @@ -25,7 +31,7 @@
{{if .LicensePackages}} -
{{ctx.Locale.Tr "repo.licenses.key_prefix"}}
{{.KeyPrefix}}{{if .IsInternal}} Master{{end}}{{.KeyPrefix}}{{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} {{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{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}}
+
@@ -39,16 +45,19 @@ {{range .LicensePackages}} - + {{if $.IsRepoAdmin}} @@ -108,7 +114,7 @@
-

0 = unlimited

+

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

@@ -136,7 +142,7 @@ {{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
-
{{ctx.Locale.Tr "repo.licenses.package_name"}}
{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{.Name}}{{if eq .Name "Master (Internal)"}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}}{{if .Description}}
{{.Description}}{{end}}
{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}} {{if .AllowedChannels}}{{.AllowedChannels}}{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}} {{.KeyCount}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} -
+ {{$.CsrfTokenHtml}} + {{if $.IsSiteAdmin}} + + {{end}} @@ -58,12 +67,9 @@ {{svg "octicon-pencil" 14}} {{if $.IsSiteAdmin}} - - {{$.CsrfTokenHtml}} - -
+ {{end}} {{end}}
+
@@ -150,7 +156,7 @@ {{range .LicenseKeys}} - + @@ -161,13 +167,13 @@ {{svg "octicon-pencil" 14}} + {{end}} - - {{$.CsrfTokenHtml}} - - + {{end}} @@ -183,17 +189,17 @@
- +
- - + +
- +
- - + +
diff --git a/templates/repo/licenses_edit_key.tmpl b/templates/repo/licenses_edit_key.tmpl index 10c6706a2c..a340fb1006 100644 --- a/templates/repo/licenses_edit_key.tmpl +++ b/templates/repo/licenses_edit_key.tmpl @@ -43,6 +43,7 @@ +

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

diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl index f6843e5a09..85fa31d536 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -27,7 +27,7 @@
-

0 = unlimited

+

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

@@ -47,6 +47,7 @@
+

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

diff --git a/templates/repo/release_tag_header.tmpl b/templates/repo/release_tag_header.tmpl index 3e83f132d5..2ac848884b 100644 --- a/templates/repo/release_tag_header.tmpl +++ b/templates/repo/release_tag_header.tmpl @@ -16,15 +16,15 @@ {{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}} {{end}} - {{if not .PageIsTagList}} + {{if and (not .PageIsTagList) .LicensingEnabled}} {{if or (eq .RepoUpdatePlatform "joomla") (eq .RepoUpdatePlatform "both") (eq .RepoUpdatePlatform "")}} - {{svg "octicon-download" 16}} Joomla XML + {{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_joomla_xml"}} {{end}} {{if or (eq .RepoUpdatePlatform "dolibarr") (eq .RepoUpdatePlatform "both")}} - {{svg "octicon-download" 16}} Dolibarr JSON + {{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_dolibarr_json"}} {{end}} {{end}} From 1a4d0739db7dc69b6eb191d1b6978fab953705fd Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 09:00:49 -0500 Subject: [PATCH 3/4] fix(permissions): admin teams implicitly inherit access to all unit types Admin-level teams (team.authorize >= Admin) now implicitly get admin access to all unit types in UnitMaxAccess(), even without explicit TeamUnit records. This resolves the long-standing TEAM-UNIT-PERMISSION issue where adding new units (like TypeLicenses) left existing admin teams without access. Resolves: #304 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/organization/team_list.go | 5 +++++ models/organization/team_repo.go | 2 +- routers/web/org/teams.go | 16 +++------------- templates/org/team/sidebar.tmpl | 2 +- templates/repo/settings/collaboration.tmpl | 2 +- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/models/organization/team_list.go b/models/organization/team_list.go index 329b17c47b..fa572ff6e1 100644 --- a/models/organization/team_list.go +++ b/models/organization/team_list.go @@ -31,6 +31,11 @@ func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode { if team.IsOwnerTeam() { return perm.AccessModeOwner } + // Admin-level teams implicitly have admin access to all units, + // even units added after the team was created (no TeamUnit record needed). + if team.HasAdminAccess() && maxAccess < perm.AccessModeAdmin { + maxAccess = perm.AccessModeAdmin + } for _, teamUnit := range team.Units { if teamUnit.Type != tp { continue diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go index 3fbd462c63..0bc3e15735 100644 --- a/models/organization/team_repo.go +++ b/models/organization/team_repo.go @@ -52,7 +52,7 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error { // GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit. // This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control. -// FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details +// Note: admin-level teams (authorize >= Admin) implicitly have access to all units. func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teams []*Team, err error) { teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...) if err != nil { diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index acd80808d2..b340a4a0d4 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -324,19 +324,9 @@ func NewTeam(ctx *context.Context) { ctx.HTML(http.StatusOK, tplTeamNew) } -// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future, -// The existing teams won't inherit the correct admin permission for the new unit. -// The full history is like this: -// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission. -// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs. -// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner -// - Sometimes, "team unit" is used not really used and "team unit" is used. -// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both. -// -// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions. -// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units. -// -// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones. +// getUnitPerms parses the unit permission form values for a team. +// Note: admin teams (team.authorize >= Admin) implicitly have admin access to +// all units via UnitMaxAccess(), so explicit TeamUnit records are supplementary. func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode { unitPerms := make(map[unit_model.Type]perm.AccessMode) for _, ut := range unit_model.AllRepoUnitTypes { diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 750a20ec11..8f415d874f 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -47,7 +47,7 @@

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

{{ctx.Locale.Tr "org.teams.write_permission_desc"}} {{else if (eq .Team.AccessMode 3)}} - {{/* FIXME: here might not right, see "FIXME: TEAM-UNIT-PERMISSION", new units might not have correct admin permission*/}} + {{/* Admin teams implicitly have admin access to all units (including newly added ones) */}}

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

{{ctx.Locale.Tr "org.teams.admin_permission_desc"}} {{else}} diff --git a/templates/repo/settings/collaboration.tmpl b/templates/repo/settings/collaboration.tmpl index d10ae06da5..90d18b833a 100644 --- a/templates/repo/settings/collaboration.tmpl +++ b/templates/repo/settings/collaboration.tmpl @@ -62,7 +62,7 @@ {{.Name}}
- {{/*FIXME: TEAM-UNIT-PERMISSION this display is not right, search the fixme keyword to see more details */}} + {{/* Team access mode: 0=per-unit, 1=read, 2=write, 3=admin (all units), 4=owner */}} {{svg "octicon-shield-lock"}} {{if eq .AccessMode 0}} {{ctx.Locale.Tr "repo.settings.collaboration.per_unit"}} From c20139393de5dc941f70bfbca999bf8b07a621b1 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 09:04:12 -0500 Subject: [PATCH 4/4] fix(licenses): pass IsOrganizationOwner to org licenses template Required for the custom key input field visibility check. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/org/licenses.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 1ab78a5dac..f51ac9ba83 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -95,6 +95,7 @@ func Licenses(ctx *context.Context) { ctx.Data["LicenseKeys"] = keys ctx.Data["IsRepoAdmin"] = canWriteLicenses ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() + ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner ctx.Data["OrgLicensingEnabled"] = true orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
{{ctx.Locale.Tr "repo.licenses.key_prefix"}}
{{.KeyPrefix}}{{if .IsInternal}} Master{{end}}{{.KeyPrefix}}{{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} {{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{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}}