diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go
index d004427fb5..827ff5ddf2 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"`
}
@@ -75,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)
@@ -113,6 +128,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 +155,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 +171,11 @@ 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.
+// 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)
if err != nil {
@@ -160,5 +203,103 @@ 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")
+ }
+ } 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.
+ 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
}
+
+// 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_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/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/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/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/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/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..1ee6f8c4b5 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)",
@@ -2629,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",
@@ -2638,13 +2644,13 @@
"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.",
"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",
@@ -2654,6 +2660,32 @@
"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.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",
@@ -2795,18 +2827,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..d91927df29 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,13 @@ 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
+ 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 3587b66620..f51ac9ba83 100644
--- a/routers/web/org/licenses.go
+++ b/routers/web/org/licenses.go
@@ -6,9 +6,13 @@ package org
import (
"net/http"
"strconv"
+ "strings"
"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"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
@@ -16,6 +20,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
@@ -31,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)
@@ -66,8 +93,17 @@ 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["IsOrganizationOwner"] = ctx.Org.IsOwner
+ 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 +120,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,
}
@@ -130,10 +173,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.
@@ -157,9 +211,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")
@@ -178,3 +408,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/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/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..d9d32dfe9f 100644
--- a/routers/web/repo/licenses.go
+++ b/routers/web/repo/licenses.go
@@ -6,9 +6,12 @@ package repo
import (
"net/http"
"strconv"
+ "strings"
"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"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
@@ -16,6 +19,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
@@ -28,12 +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)
@@ -68,6 +94,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 +117,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,
}
@@ -130,16 +171,28 @@ 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
// Re-render the page with the new key displayed.
@@ -158,6 +211,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 +241,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 +322,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 +354,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 +393,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
@@ -240,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/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..6c2ce3907c 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1106,10 +1106,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/licenses", func() {
m.Get("", org.Licenses)
- m.Post("/packages", org.LicensesCreatePackage)
- m.Post("/keys/generate", org.LicensesGenerateKey)
- 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)
@@ -1516,13 +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.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/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..4f19318e7f 100644
--- a/templates/org/licenses.tmpl
+++ b/templates/org/licenses.tmpl
@@ -7,7 +7,10 @@
{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}
-
{{.NewMasterKey}}
+
+
+ {{svg "octicon-copy" 14}}
+
{{end}}
@@ -15,7 +18,10 @@
{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}
-
{{.NewKeyCreated}}
+
+
+ {{svg "octicon-copy" 14}}
+
{{end}}
@@ -48,11 +54,18 @@
{{ctx.Locale.Tr "repo.licenses.max_sites"}}
-
0 = unlimited
+
0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}
@@ -62,7 +75,7 @@
{{end}}
{{if .LicensePackages}}
-
+
{{ctx.Locale.Tr "repo.licenses.package_name"}}
@@ -76,20 +89,33 @@
{{range .LicensePackages}}
- {{.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}}
{{if $.IsRepoAdmin}}
-
-
@@ -110,12 +136,13 @@
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
-
+
{{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}}
@@ -123,18 +150,24 @@
{{range .LicenseKeys}}
- {{.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.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}}
-
-
- {{$.CsrfTokenHtml}}
-
- {{svg "octicon-x" 14}}
-
-
+
+ {{if not .IsInternal}}
+
+ {{svg "octicon-pencil" 14}}
+
+
+ {{svg "octicon-sync" 14}}
+
+ {{end}}
+
+ {{svg "octicon-x" 14}}
+
{{end}}
diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl
new file mode 100644
index 0000000000..fe0ea15e02
--- /dev/null
+++ b/templates/org/licenses_edit_package.tmpl
@@ -0,0 +1,60 @@
+{{template "base/head" .}}
+
+ {{template "org/header" .}}
+
+
+
+
+ {{.CsrfTokenHtml}}
+
+
+
+
{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})
+
+
0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}
+
+
+
{{ctx.Locale.Tr "repo.licenses.max_sites"}}
+
+
0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}
+
+
+
{{ctx.Locale.Tr "repo.licenses.channels"}}
+ {{if .AvailableStreams}}
+ {{range .AvailableStreams}}
+
+
+ {{.Name}}{{if .Description}} ({{.Description}}) {{end}}
+
+ {{end}}
+ {{end}}
+
{{ctx.Locale.Tr "repo.licenses.channels_help"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "repo.licenses.active"}}
+
+
{{ctx.Locale.Tr "repo.licenses.active_help_package"}}
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index bcbb6ae323..1199957581 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -25,9 +25,12 @@
{{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"}}
+ {{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/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_desc"}}
{{.CsrfTokenHtml}}
+ {{ctx.Locale.Tr "org.settings.licensing_desc"}}
+
+
+
+
+ {{ctx.Locale.Tr "org.settings.enable_licensing"}}
+
+
{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}
+
+
+
+
+
+ {{ctx.Locale.Tr "org.settings.require_key"}}
+
+
{{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"}}
+
{{ctx.Locale.Tr "org.settings.stream_mode"}}
@@ -26,8 +51,7 @@
{{ctx.Locale.Tr "org.settings.default_streams"}}
-
{{ctx.Locale.Tr "org.settings.default_streams_joomla"}}
-
+
{{ctx.Locale.Tr "org.settings.stream_name"}}
@@ -39,12 +63,13 @@
{{range .EffectiveStreams}}
{{.Name}}
- {{if .Suffix}}{{.Suffix}}{{else}}(no suffix) {{end}}
+ {{if .Suffix}}{{.Suffix}}{{else}}{{ctx.Locale.Tr "org.settings.no_suffix"}} {{end}}
{{.Description}}
{{end}}
+ {{ctx.Locale.Tr "org.settings.streams_tag_help"}}
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/licenses.tmpl b/templates/repo/licenses.tmpl
index c4fa9207b3..e03d5dad11 100644
--- a/templates/repo/licenses.tmpl
+++ b/templates/repo/licenses.tmpl
@@ -7,7 +7,10 @@
{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}
-
{{.NewMasterKey}}
+
+
+ {{svg "octicon-copy" 14}}
+
{{end}}
@@ -15,7 +18,10 @@
{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}
-
{{.NewKeyCreated}}
+
+
+ {{svg "octicon-copy" 14}}
+
{{end}}
@@ -25,7 +31,7 @@
{{if .LicensePackages}}
-
+
{{ctx.Locale.Tr "repo.licenses.package_name"}}
@@ -39,30 +45,32 @@
{{range .LicensePackages}}
- {{.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}}
{{if $.IsRepoAdmin}}
-
+
{{$.CsrfTokenHtml}}
+ {{if $.IsSiteAdmin}}
+
+ {{end}}
{{svg "octicon-plus" 14}}
+ {{if ne .Name "Master (Internal)"}}
{{svg "octicon-pencil" 14}}
{{if $.IsSiteAdmin}}
-
- {{$.CsrfTokenHtml}}
-
- {{svg "octicon-trash" 14}}
-
-
+
+ {{svg "octicon-trash" 14}}
+
+ {{end}}
{{end}}
{{end}}
@@ -106,11 +114,18 @@
{{ctx.Locale.Tr "repo.licenses.max_sites"}}
-
0 = unlimited
+
0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}
@@ -127,12 +142,13 @@
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
-
+
{{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,18 +156,24 @@
{{range .LicenseKeys}}
- {{.KeyPrefix}}
+ {{.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.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}}
-
-
- {{$.CsrfTokenHtml}}
-
- {{svg "octicon-x" 14}}
-
-
+
+ {{if not .IsInternal}}
+
+ {{svg "octicon-pencil" 14}}
+
+
+ {{svg "octicon-sync" 14}}
+
+ {{end}}
+
+ {{svg "octicon-x" 14}}
+
{{end}}
@@ -167,17 +189,17 @@
diff --git a/templates/repo/licenses_edit_key.tmpl b/templates/repo/licenses_edit_key.tmpl
new file mode 100644
index 0000000000..a340fb1006
--- /dev/null
+++ b/templates/repo/licenses_edit_key.tmpl
@@ -0,0 +1,56 @@
+{{template "base/head" .}}
+
+ {{template "repo/header" .}}
+
+
+
+
+ {{ctx.Locale.Tr "repo.licenses.key_prefix"}}: {{.Key.KeyPrefix}}
+
+
+ {{.CsrfTokenHtml}}
+
+
+
+
{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}
+
+
{{ctx.Locale.Tr "repo.licenses.domain_restriction_help"}}
+
+
+
{{ctx.Locale.Tr "repo.licenses.max_sites"}}
+
+
0 = {{ctx.Locale.Tr "repo.licenses.use_package_default"}}
+
+
+
+
{{ctx.Locale.Tr "repo.licenses.expires_at"}}
+
+
{{ctx.Locale.Tr "repo.licenses.expires_at_help"}}
+
+
+
+
+ {{ctx.Locale.Tr "repo.licenses.active"}}
+
+
{{ctx.Locale.Tr "repo.licenses.active_help_key"}}
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl
index 5260e6216a..85fa31d536 100644
--- a/templates/repo/licenses_edit_package.tmpl
+++ b/templates/repo/licenses_edit_package.tmpl
@@ -27,11 +27,18 @@
{{ctx.Locale.Tr "repo.licenses.max_sites"}}
-
0 = unlimited
+
0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}
@@ -40,6 +47,7 @@
{{ctx.Locale.Tr "repo.licenses.active"}}
+ {{ctx.Locale.Tr "repo.licenses.active_help_package"}}
{{ctx.Locale.Tr "save"}}
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}}
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"}}
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"}}
+
+
+ {{ctx.Locale.Tr "repo.settings.enable_licensing"}}
+
+
+
+
{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}
{{ctx.Locale.Tr "repo.settings.update_platform"}}
@@ -521,12 +535,14 @@
Dolibarr (JSON)
{{ctx.Locale.Tr "repo.settings.update_platform_both"}}
+
{{ctx.Locale.Tr "repo.settings.update_platform_help"}}
{{ctx.Locale.Tr "repo.settings.require_update_key"}}
+
{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}