feat(licenses): implement full commercial license management system
Add key editing, domain enforcement, purchase webhooks, public
validation API, channels multiselect, Joomla downloadkey element,
licensing feature toggle, unified update system, release tag
enforcement, heartbeat tracking, and improved settings UX.
Phase 1: Full key display with AbsoluteShort dates, master package
protection (hide edit/delete in UI, reject in handlers).
Phase 2: Key edit page with template, handlers, and routes for both
repo and org levels. Master keys redirect away.
Phase 3: Domain restriction checking against CSV allowlist,
MaxSites enforcement via CountUniqueDomainsByKey and
IsDomainKnownForKey, dlid query param support for Joomla.
Phase 4: Purchase webhook (POST /license-keys/purchase) with
PaymentRef idempotency. Public validation endpoint
(POST /license-keys/validate) outside auth middleware.
PATCH /license-keys/{id} for API key editing.
Phase 5: Channels multiselect using org UpdateStreamConfig streams
rendered as checkboxes, stored as JSON arrays.
Additional: downloadkey XML element, LicensingEnabled toggle on
UpdateStreamConfig, Dolibarr endpoint unified with key validation,
release tag suffix enforcement, LastHeartbeatUnix field with
TouchHeartbeat, and cleaned-up settings pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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"`
|
||||
}
|
||||
@@ -113,6 +115,22 @@ func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseK
|
||||
return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
|
||||
}
|
||||
|
||||
// GetLicenseKeyByPaymentRef looks up a key by its payment reference (idempotency).
|
||||
func GetLicenseKeyByPaymentRef(ctx context.Context, paymentRef string) (*LicenseKey, error) {
|
||||
if paymentRef == "" {
|
||||
return nil, db.ErrNotExist{Resource: "LicenseKey"}
|
||||
}
|
||||
key := new(LicenseKey)
|
||||
has, err := db.GetEngine(ctx).Where("payment_ref = ?", paymentRef).Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, db.ErrNotExist{Resource: "LicenseKey"}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// CountKeysByPackage returns the number of keys for a package.
|
||||
func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey))
|
||||
@@ -124,6 +142,14 @@ func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchHeartbeat updates the last heartbeat timestamp for a key.
|
||||
func TouchHeartbeat(ctx context.Context, keyID int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(keyID).
|
||||
Cols("last_heartbeat_unix").
|
||||
Update(&LicenseKey{LastHeartbeatUnix: timeutil.TimeStampNow()})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteLicenseKey deletes a license key by ID.
|
||||
func DeleteLicenseKey(ctx context.Context, id int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
|
||||
@@ -132,7 +158,9 @@ func DeleteLicenseKey(ctx context.Context, id int64) error {
|
||||
|
||||
// ValidateLicenseKey validates a raw key string against the database.
|
||||
// Returns the key record and its associated package, or an error.
|
||||
func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, error) {
|
||||
// The domain parameter is optional — when provided, it is checked against
|
||||
// the key's DomainRestriction list and the MaxSites limit.
|
||||
func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey, *LicensePackage, error) {
|
||||
hash := HashKey(rawKey)
|
||||
key, err := GetLicenseKeyByHash(ctx, hash)
|
||||
if err != nil {
|
||||
@@ -160,5 +188,38 @@ func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *Licen
|
||||
return nil, nil, fmt.Errorf("license package is deactivated")
|
||||
}
|
||||
|
||||
// Domain restriction check — skip for internal/master keys.
|
||||
if domain != "" && !key.IsInternal {
|
||||
if key.DomainRestriction != "" {
|
||||
allowed := false
|
||||
for _, d := range strings.Split(key.DomainRestriction, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(d), domain) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, nil, fmt.Errorf("domain not allowed for this license key")
|
||||
}
|
||||
}
|
||||
|
||||
// Site limit check: use key's MaxSites, fall back to package default.
|
||||
maxSites := key.MaxSites
|
||||
if maxSites == 0 {
|
||||
maxSites = pkg.MaxSites
|
||||
}
|
||||
if maxSites > 0 {
|
||||
uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to count domains: %w", err)
|
||||
}
|
||||
// Allow if this domain is already recorded, or if under the limit.
|
||||
domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
|
||||
if !domainKnown && uniqueDomains >= int64(maxSites) {
|
||||
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return key, pkg, nil
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)",
|
||||
@@ -2638,7 +2644,7 @@
|
||||
"repo.licenses.new_package": "New Package",
|
||||
"repo.licenses.description": "Description",
|
||||
"repo.licenses.max_sites": "Max Sites",
|
||||
"repo.licenses.channels_help": "Comma-separated channel names (e.g. stable,release-candidate). Leave empty for all channels.",
|
||||
"repo.licenses.channels_help": "Select which update channels this package grants access to. Leave all unchecked for all channels.",
|
||||
"repo.licenses.create_package": "Create License Package",
|
||||
"repo.licenses.create_new_package": "Create New License Package",
|
||||
"repo.licenses.package_created": "License package created successfully.",
|
||||
@@ -2654,6 +2660,16 @@
|
||||
"repo.licenses.master_key_created": "Master License Key Created",
|
||||
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
|
||||
"repo.licenses.update_feeds": "Update Feed URLs",
|
||||
"repo.licenses.edit_key": "Edit License Key",
|
||||
"repo.licenses.licensee_name": "Licensee Name",
|
||||
"repo.licenses.licensee_email": "Licensee Email",
|
||||
"repo.licenses.domain_restriction": "Domain Restriction",
|
||||
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty for no restriction.",
|
||||
"repo.licenses.use_package_default": "use package default",
|
||||
"repo.licenses.expires_at": "Expires At",
|
||||
"repo.licenses.expires_at_help": "Leave empty for no expiry (lifetime).",
|
||||
"repo.licenses.key_updated": "License key updated.",
|
||||
"repo.licenses.last_seen": "Last Seen",
|
||||
"repo.release.draft": "Draft",
|
||||
"repo.release.prerelease": "Pre-Release",
|
||||
"repo.release.stable": "Stable",
|
||||
@@ -2795,18 +2811,26 @@
|
||||
"org.form.create_org_not_allowed": "You are not allowed to create an organization.",
|
||||
"org.settings": "Settings",
|
||||
"org.settings.options": "Organization",
|
||||
"org.settings.update_streams": "Licenses & Update Streams",
|
||||
"org.settings.update_streams_desc": "Configure the default update streams for all repositories in this organization. Repos can override with their own settings.",
|
||||
"org.settings.update_streams": "Licensing & Update Streams",
|
||||
"org.settings.licensing": "Licensing",
|
||||
"org.settings.licensing_desc": "Control commercial license key management and gated update feeds across all repositories in this organization.",
|
||||
"org.settings.enable_licensing": "Enable licensing for this organization",
|
||||
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
|
||||
"org.settings.require_key": "Require license key for all update feeds",
|
||||
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
|
||||
"org.settings.update_streams_heading": "Update Streams",
|
||||
"org.settings.update_streams_desc": "Configure the default update streams for all repositories. Release tags are matched to streams by their suffix. Repos can override with per-repo settings.",
|
||||
"org.settings.stream_mode": "Stream Mode",
|
||||
"org.settings.stream_mode_joomla": "Standard Joomla streams (stable, release-candidate, beta, alpha, development)",
|
||||
"org.settings.stream_mode_custom": "Custom streams (define your own channels and tag patterns)",
|
||||
"org.settings.default_streams": "Active Streams",
|
||||
"org.settings.default_streams_joomla": "These are the currently active update streams. Release tags are matched to streams by their suffix.",
|
||||
"org.settings.stream_name": "Stream Name",
|
||||
"org.settings.stream_name": "Channel",
|
||||
"org.settings.stream_suffix": "Tag Suffix",
|
||||
"org.settings.no_suffix": "none (stable)",
|
||||
"org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).",
|
||||
"org.settings.custom_streams": "Custom Stream Definitions (JSON)",
|
||||
"org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]",
|
||||
"org.settings.update_streams_saved": "Update stream settings saved.",
|
||||
"org.settings.update_streams_saved": "Settings saved.",
|
||||
"org.settings.full_name": "Full Name",
|
||||
"org.settings.email": "Contact Email Address",
|
||||
"org.settings.website": "Website",
|
||||
|
||||
@@ -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,9 @@ func home(ctx *context.Context, viewRepositories bool) {
|
||||
ctx.Data["Teams"] = ctx.Org.Teams
|
||||
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
|
||||
ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
|
||||
|
||||
orgCfg, _ := licenses_model.GetOrgConfig(ctx, ctx.Org.Organization.ID)
|
||||
ctx.Data["OrgLicensingEnabled"] = orgCfg != nil && orgCfg.LicensingEnabled
|
||||
ctx.Data["IsPublicMember"] = func(uid int64) bool {
|
||||
return membersIsPublic[uid]
|
||||
}
|
||||
|
||||
+215
-1
@@ -6,9 +6,11 @@ package org
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
@@ -16,6 +18,27 @@ import (
|
||||
|
||||
const tplOrgLicenses templates.TplName = "org/licenses"
|
||||
|
||||
// parseOrgAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice.
|
||||
func parseOrgAllowedChannels(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, "[") {
|
||||
var parsed []string
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LicensePackageDisplay is used in templates.
|
||||
type LicensePackageDisplay struct {
|
||||
*licenses.LicensePackage
|
||||
@@ -68,6 +91,14 @@ func Licenses(ctx *context.Context) {
|
||||
ctx.Data["LicenseKeys"] = keys
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
|
||||
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
|
||||
ctx.Data["OrgLicensingEnabled"] = true
|
||||
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgLicenses)
|
||||
}
|
||||
@@ -84,13 +115,20 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
|
||||
channels := ctx.Req.Form["allowed_channels"]
|
||||
var allowedChannels string
|
||||
if len(channels) > 0 {
|
||||
data, _ := json.Marshal(channels)
|
||||
allowedChannels = string(data)
|
||||
}
|
||||
|
||||
pkg := &licenses.LicensePackage{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
AllowedChannels: ctx.FormString("allowed_channels"),
|
||||
AllowedChannels: allowedChannels,
|
||||
RepoScope: "all",
|
||||
IsActive: true,
|
||||
}
|
||||
@@ -157,9 +195,185 @@ func LicensesGenerateKey(ctx *context.Context) {
|
||||
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
|
||||
ctx.Data["LicenseKeys"] = keys
|
||||
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgLicenses)
|
||||
}
|
||||
|
||||
const tplOrgLicensesEditPackage templates.TplName = "org/licenses_edit_package"
|
||||
const tplOrgLicensesEditKey templates.TplName = "repo/licenses_edit_key"
|
||||
|
||||
// LicensesEditPackage shows the edit form for an org license package.
|
||||
func LicensesEditPackage(ctx *context.Context) {
|
||||
pkgID := ctx.PathParamInt64("id")
|
||||
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicensePackageByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be edited")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package")
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["Package"] = pkg
|
||||
ctx.Data["SelectedChannels"] = parseOrgAllowedChannels(pkg.AllowedChannels)
|
||||
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ctx.Org.Organization.ID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgLicensesEditPackage)
|
||||
}
|
||||
|
||||
// LicensesEditPackagePost saves edits to an org license package.
|
||||
func LicensesEditPackagePost(ctx *context.Context) {
|
||||
pkgID := ctx.PathParamInt64("id")
|
||||
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicensePackageByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be edited")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
pkg.Name = ctx.FormString("name")
|
||||
pkg.Description = ctx.FormString("description")
|
||||
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||
pkg.DurationDays = durationDays
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
pkg.MaxSites = maxSites
|
||||
|
||||
channels := ctx.Req.Form["allowed_channels"]
|
||||
if len(channels) > 0 {
|
||||
data, _ := json.Marshal(channels)
|
||||
pkg.AllowedChannels = string(data)
|
||||
} else {
|
||||
pkg.AllowedChannels = ""
|
||||
}
|
||||
|
||||
pkg.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
|
||||
ctx.ServerError("UpdateLicensePackage", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.package_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
}
|
||||
|
||||
// LicensesDeletePackage deletes an org license package. Site admin only.
|
||||
func LicensesDeletePackage(ctx *context.Context) {
|
||||
if !ctx.IsUserSiteAdmin() {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
pkgID := ctx.PathParamInt64("id")
|
||||
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicensePackageByID", err)
|
||||
return
|
||||
}
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be deleted")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
return
|
||||
}
|
||||
if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil {
|
||||
ctx.ServerError("DeleteLicensePackage", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.package_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
}
|
||||
|
||||
// LicensesEditKey shows the edit form for an org license key.
|
||||
func LicensesEditKey(ctx *context.Context) {
|
||||
keyID := ctx.PathParamInt64("id")
|
||||
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicenseKeyByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if key.IsInternal {
|
||||
ctx.Flash.Error("Master keys cannot be edited")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key")
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["Key"] = key
|
||||
ctx.Data["FormAction"] = ctx.Org.OrgLink + "/-/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit"
|
||||
ctx.Data["BackLink"] = ctx.Org.OrgLink + "/-/licenses"
|
||||
|
||||
if key.ExpiresUnix > 0 {
|
||||
ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02")
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgLicensesEditKey)
|
||||
}
|
||||
|
||||
// LicensesEditKeyPost saves edits to an org license key.
|
||||
func LicensesEditKeyPost(ctx *context.Context) {
|
||||
keyID := ctx.PathParamInt64("id")
|
||||
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicenseKeyByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if key.IsInternal {
|
||||
ctx.Flash.Error("Master keys cannot be edited")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
key.LicenseeName = ctx.FormString("licensee_name")
|
||||
key.LicenseeEmail = ctx.FormString("licensee_email")
|
||||
key.DomainRestriction = ctx.FormString("domain_restriction")
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
key.MaxSites = maxSites
|
||||
key.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
expiresStr := ctx.FormString("expires_at")
|
||||
if expiresStr != "" {
|
||||
t, err := time.Parse("2006-01-02", expiresStr)
|
||||
if err == nil {
|
||||
key.ExpiresUnix = timeutil.TimeStamp(t.Unix())
|
||||
}
|
||||
} else {
|
||||
key.ExpiresUnix = 0
|
||||
}
|
||||
|
||||
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
|
||||
ctx.ServerError("UpdateLicenseKey", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||
}
|
||||
|
||||
// LicensesRevokeKey handles POST to revoke an org license key.
|
||||
func LicensesRevokeKey(ctx *context.Context) {
|
||||
keyID := ctx.PathParamInt64("id")
|
||||
|
||||
@@ -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,11 @@ package repo
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
@@ -16,6 +18,27 @@ import (
|
||||
|
||||
const tplLicenses templates.TplName = "repo/licenses"
|
||||
|
||||
// parseAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice.
|
||||
func parseAllowedChannels(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, "[") {
|
||||
var parsed []string
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LicensePackageDisplay is used in templates.
|
||||
type LicensePackageDisplay struct {
|
||||
*licenses.LicensePackage
|
||||
@@ -29,6 +52,7 @@ func Licenses(ctx *context.Context) {
|
||||
ctx.Data["PageIsLicenses"] = true
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
@@ -68,6 +92,14 @@ func Licenses(ctx *context.Context) {
|
||||
}
|
||||
ctx.Data["LicenseKeys"] = keys
|
||||
|
||||
// Load available streams for the channels multiselect.
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplLicenses)
|
||||
}
|
||||
|
||||
@@ -83,13 +115,20 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
|
||||
channels := ctx.Req.Form["allowed_channels"]
|
||||
var allowedChannels string
|
||||
if len(channels) > 0 {
|
||||
data, _ := json.Marshal(channels)
|
||||
allowedChannels = string(data)
|
||||
}
|
||||
|
||||
pkg := &licenses.LicensePackage{
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
AllowedChannels: ctx.FormString("allowed_channels"),
|
||||
AllowedChannels: allowedChannels,
|
||||
RepoScope: "all",
|
||||
IsActive: true,
|
||||
}
|
||||
@@ -140,6 +179,7 @@ func LicensesGenerateKey(ctx *context.Context) {
|
||||
ctx.Data["PageIsLicenses"] = true
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
|
||||
ctx.Data["NewKeyCreated"] = rawKey
|
||||
|
||||
// Re-render the page with the new key displayed.
|
||||
@@ -158,6 +198,13 @@ func LicensesGenerateKey(ctx *context.Context) {
|
||||
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
|
||||
ctx.Data["LicenseKeys"] = keys
|
||||
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplLicenses)
|
||||
}
|
||||
|
||||
@@ -181,6 +228,77 @@ func LicensesRevokeKey(ctx *context.Context) {
|
||||
}
|
||||
|
||||
const tplLicensesEditPackage templates.TplName = "repo/licenses_edit_package"
|
||||
const tplLicensesEditKey templates.TplName = "repo/licenses_edit_key"
|
||||
|
||||
// LicensesEditKey shows the edit form for a license key.
|
||||
func LicensesEditKey(ctx *context.Context) {
|
||||
keyID := ctx.PathParamInt64("id")
|
||||
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicenseKeyByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if key.IsInternal {
|
||||
ctx.Flash.Error("Master keys cannot be edited")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key")
|
||||
ctx.Data["PageIsLicenses"] = true
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["Key"] = key
|
||||
ctx.Data["FormAction"] = ctx.Repo.RepoLink + "/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit"
|
||||
ctx.Data["BackLink"] = ctx.Repo.RepoLink + "/licenses"
|
||||
|
||||
if key.ExpiresUnix > 0 {
|
||||
ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02")
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplLicensesEditKey)
|
||||
}
|
||||
|
||||
// LicensesEditKeyPost saves edits to a license key.
|
||||
func LicensesEditKeyPost(ctx *context.Context) {
|
||||
keyID := ctx.PathParamInt64("id")
|
||||
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicenseKeyByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if key.IsInternal {
|
||||
ctx.Flash.Error("Master keys cannot be edited")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
key.LicenseeName = ctx.FormString("licensee_name")
|
||||
key.LicenseeEmail = ctx.FormString("licensee_email")
|
||||
key.DomainRestriction = ctx.FormString("domain_restriction")
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
key.MaxSites = maxSites
|
||||
key.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
expiresStr := ctx.FormString("expires_at")
|
||||
if expiresStr != "" {
|
||||
t, err := time.Parse("2006-01-02", expiresStr)
|
||||
if err == nil {
|
||||
key.ExpiresUnix = timeutil.TimeStamp(t.Unix())
|
||||
}
|
||||
} else {
|
||||
key.ExpiresUnix = 0
|
||||
}
|
||||
|
||||
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
|
||||
ctx.ServerError("UpdateLicenseKey", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
}
|
||||
|
||||
// LicensesEditPackage shows the edit form for a license package.
|
||||
func LicensesEditPackage(ctx *context.Context) {
|
||||
@@ -191,10 +309,26 @@ func LicensesEditPackage(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be edited")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package")
|
||||
ctx.Data["PageIsLicenses"] = true
|
||||
ctx.Data["IsLicensesPage"] = true
|
||||
ctx.Data["Package"] = pkg
|
||||
ctx.Data["SelectedChannels"] = parseAllowedChannels(pkg.AllowedChannels)
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
|
||||
if orgCfg != nil {
|
||||
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
|
||||
} else {
|
||||
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplLicensesEditPackage)
|
||||
}
|
||||
|
||||
@@ -207,13 +341,27 @@ func LicensesEditPackagePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be edited")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
pkg.Name = ctx.FormString("name")
|
||||
pkg.Description = ctx.FormString("description")
|
||||
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||
pkg.DurationDays = durationDays
|
||||
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||
pkg.MaxSites = maxSites
|
||||
pkg.AllowedChannels = ctx.FormString("allowed_channels")
|
||||
|
||||
channels := ctx.Req.Form["allowed_channels"]
|
||||
if len(channels) > 0 {
|
||||
data, _ := json.Marshal(channels)
|
||||
pkg.AllowedChannels = string(data)
|
||||
} else {
|
||||
pkg.AllowedChannels = ""
|
||||
}
|
||||
|
||||
pkg.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
|
||||
@@ -232,6 +380,16 @@ func LicensesDeletePackage(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
pkgID := ctx.PathParamInt64("id")
|
||||
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLicensePackageByID", err)
|
||||
return
|
||||
}
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
ctx.Flash.Error("Master package cannot be deleted")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil {
|
||||
ctx.ServerError("DeleteLicensePackage", err)
|
||||
return
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1107,7 +1107,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/licenses", func() {
|
||||
m.Get("", org.Licenses)
|
||||
m.Post("/packages", org.LicensesCreatePackage)
|
||||
m.Get("/packages/{id}/edit", org.LicensesEditPackage)
|
||||
m.Post("/packages/{id}/edit", org.LicensesEditPackagePost)
|
||||
m.Post("/packages/{id}/delete", org.LicensesDeletePackage)
|
||||
m.Post("/keys/generate", org.LicensesGenerateKey)
|
||||
m.Get("/keys/{id}/edit", org.LicensesEditKey)
|
||||
m.Post("/keys/{id}/edit", org.LicensesEditKeyPost)
|
||||
m.Post("/keys/{id}/revoke", org.LicensesRevokeKey)
|
||||
})
|
||||
|
||||
@@ -1521,6 +1526,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/packages/{id}/edit", repo.LicensesEditPackagePost)
|
||||
m.Post("/packages/{id}/delete", repo.LicensesDeletePackage)
|
||||
m.Post("/keys/generate", repo.LicensesGenerateKey)
|
||||
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
|
||||
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
|
||||
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
|
||||
}, optSignIn, context.RepoAssignment)
|
||||
// end "/{username}/{reponame}": licenses
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,14 @@
|
||||
</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>
|
||||
@@ -82,14 +89,27 @@
|
||||
<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">
|
||||
<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">
|
||||
{{$.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"}}
|
||||
<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}}
|
||||
<form method="post" action="{{$.Org.HomeLink}}/-/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>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
@@ -116,6 +136,7 @@
|
||||
<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>
|
||||
@@ -125,13 +146,19 @@
|
||||
<tr>
|
||||
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</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">
|
||||
<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>
|
||||
{{end}}
|
||||
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red button" type="submit">
|
||||
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
|
||||
{{svg "octicon-x" 14}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{{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 = 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>
|
||||
</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,7 +25,7 @@
|
||||
{{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"}}
|
||||
</a>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
{{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>
|
||||
@@ -64,6 +65,7 @@
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
@@ -110,7 +112,14 @@
|
||||
</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>
|
||||
@@ -133,6 +142,7 @@
|
||||
<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,12 +150,18 @@
|
||||
<tbody>
|
||||
{{range .LicenseKeys}}
|
||||
<tr>
|
||||
<td><code>{{.KeyPrefix}}</code></td>
|
||||
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</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">
|
||||
<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>
|
||||
{{end}}
|
||||
<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"}}">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{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>
|
||||
</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" .}}
|
||||
@@ -31,7 +31,14 @@
|
||||
</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>
|
||||
|
||||
@@ -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