Merge pull request 'feat(licenses): UI/UX cleanup, permissions system, and key management improvements' (#306) from dev into main
Deploy MokoGitea / deploy (push) Failing after 3m35s
Deploy MokoGitea / deploy (push) Failing after 3m35s
This commit was merged in pull request #306.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+12
-3
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
+267
-8
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-11
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -135,6 +135,7 @@ type RepoSettingForm struct {
|
||||
ReleasesVisibility string
|
||||
UpdatePlatform string
|
||||
RequireUpdateKey bool
|
||||
EnableLicensing bool
|
||||
|
||||
EnablePackages bool
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+53
-20
@@ -7,7 +7,10 @@
|
||||
<div class="ui info message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
|
||||
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
|
||||
<div class="ui action input tw-w-full tw-mt-2">
|
||||
<input class="js-new-master-key" type="text" readonly value="{{.NewMasterKey}}" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-new-master-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -15,7 +18,10 @@
|
||||
<div class="ui success message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
|
||||
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
|
||||
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
|
||||
<div class="ui action input tw-w-full tw-mt-2">
|
||||
<input class="js-new-license-key" type="text" readonly value="{{.NewKeyCreated}}" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-new-license-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -48,11 +54,18 @@
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="0" min="0">
|
||||
<p class="help">0 = unlimited</p>
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||
<input name="allowed_channels" placeholder="stable,release-candidate">
|
||||
{{if $.AvailableStreams}}
|
||||
{{range $.AvailableStreams}}
|
||||
<div class="ui checkbox tw-mr-4 tw-mb-2">
|
||||
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,7 +75,7 @@
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .LicensePackages}}
|
||||
<table class="ui table">
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
|
||||
@@ -76,20 +89,33 @@
|
||||
<tbody>
|
||||
{{range .LicensePackages}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td><strong>{{.Name}}</strong>{{if eq .Name "Master (Internal)"}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
|
||||
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
|
||||
<td>{{.KeyCount}}</td>
|
||||
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||
{{if $.IsRepoAdmin}}
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline">
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline tw-flex tw-gap-1 tw-items-center">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="package_id" value="{{.ID}}">
|
||||
<button class="ui tiny primary button" type="submit">
|
||||
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
|
||||
{{if or $.IsSiteAdmin $.IsOrganizationOwner}}
|
||||
<input type="text" name="custom_key" placeholder="{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}" class="tw-w-32 tw-text-xs" title="{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}">
|
||||
{{end}}
|
||||
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||
{{svg "octicon-plus" 14}}
|
||||
</button>
|
||||
</form>
|
||||
{{if ne .Name "Master (Internal)"}}
|
||||
<a class="ui tiny button" href="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</a>
|
||||
{{if $.IsSiteAdmin}}
|
||||
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
@@ -110,12 +136,13 @@
|
||||
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<table class="ui table">
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||
</tr>
|
||||
@@ -123,18 +150,24 @@
|
||||
<tbody>
|
||||
{{range .LicenseKeys}}
|
||||
<tr>
|
||||
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</span>{{end}}</td>
|
||||
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}</td>
|
||||
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
|
||||
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
|
||||
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
|
||||
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
|
||||
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||
{{if $.IsRepoAdmin}}
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red button" type="submit">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</form>
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
{{if not .IsInternal}}
|
||||
<a class="ui tiny button" href="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_key"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</a>
|
||||
<button class="ui tiny green button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/renew" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_renew_key"}}" title="{{ctx.Locale.Tr "repo.licenses.renew"}}">
|
||||
{{svg "octicon-sync" 14}}
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content organization">
|
||||
{{template "org/header" .}}
|
||||
<div class="ui container">
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.licenses.edit_package"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages/{{.Package.ID}}/edit">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="two fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
|
||||
<input name="name" required value="{{.Package.Name}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
|
||||
<input name="description" value="{{.Package.Description}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
|
||||
<input name="duration_days" type="number" value="{{.Package.DurationDays}}" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="{{.Package.MaxSites}}" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||
{{if .AvailableStreams}}
|
||||
{{range .AvailableStreams}}
|
||||
<div class="ui checkbox tw-mr-4 tw-mb-2">
|
||||
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="is_active" type="checkbox" {{if .Package.IsActive}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_package"}}</p>
|
||||
</div>
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
|
||||
<a class="ui button" href="{{$.Org.HomeLink}}/-/licenses">{{ctx.Locale.Tr "cancel"}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -25,9 +25,12 @@
|
||||
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .IsOrganizationMember}}
|
||||
{{if and .IsOrganizationMember (or .OrgLicensingEnabled .IsLicensesPage)}}
|
||||
<a class="{{if .IsLicensesPage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/licenses">
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||
{{if .NumOrgLicensePackages}}
|
||||
<div class="ui small label">{{.NumOrgLicensePackages}}</div>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if and .IsRepoIndexerEnabled .CanReadCode}}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsUpdateStreams}}active {{end}}item" href="{{.OrgLink}}/settings/update-streams">
|
||||
{{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
</a>
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
{{template "org/settings/layout_head" (dict "pageClass" "organization settings")}}
|
||||
<div class="org-setting-content">
|
||||
|
||||
{{/* ── Section 1: Licensing ── */}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.update_streams"}}
|
||||
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "org.settings.licensing"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p>{{ctx.Locale.Tr "org.settings.update_streams_desc"}}</p>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/update-streams">
|
||||
{{.CsrfTokenHtml}}
|
||||
|
||||
<p>{{ctx.Locale.Tr "org.settings.licensing_desc"}}</p>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="licensing_enabled" type="checkbox" {{if .StreamConfig.LicensingEnabled}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "org.settings.enable_licensing"}}</strong></label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_key" type="checkbox" {{if .StreamConfig.RequireKey}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "org.settings.require_key"}}</strong></label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.require_key_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
|
||||
{{/* ── Section 2: Update Streams ── */}}
|
||||
<h5>{{svg "octicon-rss" 14}} {{ctx.Locale.Tr "org.settings.update_streams_heading"}}</h5>
|
||||
<p>{{ctx.Locale.Tr "org.settings.update_streams_desc"}}</p>
|
||||
|
||||
<div class="grouped fields">
|
||||
<label>{{ctx.Locale.Tr "org.settings.stream_mode"}}</label>
|
||||
<div class="field">
|
||||
@@ -26,8 +51,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.default_streams"}}</label>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.default_streams_joomla"}}</p>
|
||||
<table class="ui small table">
|
||||
<table class="ui small compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.stream_name"}}</th>
|
||||
@@ -39,12 +63,13 @@
|
||||
{{range .EffectiveStreams}}
|
||||
<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td>{{if .Suffix}}<code>{{.Suffix}}</code>{{else}}<em>(no suffix)</em>{{end}}</td>
|
||||
<td>{{if .Suffix}}<code>{{.Suffix}}</code>{{else}}<span class="text grey">{{ctx.Locale.Tr "org.settings.no_suffix"}}</span>{{end}}</td>
|
||||
<td>{{.Description}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.streams_tag_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
|
||||
{{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) */}}
|
||||
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
|
||||
{{ctx.Locale.Tr "org.teams.admin_permission_desc"}}
|
||||
{{else}}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
<div class="ui info message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
|
||||
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
|
||||
<div class="ui action input tw-w-full tw-mt-2">
|
||||
<input class="js-new-master-key" type="text" readonly value="{{.NewMasterKey}}" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-new-master-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -15,7 +18,10 @@
|
||||
<div class="ui success message">
|
||||
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
|
||||
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
|
||||
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
|
||||
<div class="ui action input tw-w-full tw-mt-2">
|
||||
<input class="js-new-license-key" type="text" readonly value="{{.NewKeyCreated}}" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-new-license-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -25,7 +31,7 @@
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .LicensePackages}}
|
||||
<table class="ui table">
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
|
||||
@@ -39,30 +45,32 @@
|
||||
<tbody>
|
||||
{{range .LicensePackages}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td><strong>{{.Name}}</strong>{{if eq .Name "Master (Internal)"}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
|
||||
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
|
||||
<td>{{.KeyCount}}</td>
|
||||
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||
{{if $.IsRepoAdmin}}
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline">
|
||||
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline tw-flex tw-gap-1 tw-items-center">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="package_id" value="{{.ID}}">
|
||||
{{if $.IsSiteAdmin}}
|
||||
<input type="text" name="custom_key" placeholder="{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}" class="tw-w-32 tw-text-xs" title="{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}">
|
||||
{{end}}
|
||||
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||
{{svg "octicon-plus" 14}}
|
||||
</button>
|
||||
</form>
|
||||
{{if ne .Name "Master (Internal)"}}
|
||||
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</a>
|
||||
{{if $.IsSiteAdmin}}
|
||||
<form method="post" action="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" class="tw-inline" onsubmit="return confirm('Delete this package? This action cannot be undone.')">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
</button>
|
||||
</form>
|
||||
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
@@ -106,11 +114,18 @@
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="0" min="0">
|
||||
<p class="help">0 = unlimited</p>
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||
<input name="allowed_channels" placeholder="stable,release-candidate">
|
||||
{{if .AvailableStreams}}
|
||||
{{range .AvailableStreams}}
|
||||
<div class="ui checkbox tw-mr-4 tw-mb-2">
|
||||
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,12 +142,13 @@
|
||||
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<table class="ui table">
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||
</tr>
|
||||
@@ -140,18 +156,24 @@
|
||||
<tbody>
|
||||
{{range .LicenseKeys}}
|
||||
<tr>
|
||||
<td><code>{{.KeyPrefix}}</code></td>
|
||||
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}</td>
|
||||
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
|
||||
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
|
||||
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
|
||||
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
|
||||
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||
{{if $.IsRepoAdmin}}
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</form>
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
{{if not .IsInternal}}
|
||||
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/keys/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_key"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</a>
|
||||
<button class="ui tiny green button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/renew" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_renew_key"}}" title="{{ctx.Locale.Tr "repo.licenses.renew"}}">
|
||||
{{svg "octicon-sync" 14}}
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
@@ -167,17 +189,17 @@
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="field">
|
||||
<label>Joomla updates.xml</label>
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.feed_joomla_updates"}}</label>
|
||||
<div class="ui action input tw-w-full">
|
||||
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
|
||||
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
|
||||
<input class="js-feed-url-joomla" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-joomla" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-mt-2">
|
||||
<label>Dolibarr JSON</label>
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.feed_dolibarr_updates"}}</label>
|
||||
<div class="ui action input tw-w-full">
|
||||
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
|
||||
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
|
||||
<input class="js-feed-url-dolibarr" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
|
||||
<button class="ui button" data-clipboard-target=".js-feed-url-dolibarr" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<h4 class="ui top attached header">
|
||||
{{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.licenses.edit_key"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="tw-mb-4">
|
||||
<strong>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}:</strong> <code>{{.Key.KeyPrefix}}</code>
|
||||
</div>
|
||||
<form class="ui form" method="post" action="{{.FormAction}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.licensee_name"}}</label>
|
||||
<input name="licensee_name" value="{{.Key.LicenseeName}}" placeholder="Customer name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.licensee_email"}}</label>
|
||||
<input name="licensee_email" type="email" value="{{.Key.LicenseeEmail}}" placeholder="customer@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}</label>
|
||||
<input name="domain_restriction" value="{{.Key.DomainRestriction}}" placeholder="example.com,example.org">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_restriction_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="{{.Key.MaxSites}}" min="0">
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.use_package_default"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.expires_at"}}</label>
|
||||
<input name="expires_at" type="date" value="{{.ExpiresDate}}">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.expires_at_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="is_active" type="checkbox" {{if .Key.IsActive}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_key"}}</p>
|
||||
</div>
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
|
||||
<a class="ui button" href="{{.BackLink}}">{{ctx.Locale.Tr "cancel"}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -27,11 +27,18 @@
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||
<input name="max_sites" type="number" value="{{.Package.MaxSites}}" min="0">
|
||||
<p class="help">0 = unlimited</p>
|
||||
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||
<input name="allowed_channels" value="{{.Package.AllowedChannels}}">
|
||||
{{if .AvailableStreams}}
|
||||
{{range .AvailableStreams}}
|
||||
<div class="ui checkbox tw-mr-4 tw-mb-2">
|
||||
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
|
||||
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,6 +47,7 @@
|
||||
<input name="is_active" type="checkbox" {{if .Package.IsActive}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_package"}}</p>
|
||||
</div>
|
||||
<div class="field tw-mt-4">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if not .PageIsTagList}}
|
||||
{{if and (not .PageIsTagList) .LicensingEnabled}}
|
||||
{{if or (eq .RepoUpdatePlatform "joomla") (eq .RepoUpdatePlatform "both") (eq .RepoUpdatePlatform "")}}
|
||||
<a class="ui small button" href="{{.RepoLink}}/updates.xml" target="_blank">
|
||||
{{svg "octicon-download" 16}} Joomla XML
|
||||
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_joomla_xml"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if or (eq .RepoUpdatePlatform "dolibarr") (eq .RepoUpdatePlatform "both")}}
|
||||
<a class="ui small button" href="{{.RepoLink}}/updates/dolibarr.json" target="_blank">
|
||||
{{svg "octicon-download" 16}} Dolibarr JSON
|
||||
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_dolibarr_json"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{{.Name}}
|
||||
</a>
|
||||
<div class="item-body flex-text-block">
|
||||
{{/*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"}}
|
||||
|
||||
@@ -514,6 +514,20 @@
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{{/* ── Licensing & Update Feeds ── */}}
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.licensing_section"}}</label>
|
||||
<div class="ui checkbox">
|
||||
<input class="enable-system" name="enable_licensing" type="checkbox" data-target="#licensing_options_box" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field tw-pl-4{{if not (and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled)}} disabled{{end}}" id="licensing_options_box">
|
||||
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
|
||||
<select name="update_platform" class="ui dropdown">
|
||||
@@ -521,12 +535,14 @@
|
||||
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
|
||||
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user