Compare commits

...

19 Commits

Author SHA1 Message Date
Jonathan Miller 6d194f9bdf fix: sort imports in manage.go (stdlib before module imports)
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
PR RC Release / Build RC Release (pull_request) Failing after 51s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m0s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m44s
2026-06-20 23:03:44 -05:00
Jonathan Miller 403db405cb fix: resolve all compile errors in licensing endpoints
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m4s
PR RC Release / Build RC Release (pull_request) Failing after 1m5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 54s
- ctx.BindJSON → json.NewDecoder(ctx.Req.Body).Decode
- ctx.Orm() → db.GetEngine(ctx)
- ctx.NotFound() → ctx.APIErrorNotFound()
- GetAttachmentsByReleaseID → db.GetEngine query
2026-06-20 22:57:19 -05:00
Jonathan Miller 79cc30e9a8 fix: use ctx.APIError instead of ctx.Error in licensing endpoints
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 10s
PR RC Release / Build RC Release (pull_request) Failing after 1m13s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m19s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m0s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m1s
APIContext doesn't have an Error method — use APIError(status, msg)
which is the correct 2-arg pattern for Gitea API error responses.
2026-06-20 21:47:02 -05:00
Jonathan Miller e3949077b0 Merge remote-tracking branch 'origin/main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 43s
PR RC Release / Build RC Release (pull_request) Failing after 41s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 42s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m35s
# Conflicts:
#	.mokogitea/manifest.xml
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
2026-06-20 21:35:55 -05:00
jmiller e469b4a857 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:28:46 +00:00
jmiller acae63f727 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-21 01:28:38 +00:00
jmiller e71ab8415f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:29 +00:00
jmiller 03ce66a4f4 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 01:28:22 +00:00
jmiller deafaeca65 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 01:28:16 +00:00
jmiller 5e74c22609 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:10 +00:00
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
16 changed files with 1375 additions and 12 deletions
+18
View File
@@ -205,6 +205,12 @@ jobs:
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level" - name: "Determine version bump level"
id: bump id: bump
run: | run: |
@@ -228,6 +234,18 @@ jobs:
--path . --stability stable ${BUMP_FLAG} --branch main \ --path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: "Read published version"
id: version
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: Update release notes and promote changelog - name: Update release notes and promote changelog
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# VERSION: 06.20.00 # VERSION: 01.00.00
# 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"
+45
View File
@@ -487,3 +487,48 @@ jobs:
echo "Source: ${FILE_COUNT} files" echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+2 -4
View File
@@ -49,10 +49,8 @@ jobs:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release runs-on: release
if: >- if: >-
(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'push') && github.event_name == 'push'
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]')
steps: steps:
- name: Checkout - name: Checkout
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal # INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml # PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge # BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+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())
+154
View File
@@ -0,0 +1,154 @@
// 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.APIError(http.StatusBadRequest, "invalid version format")
return
}
expires, ok := licensing_service.ParseExpires(expiresStr)
if !ok || token == "" || dlid == "" {
ctx.APIError(http.StatusForbidden, "missing or invalid download parameters")
return
}
// Verify signed token
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
ctx.APIError(http.StatusForbidden, "invalid or expired download token")
return
}
// Verify DLID is still valid
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil || license == nil || !license.IsActive() {
ctx.APIError(http.StatusForbidden, "license invalid or expired")
return
}
// Verify entitlement
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
if !has {
ctx.APIError(http.StatusForbidden, "no entitlement for product")
return
}
// Resolve repo from entitlement
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get entitlements")
return
}
var repoOwner, repoName string
for _, ent := range ents {
if ent.ProductCode == product {
repoOwner = ent.RepoOwner
repoName = ent.RepoName
break
}
}
if repoName == "" {
ctx.APIError(http.StatusNotFound, "product repo not found")
return
}
// Find repo
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
if err != nil || repo == nil {
ctx.APIError(http.StatusNotFound, "repository not found")
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.APIError(http.StatusInternalServerError, "failed to list releases")
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.APIError(http.StatusNotFound, fmt.Sprintf("release version %s not found", version))
return
}
// Find ZIP attachment
var attachments []*repo_model.Attachment
err = db.GetEngine(ctx).Where("release_id = ?", targetRelease.ID).Find(&attachments)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get attachments")
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.APIError(http.StatusNotFound, "no zip attachment found for release")
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.APIError(http.StatusInternalServerError, "failed to open attachment")
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)
}
}
+479
View File
@@ -0,0 +1,479 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"encoding/json"
"net/http"
"strconv"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
mojo_json "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 := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
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.APIError(http.StatusInternalServerError, "failed to create license")
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 := db.GetEngine(ctx).Limit(limit, (page-1)*limit).Find(&licenses)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
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.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
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.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
return
}
var req updateLicenseRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
if req.Tier != nil && *req.Tier != license.Tier {
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to update tier")
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.APIError(http.StatusInternalServerError, "failed to update status")
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")
db.GetEngine(ctx).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.APIError(http.StatusBadRequest, "invalid license ID")
return
}
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to revoke license")
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.APIError(http.StatusInternalServerError, "failed to list licenses")
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.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list domains")
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.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
domain := ctx.PathParam("domain")
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to deactivate domain")
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.APIError(http.StatusInternalServerError, "failed to list tiers")
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 := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
reposJSON, _ := mojo_json.Marshal(req.Repos)
tier := &licensing_model.ProductTier{
TierKey: req.TierKey,
TierName: req.TierName,
Repos: string(reposJSON),
MaxDomains: req.MaxDomains,
SortOrder: req.SortOrder,
}
_, err := db.GetEngine(ctx).Insert(tier)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to create tier")
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.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
tier := new(licensing_model.ProductTier)
has, err := db.GetEngine(ctx).ID(id).Get(tier)
if err != nil || !has {
ctx.APIErrorNotFound()
return
}
var req updateTierRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
cols := make([]string, 0)
if req.TierName != nil {
tier.TierName = *req.TierName
cols = append(cols, "tier_name")
}
if req.Repos != nil {
reposJSON, _ := mojo_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 {
db.GetEngine(ctx).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.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
// Check if any licenses use this tier
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.APIErrorNotFound()
return
}
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
if count > 0 {
ctx.APIError(http.StatusConflict, "cannot delete tier with active licenses")
return
}
db.GetEngine(ctx).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)
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package admin
import (
"net/http"
"strconv"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
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, _ := db.GetEngine(ctx).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 := db.GetEngine(ctx).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, _ := db.GetEngine(ctx).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 := db.GetEngine(ctx).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, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.NotFound(nil)
return
}
count, _ := db.GetEngine(ctx).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
}
db.GetEngine(ctx).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">