Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f881c746 | |||
| 3a405033ae | |||
| 034795951f | |||
| 1d1b867df5 | |||
| 63b599f62c | |||
| 5bd449017c | |||
| fe3de3fbff | |||
| 3e909df6d4 | |||
| 30bb5e33e2 |
@@ -4,7 +4,7 @@
|
|||||||
<name>MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<org>MokoConsulting</org>
|
<org>MokoConsulting</org>
|
||||||
<description>Moko fork of Gitea - adding project board REST API endpoints and custom enhancements</description>
|
<description>Moko fork of Gitea - adding project board REST API endpoints and custom enhancements</description>
|
||||||
<version>06.18.07</version>
|
<version>06.18.12</version>
|
||||||
<version-prefix>v1.26.1+MOKO</version-prefix>
|
<version-prefix>v1.26.1+MOKO</version-prefix>
|
||||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||||
</identity>
|
</identity>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 06.18.07
|
# VERSION: 06.18.12
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
+22
-5
@@ -1,12 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
### Added
|
||||||
|
- License validation API: GET /api/v1/licensing/validate (public) and GET /api/v1/licensing/{dlid}/status (authenticated)
|
||||||
|
- Public validate returns valid/invalid with reason codes (expired, revoked, no_entitlement, domain_limit)
|
||||||
|
- Authenticated status returns full license detail with entitlement list and domain usage
|
||||||
|
- Signed download endpoint: GET /api/v1/licensing/download/{product}/{version}.zip with ed25519 tokens
|
||||||
|
- ed25519 keypair auto-generated on first use, stored in Gitea data directory
|
||||||
|
- Update XML endpoint now generates signed download URLs with 5-minute TTL
|
||||||
|
- License management API: admin CRUD for licenses and tiers, user self-service for domains
|
||||||
|
- Product tier admin UI: /-/admin/license-tiers with CRUD, repo mapping, and license count display
|
||||||
|
|
||||||
|
## [06.20.00] --- 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables
|
||||||
|
- License CRUD with CRC32-checksummed DLID generation and format validation
|
||||||
|
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
||||||
|
- Domain activation tracking with limit enforcement
|
||||||
|
- 13 seeded product tiers from base to enterprise
|
||||||
|
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||||
|
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||||
|
- Merged PR workflows (gitleaks, branch-check, ci-generic) into pr-check
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
## [06.19.00] --- 2026-06-20
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-19
|
|
||||||
|
|
||||||
## [06.18.00] --- 2026-06-19
|
## [06.18.00] --- 2026-06-19
|
||||||
|
|||||||
+31
-1
@@ -1860,9 +1860,39 @@ func Routes() *web.Router {
|
|||||||
m.Get("/search", repo.TopicSearch)
|
m.Get("/search", repo.TopicSearch)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
||||||
|
|
||||||
// Licensing endpoints — DLID-gated, no token required
|
// Licensing endpoints
|
||||||
m.Group("/licensing", func() {
|
m.Group("/licensing", func() {
|
||||||
|
// Public (no auth)
|
||||||
m.Get("/updates/{product}", licensing.ServeUpdates)
|
m.Get("/updates/{product}", licensing.ServeUpdates)
|
||||||
|
m.Get("/validate", licensing.Validate)
|
||||||
|
m.Get("/download/{product}/{version}", licensing.ServeDownload)
|
||||||
|
|
||||||
|
// User self-service (authenticated)
|
||||||
|
m.Group("/my", func() {
|
||||||
|
m.Get("/licenses", licensing.MyLicenses)
|
||||||
|
m.Get("/licenses/{id}/domains", licensing.MyLicenseDomains)
|
||||||
|
m.Delete("/licenses/{id}/domains/{domain}", licensing.MyDeactivateDomain)
|
||||||
|
}, reqToken())
|
||||||
|
|
||||||
|
// Admin license management
|
||||||
|
m.Group("/licenses", func() {
|
||||||
|
m.Get("", licensing.ListLicenses)
|
||||||
|
m.Post("", licensing.CreateLicense)
|
||||||
|
m.Get("/{id}", licensing.GetLicense)
|
||||||
|
m.Patch("/{id}", licensing.UpdateLicense)
|
||||||
|
m.Delete("/{id}", licensing.DeleteLicense)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Admin tier management
|
||||||
|
m.Group("/tiers", func() {
|
||||||
|
m.Get("", licensing.ListTiers)
|
||||||
|
m.Post("", licensing.CreateTier)
|
||||||
|
m.Patch("/{id}", licensing.UpdateTier)
|
||||||
|
m.Delete("/{id}", licensing.DeleteTier)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Authenticated license detail
|
||||||
|
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
||||||
})
|
})
|
||||||
}, sudo())
|
}, sudo())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeDownload handles GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ
|
||||||
|
func ServeDownload(ctx *context.APIContext) {
|
||||||
|
product := ctx.PathParam("product")
|
||||||
|
versionFile := ctx.PathParam("version")
|
||||||
|
token := ctx.FormString("token")
|
||||||
|
expiresStr := ctx.FormString("expires")
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
|
||||||
|
version, ok := licensing_service.ParseDownloadParams(versionFile)
|
||||||
|
if !ok {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid version format", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expires, ok := licensing_service.ParseExpires(expiresStr)
|
||||||
|
if !ok || token == "" || dlid == "" {
|
||||||
|
ctx.Error(http.StatusForbidden, "missing or invalid download parameters", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signed token
|
||||||
|
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
|
||||||
|
ctx.Error(http.StatusForbidden, "invalid or expired download token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DLID is still valid
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil || license == nil || !license.IsActive() {
|
||||||
|
ctx.Error(http.StatusForbidden, "license invalid or expired", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entitlement
|
||||||
|
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if !has {
|
||||||
|
ctx.Error(http.StatusForbidden, "no entitlement for product", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve repo from entitlement
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to get entitlements", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var repoOwner, repoName string
|
||||||
|
for _, ent := range ents {
|
||||||
|
if ent.ProductCode == product {
|
||||||
|
repoOwner = ent.RepoOwner
|
||||||
|
repoName = ent.RepoName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repoName == "" {
|
||||||
|
ctx.Error(http.StatusNotFound, "product repo not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find repo
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
|
||||||
|
if err != nil || repo == nil {
|
||||||
|
ctx.Error(http.StatusNotFound, "repository not found", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the release with matching version
|
||||||
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
ListOptions: db.ListOptionsAll,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
IncludeTags: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to list releases", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRelease *repo_model.Release
|
||||||
|
for _, rel := range releases {
|
||||||
|
relVersion := extractVersion(rel.TagName)
|
||||||
|
if relVersion == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rel.Title != "" && extractVersion(rel.Title) == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetRelease == nil {
|
||||||
|
ctx.Error(http.StatusNotFound, fmt.Sprintf("release version %s not found", version), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ZIP attachment
|
||||||
|
attachments, err := repo_model.GetAttachmentsByReleaseID(ctx, targetRelease.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to get attachments", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipAttachment *repo_model.Attachment
|
||||||
|
for _, att := range attachments {
|
||||||
|
if att.Name != "" && len(att.Name) > 4 && att.Name[len(att.Name)-4:] == ".zip" {
|
||||||
|
zipAttachment = att
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zipAttachment == nil {
|
||||||
|
ctx.Error(http.StatusNotFound, "no zip attachment found for release", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the download
|
||||||
|
licensing_model.LogLicenseAudit(ctx, license.ID, "download",
|
||||||
|
product, fmt.Sprintf("%s/%s", version, zipAttachment.Name))
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
fr, err := storage.Attachments.Open(zipAttachment.RelativePath())
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to open attachment", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fr.Close()
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/zip")
|
||||||
|
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipAttachment.Name))
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if _, err := io.Copy(ctx.Resp, fr); err != nil {
|
||||||
|
log.Error("ServeDownload: io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Admin: License CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type createLicenseRequest struct {
|
||||||
|
UserID int64 `json:"user_id" binding:"Required"`
|
||||||
|
Tier string `json:"tier" binding:"Required"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
ExpiresMonths int `json:"expires_months"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicense handles POST /api/v1/licensing/licenses
|
||||||
|
func CreateLicense(ctx *context.APIContext) {
|
||||||
|
var req createLicenseRequest
|
||||||
|
if err := ctx.BindJSON(&req); err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid request body", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve max_domains from tier if not specified
|
||||||
|
maxDomains := req.MaxDomains
|
||||||
|
if maxDomains == 0 {
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, req.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
maxDomains = tier.MaxDomains
|
||||||
|
}
|
||||||
|
if maxDomains == 0 {
|
||||||
|
maxDomains = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt timeutil.TimeStamp
|
||||||
|
if req.ExpiresMonths > 0 {
|
||||||
|
expiresAt = timeutil.TimeStamp(time.Now().AddDate(0, req.ExpiresMonths, 0).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.CreateLicense(ctx, req.UserID, req.Tier, maxDomains, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to create license", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Notes != "" {
|
||||||
|
license.Notes = req.Notes
|
||||||
|
// TODO: update notes field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build entitlements from tier
|
||||||
|
if err := licensing_model.RebuildEntitlements(ctx, license.ID, req.Tier); err != nil {
|
||||||
|
log.Error("CreateLicense: RebuildEntitlements: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenses handles GET /api/v1/licensing/licenses
|
||||||
|
func ListLicenses(ctx *context.APIContext) {
|
||||||
|
page := ctx.FormInt("page")
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, get all licenses (pagination via offset)
|
||||||
|
// TODO: add proper pagination to the model layer
|
||||||
|
var licenses []*licensing_model.License
|
||||||
|
err := ctx.Orm().Limit(limit, (page-1)*limit).Find(&licenses)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to list licenses", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicense handles GET /api/v1/licensing/licenses/{id}
|
||||||
|
func GetLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid license ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := licenseToJSON(ctx, license)
|
||||||
|
|
||||||
|
// Include entitlements
|
||||||
|
ents, _ := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
entList := make([]map[string]any, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
entList = append(entList, map[string]any{
|
||||||
|
"product_code": e.ProductCode,
|
||||||
|
"repo_owner": e.RepoOwner,
|
||||||
|
"repo_name": e.RepoName,
|
||||||
|
"is_custom": e.IsCustom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["entitlements"] = entList
|
||||||
|
|
||||||
|
// Include activations
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
actList := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
actList = append(actList, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"ip_address": a.IPAddress,
|
||||||
|
"joomla_ver": a.JoomlaVer,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["activations"] = actList
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateLicenseRequest struct {
|
||||||
|
Tier *string `json:"tier"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLicense handles PATCH /api/v1/licensing/licenses/{id}
|
||||||
|
func UpdateLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid license ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateLicenseRequest
|
||||||
|
if err := ctx.BindJSON(&req); err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid request body", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tier != nil && *req.Tier != license.Tier {
|
||||||
|
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to update tier", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Tier = *req.Tier
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil && *req.Status != license.Status {
|
||||||
|
if err := licensing_model.SetLicenseStatus(ctx, id, *req.Status); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to update status", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update simple fields directly
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
license.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.Notes != nil {
|
||||||
|
license.Notes = *req.Notes
|
||||||
|
cols = append(cols, "notes")
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||||
|
if err == nil {
|
||||||
|
license.ExpiresAt = timeutil.TimeStamp(t.Unix())
|
||||||
|
cols = append(cols, "expires_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cols) > 0 {
|
||||||
|
cols = append(cols, "updated_at")
|
||||||
|
ctx.Orm().ID(id).Cols(cols...).Update(license)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicense handles DELETE /api/v1/licensing/licenses/{id}
|
||||||
|
func DeleteLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid license ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to revoke license", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User: Self-service ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// MyLicenses handles GET /api/v1/licensing/my/licenses
|
||||||
|
func MyLicenses(ctx *context.APIContext) {
|
||||||
|
licenses, err := licensing_model.GetLicensesByUser(ctx, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to list licenses", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyLicenseDomains handles GET /api/v1/licensing/my/licenses/{id}/domains
|
||||||
|
func MyLicenseDomains(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid license ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to list domains", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyDeactivateDomain handles DELETE /api/v1/licensing/my/licenses/{id}/domains/{domain}
|
||||||
|
func MyDeactivateDomain(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid license ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := ctx.PathParam("domain")
|
||||||
|
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to deactivate domain", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Product Tier CRUD ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListTiers handles GET /api/v1/licensing/tiers
|
||||||
|
func ListTiers(ctx *context.APIContext) {
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to list tiers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
results = append(results, tierToJSON(t))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTierRequest struct {
|
||||||
|
TierKey string `json:"tier_key" binding:"Required"`
|
||||||
|
TierName string `json:"tier_name" binding:"Required"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTier handles POST /api/v1/licensing/tiers
|
||||||
|
func CreateTier(ctx *context.APIContext) {
|
||||||
|
var req createTierRequest
|
||||||
|
if err := ctx.BindJSON(&req); err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid request body", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := json.Marshal(req.Repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: req.TierKey,
|
||||||
|
TierName: req.TierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: req.MaxDomains,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ctx.Orm().Insert(tier)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "failed to create tier", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTierRequest struct {
|
||||||
|
TierName *string `json:"tier_name"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTier handles PATCH /api/v1/licensing/tiers/{id}
|
||||||
|
func UpdateTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid tier ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, err := ctx.Orm().ID(id).Get(tier)
|
||||||
|
if err != nil || !has {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateTierRequest
|
||||||
|
if err := ctx.BindJSON(&req); err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid request body", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.TierName != nil {
|
||||||
|
tier.TierName = *req.TierName
|
||||||
|
cols = append(cols, "tier_name")
|
||||||
|
}
|
||||||
|
if req.Repos != nil {
|
||||||
|
reposJSON, _ := json.Marshal(req.Repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
cols = append(cols, "repos")
|
||||||
|
}
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
tier.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
tier.SortOrder = *req.SortOrder
|
||||||
|
cols = append(cols, "sort_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) > 0 {
|
||||||
|
ctx.Orm().ID(id).Cols(cols...).Update(tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTier handles DELETE /api/v1/licensing/tiers/{id}
|
||||||
|
func DeleteTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusBadRequest, "invalid tier ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any licenses use this tier
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := ctx.Orm().ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := ctx.Orm().Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.Error(http.StatusConflict, "cannot delete tier with active licenses", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Orm().ID(id).Delete(new(licensing_model.ProductTier))
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func licenseToJSON(ctx *context.APIContext, l *licensing_model.License) map[string]any {
|
||||||
|
tierName := l.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, l.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, l.ID)
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"id": l.ID,
|
||||||
|
"user_id": l.UserID,
|
||||||
|
"dlid": l.DLID,
|
||||||
|
"tier": l.Tier,
|
||||||
|
"tier_name": tierName,
|
||||||
|
"max_domains": l.MaxDomains,
|
||||||
|
"domains_used": domainCount,
|
||||||
|
"status": l.Status,
|
||||||
|
"notes": l.Notes,
|
||||||
|
"created_at": formatTime(l.CreatedAt),
|
||||||
|
"updated_at": formatTime(l.UpdatedAt),
|
||||||
|
}
|
||||||
|
if l.ExpiresAt > 0 {
|
||||||
|
result["expires_at"] = formatTime(l.ExpiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierToJSON(t *licensing_model.ProductTier) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"tier_key": t.TierKey,
|
||||||
|
"tier_name": t.TierName,
|
||||||
|
"repos": t.RepoList(),
|
||||||
|
"max_domains": t.MaxDomains,
|
||||||
|
"sort_order": t.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(ts timeutil.TimeStamp) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Joomla update XML structures.
|
// Joomla update XML structures.
|
||||||
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
|
|||||||
displayName = manifest.DerivedDisplayName()
|
displayName = manifest.DerivedDisplayName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build download URL
|
// Build signed download URL
|
||||||
baseURL := setting.AppURL
|
baseURL := setting.AppURL
|
||||||
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s",
|
token, expires := licensing_service.SignDownloadToken(productCode, version, dlid)
|
||||||
baseURL, productCode, version, dlid)
|
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s&token=%s&expires=%d",
|
||||||
|
baseURL, productCode, version, dlid, token, expires)
|
||||||
|
|
||||||
updates := xmlUpdates{
|
updates := xmlUpdates{
|
||||||
Updates: []xmlUpdate{
|
Updates: []xmlUpdate{
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateResponse is the public validation result.
|
||||||
|
type validateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
TierName string `json:"tier_name,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
DomainsUsed int `json:"domains_used,omitempty"`
|
||||||
|
DomainsMax int `json:"domains_max,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the full license detail for authenticated callers.
|
||||||
|
type statusResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
DLID string `json:"dlid"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
DomainsUsed int `json:"domains_used"`
|
||||||
|
DomainsMax int `json:"domains_max"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
|
||||||
|
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
|
||||||
|
func Validate(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
product := ctx.FormString("product")
|
||||||
|
domain := ctx.FormString("domain")
|
||||||
|
|
||||||
|
if dlid == "" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.Status == "revoked" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.Status == "suspended" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.IsExpired() {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product entitlement if product is specified
|
||||||
|
if product != "" {
|
||||||
|
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: HasEntitlement: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain limit if domain is specified
|
||||||
|
if domain != "" {
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
|
||||||
|
// Check if this domain is already activated
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
found := false
|
||||||
|
for _, a := range acts {
|
||||||
|
if a.Domain == domain {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := validateResponse{
|
||||||
|
Valid: true,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status handles GET /api/v1/licensing/{dlid}/status
|
||||||
|
// Authenticated endpoint — returns full license detail with entitlement list.
|
||||||
|
func Status(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.PathParam("dlid")
|
||||||
|
|
||||||
|
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entitlements
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetEntitlementsByLicense: %v", err)
|
||||||
|
}
|
||||||
|
products := make([]string, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
products = append(products, e.ProductCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := statusResponse{
|
||||||
|
Valid: license.IsActive(),
|
||||||
|
DLID: license.DLID,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
Products: products,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplLicenseTiers templates.TplName = "admin/license_tiers"
|
||||||
|
|
||||||
|
// LicenseTiers shows the product tier management page.
|
||||||
|
func LicenseTiers(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = "Product Tiers"
|
||||||
|
ctx.Data["PageIsAdminLicenseTiers"] = true
|
||||||
|
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetAllProductTiers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierView struct {
|
||||||
|
*licensing_model.ProductTier
|
||||||
|
Repos []string
|
||||||
|
LicenseCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]tierView, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
count, _ := ctx.Orm().Where("tier = ?", t.TierKey).Count(new(licensing_model.License))
|
||||||
|
views = append(views, tierView{
|
||||||
|
ProductTier: t,
|
||||||
|
Repos: t.RepoList(),
|
||||||
|
LicenseCount: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["Tiers"] = views
|
||||||
|
ctx.HTML(http.StatusOK, tplLicenseTiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierCreate handles POST to create a new tier.
|
||||||
|
func LicenseTierCreate(ctx *context.Context) {
|
||||||
|
tierKey := ctx.FormString("tier_key")
|
||||||
|
tierName := ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
maxDomains, _ := strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if tierKey == "" || tierName == "" {
|
||||||
|
ctx.Flash.Error("Tier key and name are required")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: tierKey,
|
||||||
|
TierName: tierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: maxDomains,
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ctx.Orm().Insert(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to create tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tierName + "' created")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierUpdate handles POST to update a tier.
|
||||||
|
func LicenseTierUpdate(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := ctx.Orm().ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier.TierName = ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
tier.MaxDomains, _ = strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
tier.SortOrder, _ = strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if _, err := ctx.Orm().ID(id).Cols("tier_name", "repos", "max_domains", "sort_order").Update(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to update tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' updated")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierDelete handles POST to delete a tier.
|
||||||
|
func LicenseTierDelete(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := ctx.Orm().ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := ctx.Orm().Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.Flash.Error("Cannot delete tier with active licenses. Reassign licenses first.")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Orm().ID(id).Delete(new(licensing_model.ProductTier))
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' deleted")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
@@ -842,6 +842,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Post("/cleanup", admin.CleanupExpiredData)
|
m.Post("/cleanup", admin.CleanupExpiredData)
|
||||||
}, packagesEnabled)
|
}, packagesEnabled)
|
||||||
|
|
||||||
|
m.Group("/license-tiers", func() {
|
||||||
|
m.Get("", admin.LicenseTiers)
|
||||||
|
m.Post("", admin.LicenseTierCreate)
|
||||||
|
m.Post("/{id}/delete", admin.LicenseTierDelete)
|
||||||
|
})
|
||||||
|
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Get("", admin.DefaultOrSystemWebhooks)
|
m.Get("", admin.DefaultOrSystemWebhooks)
|
||||||
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
|
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyFileName = "licensing_ed25519.key"
|
||||||
|
downloadTTL = 5 * time.Minute
|
||||||
|
tokenSeparator = "|"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privateKey ed25519.PrivateKey
|
||||||
|
publicKey ed25519.PublicKey
|
||||||
|
keyOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initKeys loads or generates the ed25519 keypair used for signing download tokens.
|
||||||
|
func initKeys() {
|
||||||
|
keyOnce.Do(func() {
|
||||||
|
keyPath := filepath.Join(setting.AppDataPath, keyFileName)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(keyPath)
|
||||||
|
if err == nil && len(data) == ed25519.SeedSize {
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(data)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
log.Info("Licensing: loaded ed25519 key from %s", keyPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new keypair
|
||||||
|
seed := make([]byte, ed25519.SeedSize)
|
||||||
|
if _, err := rand.Read(seed); err != nil {
|
||||||
|
log.Error("Licensing: failed to generate ed25519 seed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(seed)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
|
||||||
|
if err := os.WriteFile(keyPath, seed, 0600); err != nil {
|
||||||
|
log.Error("Licensing: failed to save ed25519 key to %s: %v", keyPath, err)
|
||||||
|
} else {
|
||||||
|
log.Info("Licensing: generated new ed25519 key at %s", keyPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignDownloadToken creates a signed, time-limited download token.
|
||||||
|
// The message format is: product|version|dlid|expires
|
||||||
|
func SignDownloadToken(product, version, dlid string) (token string, expires int64) {
|
||||||
|
initKeys()
|
||||||
|
if privateKey == nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
expires = time.Now().Add(downloadTTL).Unix()
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig := ed25519.Sign(privateKey, []byte(message))
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return token, expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDownloadToken validates a signed download token.
|
||||||
|
// Returns the parsed product, version, dlid, and any error.
|
||||||
|
func VerifyDownloadToken(token string, product, version, dlid string, expires int64) bool {
|
||||||
|
initKeys()
|
||||||
|
if publicKey == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if time.Now().Unix() > expires {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct message
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ed25519.Verify(publicKey, []byte(message), sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDownloadParams extracts product and version from the URL path segment.
|
||||||
|
// Expects format: "{version}.zip" with product as a separate path param.
|
||||||
|
func ParseDownloadParams(versionFile string) (version string, ok bool) {
|
||||||
|
if !strings.HasSuffix(versionFile, ".zip") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
version = strings.TrimSuffix(versionFile, ".zip")
|
||||||
|
if version == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpires converts the expires query parameter to int64.
|
||||||
|
func ParseExpires(s string) (int64, bool) {
|
||||||
|
v, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}
|
||||||
|
<div class="admin-setting-content">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
Product Tiers
|
||||||
|
<div class="ui right">
|
||||||
|
<button class="ui primary tiny button" id="btn-new-tier">New Tier</button>
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .Tiers}}
|
||||||
|
<table class="ui very basic striped table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Repos</th>
|
||||||
|
<th>Max Domains</th>
|
||||||
|
<th>Licenses</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tiers}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.TierKey}}</code></td>
|
||||||
|
<td>{{.TierName}}</td>
|
||||||
|
<td>
|
||||||
|
{{range .Repos}}
|
||||||
|
<span class="ui label">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}</td>
|
||||||
|
<td>{{.LicenseCount}}</td>
|
||||||
|
<td>{{.SortOrder}}</td>
|
||||||
|
<td class="right aligned">
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers/{{.ID}}/delete" style="display:inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="ui tiny red button{{if gt .LicenseCount 0}} disabled{{end}}" type="submit"
|
||||||
|
{{if gt .LicenseCount 0}}title="Cannot delete tier with active licenses"{{end}}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No product tiers defined. Create one to get started.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Tier Form (hidden by default) -->
|
||||||
|
<div id="new-tier-form" class="ui attached segment" style="display:none">
|
||||||
|
<h5>Create New Tier</h5>
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers" class="ui form">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Key</label>
|
||||||
|
<input type="text" name="tier_key" placeholder="e.g. pos, suite, enterprise" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Name</label>
|
||||||
|
<input type="text" name="tier_name" placeholder="e.g. MokoSuite POS" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Max Domains (0 = unlimited)</label>
|
||||||
|
<input type="number" name="max_domains" value="3" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<input type="number" name="sort_order" value="50" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Repos (comma-separated)</label>
|
||||||
|
<input type="text" name="repos" placeholder="MokoSuite,MokoSuiteCRM,MokoSuiteERP">
|
||||||
|
<p class="help">Enter repo names separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button" type="submit">Create Tier</button>
|
||||||
|
<button class="ui button" type="button" id="btn-cancel-tier">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('btn-new-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = '';
|
||||||
|
this.style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-cancel-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = 'none';
|
||||||
|
document.getElementById('btn-new-tier').style.display = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{template "admin/layout_footer" .}}
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
|
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
|
||||||
{{svg "octicon-paintbrush" 16}} Branding
|
{{svg "octicon-paintbrush" 16}} Branding
|
||||||
</a>
|
</a>
|
||||||
|
<a class="{{if .PageIsAdminLicenseTiers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/license-tiers">
|
||||||
|
{{svg "octicon-key" 16}} License Tiers
|
||||||
|
</a>
|
||||||
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
||||||
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
|
|||||||
Reference in New Issue
Block a user