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

{{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,11 +54,18 @@
-

0 = unlimited

+

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

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

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

@@ -62,7 +75,7 @@ {{end}} {{if .LicensePackages}} - +
@@ -76,20 +89,33 @@ {{range .LicensePackages}} - + {{if $.IsRepoAdmin}} - {{end}} @@ -110,12 +136,13 @@ {{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 ne .Name "Master (Internal)"}} + + {{svg "octicon-pencil" 14}} + + {{if $.IsSiteAdmin}} + + {{end}} + {{end}}
+
+ {{if .IsRepoAdmin}}{{end}} @@ -123,18 +150,24 @@ {{range .LicenseKeys}} - + - + + {{if $.IsRepoAdmin}} - {{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" .}} +
+

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

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

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

+
+
+ + +

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

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

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

+
+
+
+
+ + +
+

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

+
+
+ + {{ctx.Locale.Tr "cancel"}} +
+ +
+
+
+{{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"}} + {{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"}}

-
{{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"}}
{{.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}} -
- {{$.CsrfTokenHtml}} - -
+
+ {{if not .IsInternal}} + + {{svg "octicon-pencil" 14}} + + + {{end}} +
+
@@ -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/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"}}

{{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}} - +
@@ -39,30 +45,32 @@ {{range .LicensePackages}} - + {{if $.IsRepoAdmin}} {{end}} @@ -106,11 +114,18 @@
-

0 = unlimited

+

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

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

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

@@ -127,12 +142,13 @@ {{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}} + {{if ne .Name "Master (Internal)"}} {{svg "octicon-pencil" 14}} {{if $.IsSiteAdmin}} -
- {{$.CsrfTokenHtml}} - -
+ + {{end}} {{end}}
+
+ {{if .IsRepoAdmin}}{{end}} @@ -140,18 +156,24 @@ {{range .LicenseKeys}} - + - + + {{if $.IsRepoAdmin}} - {{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" .}} +
+

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

+
+
+ {{ctx.Locale.Tr "repo.licenses.key_prefix"}}: {{.Key.KeyPrefix}} +
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+
+
+ + +

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

+
+
+ + +

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

+
+
+
+ + +

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

+
+
+
+ + +
+

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

+
+
+ + {{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..85fa31d536 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -27,11 +27,18 @@
-

0 = unlimited

+

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

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

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

@@ -40,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}} 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_desc"}}

+

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

+

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

{{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"}}
{{.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}} -
- {{$.CsrfTokenHtml}} - -
+
+ {{if not .IsInternal}} + + {{svg "octicon-pencil" 14}} + + + {{end}} +