feat: licensing API phase 2 — validation, signed downloads, management, tier admin #660
+31
-1
@@ -1860,9 +1860,39 @@ func Routes() *web.Router {
|
||||
m.Get("/search", repo.TopicSearch)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
||||
|
||||
// Licensing endpoints — DLID-gated, no token required
|
||||
// Licensing endpoints
|
||||
m.Group("/licensing", func() {
|
||||
// Public (no auth)
|
||||
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())
|
||||
|
||||
|
||||
@@ -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/setting"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||
)
|
||||
|
||||
// Joomla update XML structures.
|
||||
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
|
||||
displayName = manifest.DerivedDisplayName()
|
||||
}
|
||||
|
||||
// Build download URL
|
||||
// Build signed download URL
|
||||
baseURL := setting.AppURL
|
||||
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s",
|
||||
baseURL, productCode, version, dlid)
|
||||
token, expires := licensing_service.SignDownloadToken(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: []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)
|
||||
}, 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.Get("", admin.DefaultOrSystemWebhooks)
|
||||
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">
|
||||
{{svg "octicon-paintbrush" 16}} Branding
|
||||
</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}}>
|
||||
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
||||
<div class="menu">
|
||||
|
||||
Reference in New Issue
Block a user