Compare commits

...

9 Commits

Author SHA1 Message Date
gitea-actions[bot] 03f881c746 chore(version): pre-release bump to 06.18.12-dev [skip ci] 2026-06-21 01:15:16 +00:00
Jonathan Miller 3a405033ae feat: add product tier admin UI with CRUD and license counts (#627)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m35s
Admin page at /-/admin/license-tiers for managing product tiers:
- Tier list with key, name, repos, max domains, license count, sort order
- Create new tier form with repo input
- Delete tier (blocked if active licenses exist)
- Nav item added to admin sidebar
2026-06-20 20:14:24 -05:00
gitea-actions[bot] 034795951f chore(version): pre-release bump to 06.18.11-dev [skip ci] 2026-06-21 01:04:49 +00:00
Jonathan Miller 1d1b867df5 feat: add license management API — admin CRUD, user self-service, tier management (#624)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m22s
Admin: POST/GET/PATCH/DELETE /api/v1/licensing/licenses (reqSiteAdmin)
User: GET /api/v1/licensing/my/licenses, manage domains (reqToken)
Tiers: GET/POST/PATCH/DELETE /api/v1/licensing/tiers (reqSiteAdmin)

Includes pagination, entitlement/activation detail in GET, tier change
triggers entitlement rebuild, delete-tier blocked if active licenses exist.
2026-06-20 20:04:15 -05:00
gitea-actions[bot] 63b599f62c chore(version): pre-release bump to 06.18.10-dev [skip ci] 2026-06-21 01:00:07 +00:00
Jonathan Miller 5bd449017c feat: add signed download endpoint with ed25519 tokens (#622)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m20s
GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ

ed25519 keypair auto-generated on first use, stored in Gitea data dir.
Update XML endpoint now generates signed URLs with 5-minute TTL.
Download verifies signature + expiry + DLID + entitlement before serving
the release ZIP attachment. Downloads logged to audit trail.
2026-06-20 19:59:37 -05:00
gitea-actions[bot] fe3de3fbff chore(version): pre-release bump to 06.18.09-dev [skip ci] 2026-06-21 00:52:30 +00:00
Jonathan Miller 3e909df6d4 feat: add license validation API — public validate + authenticated status (#623)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m16s
GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ (public)
GET /api/v1/licensing/{dlid}/status (authenticated, reqToken)

Public endpoint returns valid/invalid with reason codes for Joomla plugin
and external integration use. Authenticated endpoint returns full license
detail with entitlement list and domain usage for admin dashboards.
2026-06-20 19:51:50 -05:00
gitea-actions[bot] 30bb5e33e2 chore(version): pre-release bump to 06.18.08-dev [skip ci] 2026-06-20 22:20:41 +00:00
13 changed files with 1253 additions and 11 deletions
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+153
View File
@@ -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)
}
}
+477
View File
@@ -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)
}
+5 -3
View File
@@ -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{
+194
View File
@@ -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)
}
+127
View File
@@ -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")
}
+6
View File
@@ -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)
+132
View File
@@ -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
}
+101
View File
@@ -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" .}}
+3
View File
@@ -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">