From 68845abd59f34fe38e825430139d29e991cf8e4c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 14:08:35 -0500 Subject: [PATCH] feat(updates): license key management API endpoints (Phase 4) Add REST API for managing license packages and keys: - GET/POST /api/v1/repos/{owner}/{repo}/license-packages - GET/POST /api/v1/repos/{owner}/{repo}/license-keys - DELETE /api/v1/repos/{owner}/{repo}/license-keys/{id} - GET /api/v1/repos/{owner}/{repo}/license-keys/{id}/usage API structs for create/edit/response, with raw key only returned on creation. Requires repo admin permissions. Ref #239 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/structs/license_key.go | 107 +++++++++++++++ routers/api/v1/api.go | 12 ++ routers/api/v1/repo/license_key.go | 200 +++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 modules/structs/license_key.go create mode 100644 routers/api/v1/repo/license_key.go diff --git a/modules/structs/license_key.go b/modules/structs/license_key.go new file mode 100644 index 0000000000..3d9852d3e2 --- /dev/null +++ b/modules/structs/license_key.go @@ -0,0 +1,107 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package structs + +import "time" + +// LicensePackage represents a license package (subscription tier). +type LicensePackage struct { + ID int64 `json:"id"` + OwnerID int64 `json:"owner_id"` + Name string `json:"name"` + Description string `json:"description"` + DurationDays int `json:"duration_days"` + MaxSites int `json:"max_sites"` + RepoScope string `json:"repo_scope"` + AllowedChannels string `json:"allowed_channels"` + IsActive bool `json:"is_active"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateLicensePackageOption options for creating a license package. +type CreateLicensePackageOption struct { + Name string `json:"name" binding:"Required"` + Description string `json:"description"` + DurationDays int `json:"duration_days"` + MaxSites int `json:"max_sites"` + RepoScope string `json:"repo_scope"` + AllowedChannels string `json:"allowed_channels"` +} + +// EditLicensePackageOption options for editing a license package. +type EditLicensePackageOption struct { + Name *string `json:"name"` + Description *string `json:"description"` + DurationDays *int `json:"duration_days"` + MaxSites *int `json:"max_sites"` + RepoScope *string `json:"repo_scope"` + AllowedChannels *string `json:"allowed_channels"` + IsActive *bool `json:"is_active"` +} + +// LicenseKey represents a license key (response — never includes raw key except on creation). +type LicenseKey struct { + ID int64 `json:"id"` + PackageID int64 `json:"package_id"` + OwnerID int64 `json:"owner_id"` + KeyPrefix string `json:"key_prefix"` + LicenseeName string `json:"licensee_name"` + LicenseeEmail string `json:"licensee_email"` + DomainRestriction string `json:"domain_restriction"` + MaxSites int `json:"max_sites"` + IsInternal bool `json:"is_internal"` + IsActive bool `json:"is_active"` + // swagger:strfmt date-time + StartsAt *time.Time `json:"starts_at"` + // swagger:strfmt date-time + ExpiresAt *time.Time `json:"expires_at"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` +} + +// LicenseKeyCreated is the response when a key is first created (includes raw key). +type LicenseKeyCreated struct { + LicenseKey + // RawKey is the full license key string. Only returned on creation. + RawKey string `json:"raw_key"` +} + +// CreateLicenseKeyOption options for creating a license key. +type CreateLicenseKeyOption struct { + PackageID int64 `json:"package_id" binding:"Required"` + LicenseeName string `json:"licensee_name"` + LicenseeEmail string `json:"licensee_email"` + DomainRestriction string `json:"domain_restriction"` + MaxSites int `json:"max_sites"` + // StartsAt is optional; defaults to now. + StartsAt *time.Time `json:"starts_at"` + // ExpiresAt is optional; auto-calculated from package duration if not set. + ExpiresAt *time.Time `json:"expires_at"` +} + +// EditLicenseKeyOption options for editing a license key. +type EditLicenseKeyOption struct { + LicenseeName *string `json:"licensee_name"` + LicenseeEmail *string `json:"licensee_email"` + DomainRestriction *string `json:"domain_restriction"` + MaxSites *int `json:"max_sites"` + IsActive *bool `json:"is_active"` + ExpiresAt *time.Time `json:"expires_at"` +} + +// LicenseKeyUsage represents a usage tracking entry. +type LicenseKeyUsage struct { + ID int64 `json:"id"` + KeyID int64 `json:"key_id"` + RepoID int64 `json:"repo_id"` + Domain string `json:"domain"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + VersionFrom string `json:"version_from"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4a03b7b346..59ee6877bc 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1347,6 +1347,18 @@ func Routes() *web.Router { Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) }) }, reqRepoReader(unit.TypeReleases)) + m.Group("/license-packages", func() { + m.Combo("").Get(repo.ListLicensePackages). + Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage) + }, reqToken(), reqRepoAdmin()) + m.Group("/license-keys", func() { + m.Combo("").Get(repo.ListLicenseKeys). + Post(bind(api.CreateLicenseKeyOption{}), repo.CreateLicenseKey) + m.Group("/{id}", func() { + m.Delete(repo.DeleteLicenseKey) + m.Get("/usage", repo.GetLicenseKeyUsage) + }) + }, reqToken(), reqRepoAdmin()) m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) m.Group("/push_mirrors", func() { diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go new file mode 100644 index 0000000000..50da14d4f8 --- /dev/null +++ b/routers/api/v1/repo/license_key.go @@ -0,0 +1,200 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "net/http" + "time" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage { + return &structs.LicensePackage{ + ID: pkg.ID, + OwnerID: pkg.OwnerID, + Name: pkg.Name, + Description: pkg.Description, + DurationDays: pkg.DurationDays, + MaxSites: pkg.MaxSites, + RepoScope: pkg.RepoScope, + AllowedChannels: pkg.AllowedChannels, + IsActive: pkg.IsActive, + Created: time.Unix(int64(pkg.CreatedUnix), 0), + Updated: time.Unix(int64(pkg.UpdatedUnix), 0), + } +} + +func toLicenseKeyAPI(key *licenses.LicenseKey) *structs.LicenseKey { + lk := &structs.LicenseKey{ + ID: key.ID, + PackageID: key.PackageID, + OwnerID: key.OwnerID, + KeyPrefix: key.KeyPrefix, + LicenseeName: key.LicenseeName, + LicenseeEmail: key.LicenseeEmail, + DomainRestriction: key.DomainRestriction, + MaxSites: key.MaxSites, + IsInternal: key.IsInternal, + IsActive: key.IsActive, + Created: time.Unix(int64(key.CreatedUnix), 0), + } + if key.StartsUnix > 0 { + t := time.Unix(int64(key.StartsUnix), 0) + lk.StartsAt = &t + } + if key.ExpiresUnix > 0 { + t := time.Unix(int64(key.ExpiresUnix), 0) + lk.ExpiresAt = &t + } + return lk +} + +// ListLicensePackages lists license packages for the repo owner. +func ListLicensePackages(ctx *context.APIContext) { + pkgs, err := licenses.ListLicensePackages(ctx, ctx.Repo.Repository.OwnerID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*structs.LicensePackage, len(pkgs)) + for i, pkg := range pkgs { + result[i] = toLicensePackageAPI(pkg) + } + ctx.JSON(http.StatusOK, result) +} + +// CreateLicensePackage creates a new license package. +func CreateLicensePackage(ctx *context.APIContext) { + form := &structs.CreateLicensePackageOption{} + if err := ctx.Bind(form); err != nil { + ctx.APIErrorValidation(err) + return + } + + pkg := &licenses.LicensePackage{ + OwnerID: ctx.Repo.Repository.OwnerID, + Name: form.Name, + Description: form.Description, + DurationDays: form.DurationDays, + MaxSites: form.MaxSites, + RepoScope: form.RepoScope, + AllowedChannels: form.AllowedChannels, + } + if pkg.RepoScope == "" { + pkg.RepoScope = "all" + } + + if err := licenses.CreateLicensePackage(ctx, pkg); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, toLicensePackageAPI(pkg)) +} + +// ListLicenseKeys lists license keys for the repo owner. +func ListLicenseKeys(ctx *context.APIContext) { + keys, err := licenses.ListLicenseKeys(ctx, ctx.Repo.Repository.OwnerID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*structs.LicenseKey, len(keys)) + for i, key := range keys { + result[i] = toLicenseKeyAPI(key) + } + ctx.JSON(http.StatusOK, result) +} + +// CreateLicenseKey creates a new license key. +func CreateLicenseKey(ctx *context.APIContext) { + form := &structs.CreateLicenseKeyOption{} + if err := ctx.Bind(form); err != nil { + ctx.APIErrorValidation(err) + return + } + + key := &licenses.LicenseKey{ + PackageID: form.PackageID, + OwnerID: ctx.Repo.Repository.OwnerID, + LicenseeName: form.LicenseeName, + LicenseeEmail: form.LicenseeEmail, + DomainRestriction: form.DomainRestriction, + MaxSites: form.MaxSites, + } + + if form.StartsAt != nil { + key.StartsUnix = timeutil.TimeStamp(form.StartsAt.Unix()) + } + + if form.ExpiresAt != nil { + key.ExpiresUnix = timeutil.TimeStamp(form.ExpiresAt.Unix()) + } else { + // Auto-calculate from package duration. + pkg, err := licenses.GetLicensePackageByID(ctx, form.PackageID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if pkg.DurationDays > 0 { + start := time.Now() + if form.StartsAt != nil { + start = *form.StartsAt + } + expires := start.AddDate(0, 0, pkg.DurationDays) + key.ExpiresUnix = timeutil.TimeStamp(expires.Unix()) + } + } + + rawKey, err := licenses.CreateLicenseKey(ctx, key) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + resp := &structs.LicenseKeyCreated{ + LicenseKey: *toLicenseKeyAPI(key), + RawKey: rawKey, + } + ctx.JSON(http.StatusCreated, resp) +} + +// DeleteLicenseKey deletes a license key. +func DeleteLicenseKey(ctx *context.APIContext) { + if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} + +// GetLicenseKeyUsage returns usage logs for a license key. +func GetLicenseKeyUsage(ctx *context.APIContext) { + usages, err := licenses.GetRecentUsage(ctx, ctx.PathParamInt64("id"), 100) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*structs.LicenseKeyUsage, len(usages)) + for i, u := range usages { + result[i] = &structs.LicenseKeyUsage{ + ID: u.ID, + KeyID: u.KeyID, + RepoID: u.RepoID, + Domain: u.Domain, + IPAddress: u.IPAddress, + UserAgent: u.UserAgent, + VersionFrom: u.VersionFrom, + Created: time.Unix(int64(u.CreatedUnix), 0), + } + } + ctx.JSON(http.StatusOK, result) +} -- 2.52.0