diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 16605b5558..752f693579 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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()) diff --git a/routers/api/v1/licensing/download.go b/routers/api/v1/licensing/download.go new file mode 100644 index 0000000000..8cb4319e63 --- /dev/null +++ b/routers/api/v1/licensing/download.go @@ -0,0 +1,153 @@ +// Copyright 2026 Moko Consulting +// 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) + } +} diff --git a/routers/api/v1/licensing/manage.go b/routers/api/v1/licensing/manage.go new file mode 100644 index 0000000000..d3df99b2d9 --- /dev/null +++ b/routers/api/v1/licensing/manage.go @@ -0,0 +1,477 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/routers/api/v1/licensing/updates.go b/routers/api/v1/licensing/updates.go index e2568e62f4..9fc5387a5e 100644 --- a/routers/api/v1/licensing/updates.go +++ b/routers/api/v1/licensing/updates.go @@ -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{ diff --git a/routers/api/v1/licensing/validate.go b/routers/api/v1/licensing/validate.go new file mode 100644 index 0000000000..8380368ae8 --- /dev/null +++ b/routers/api/v1/licensing/validate.go @@ -0,0 +1,194 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/routers/web/admin/license_tiers.go b/routers/web/admin/license_tiers.go new file mode 100644 index 0000000000..03c8e46ea2 --- /dev/null +++ b/routers/web/admin/license_tiers.go @@ -0,0 +1,127 @@ +// Copyright 2026 Moko Consulting +// 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") +} diff --git a/routers/web/web.go b/routers/web/web.go index 1b5a323750..2242ab275b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/licensing/signer.go b/services/licensing/signer.go new file mode 100644 index 0000000000..aa96d0ed59 --- /dev/null +++ b/services/licensing/signer.go @@ -0,0 +1,132 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/templates/admin/license_tiers.tmpl b/templates/admin/license_tiers.tmpl new file mode 100644 index 0000000000..6c8256a89f --- /dev/null +++ b/templates/admin/license_tiers.tmpl @@ -0,0 +1,101 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}} +
+

+ Product Tiers +
+ +
+

+
+ {{if .Tiers}} + + + + + + + + + + + + + + {{range .Tiers}} + + + + + + + + + + {{end}} + +
KeyNameReposMax DomainsLicensesOrder
{{.TierKey}}{{.TierName}} + {{range .Repos}} + {{.}} + {{end}} + {{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}{{.LicenseCount}}{{.SortOrder}} +
+ {{$.CsrfTokenHtml}} + + +
+
+ {{else}} +

No product tiers defined. Create one to get started.

+ {{end}} +
+ + + +
+ + +{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 36e1e4c9a9..943a279fa7 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -87,6 +87,9 @@ {{svg "octicon-paintbrush" 16}} Branding + + {{svg "octicon-key" 16}} License Tiers +
{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}