Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d194f9bdf | |||
| 403db405cb | |||
| 79cc30e9a8 | |||
| e3949077b0 | |||
| e469b4a857 | |||
| acae63f727 | |||
| e71ab8415f | |||
| 03ce66a4f4 | |||
| deafaeca65 | |||
| 5e74c22609 | |||
| 03f881c746 | |||
| 3a405033ae | |||
| 034795951f | |||
| 1d1b867df5 | |||
| 63b599f62c | |||
| 5bd449017c | |||
| fe3de3fbff | |||
| 3e909df6d4 | |||
| 30bb5e33e2 |
@@ -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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateResponse is the public validation result.
|
||||||
|
type validateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
TierName string `json:"tier_name,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
DomainsUsed int `json:"domains_used,omitempty"`
|
||||||
|
DomainsMax int `json:"domains_max,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the full license detail for authenticated callers.
|
||||||
|
type statusResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
DLID string `json:"dlid"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
DomainsUsed int `json:"domains_used"`
|
||||||
|
DomainsMax int `json:"domains_max"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
|
||||||
|
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
|
||||||
|
func Validate(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
product := ctx.FormString("product")
|
||||||
|
domain := ctx.FormString("domain")
|
||||||
|
|
||||||
|
if dlid == "" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.Status == "revoked" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.Status == "suspended" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.IsExpired() {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product entitlement if product is specified
|
||||||
|
if product != "" {
|
||||||
|
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: HasEntitlement: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain limit if domain is specified
|
||||||
|
if domain != "" {
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
|
||||||
|
// Check if this domain is already activated
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
found := false
|
||||||
|
for _, a := range acts {
|
||||||
|
if a.Domain == domain {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := validateResponse{
|
||||||
|
Valid: true,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status handles GET /api/v1/licensing/{dlid}/status
|
||||||
|
// Authenticated endpoint — returns full license detail with entitlement list.
|
||||||
|
func Status(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.PathParam("dlid")
|
||||||
|
|
||||||
|
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entitlements
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetEntitlementsByLicense: %v", err)
|
||||||
|
}
|
||||||
|
products := make([]string, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
products = append(products, e.ProductCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := statusResponse{
|
||||||
|
Valid: license.IsActive(),
|
||||||
|
DLID: license.DLID,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
Products: products,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyFileName = "licensing_ed25519.key"
|
||||||
|
downloadTTL = 5 * time.Minute
|
||||||
|
tokenSeparator = "|"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privateKey ed25519.PrivateKey
|
||||||
|
publicKey ed25519.PublicKey
|
||||||
|
keyOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initKeys loads or generates the ed25519 keypair used for signing download tokens.
|
||||||
|
func initKeys() {
|
||||||
|
keyOnce.Do(func() {
|
||||||
|
keyPath := filepath.Join(setting.AppDataPath, keyFileName)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(keyPath)
|
||||||
|
if err == nil && len(data) == ed25519.SeedSize {
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(data)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
log.Info("Licensing: loaded ed25519 key from %s", keyPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new keypair
|
||||||
|
seed := make([]byte, ed25519.SeedSize)
|
||||||
|
if _, err := rand.Read(seed); err != nil {
|
||||||
|
log.Error("Licensing: failed to generate ed25519 seed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateKey = ed25519.NewKeyFromSeed(seed)
|
||||||
|
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||||
|
|
||||||
|
if err := os.WriteFile(keyPath, seed, 0600); err != nil {
|
||||||
|
log.Error("Licensing: failed to save ed25519 key to %s: %v", keyPath, err)
|
||||||
|
} else {
|
||||||
|
log.Info("Licensing: generated new ed25519 key at %s", keyPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignDownloadToken creates a signed, time-limited download token.
|
||||||
|
// The message format is: product|version|dlid|expires
|
||||||
|
func SignDownloadToken(product, version, dlid string) (token string, expires int64) {
|
||||||
|
initKeys()
|
||||||
|
if privateKey == nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
expires = time.Now().Add(downloadTTL).Unix()
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig := ed25519.Sign(privateKey, []byte(message))
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return token, expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDownloadToken validates a signed download token.
|
||||||
|
// Returns the parsed product, version, dlid, and any error.
|
||||||
|
func VerifyDownloadToken(token string, product, version, dlid string, expires int64) bool {
|
||||||
|
initKeys()
|
||||||
|
if publicKey == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if time.Now().Unix() > expires {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct message
|
||||||
|
message := fmt.Sprintf("%s%s%s%s%s%s%d",
|
||||||
|
product, tokenSeparator,
|
||||||
|
version, tokenSeparator,
|
||||||
|
dlid, tokenSeparator,
|
||||||
|
expires)
|
||||||
|
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ed25519.Verify(publicKey, []byte(message), sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDownloadParams extracts product and version from the URL path segment.
|
||||||
|
// Expects format: "{version}.zip" with product as a separate path param.
|
||||||
|
func ParseDownloadParams(versionFile string) (version string, ok bool) {
|
||||||
|
if !strings.HasSuffix(versionFile, ".zip") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
version = strings.TrimSuffix(versionFile, ".zip")
|
||||||
|
if version == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpires converts the expires query parameter to int64.
|
||||||
|
func ParseExpires(s string) (int64, bool) {
|
||||||
|
v, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}
|
||||||
|
<div class="admin-setting-content">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
Product Tiers
|
||||||
|
<div class="ui right">
|
||||||
|
<button class="ui primary tiny button" id="btn-new-tier">New Tier</button>
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .Tiers}}
|
||||||
|
<table class="ui very basic striped table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Repos</th>
|
||||||
|
<th>Max Domains</th>
|
||||||
|
<th>Licenses</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tiers}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.TierKey}}</code></td>
|
||||||
|
<td>{{.TierName}}</td>
|
||||||
|
<td>
|
||||||
|
{{range .Repos}}
|
||||||
|
<span class="ui label">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}</td>
|
||||||
|
<td>{{.LicenseCount}}</td>
|
||||||
|
<td>{{.SortOrder}}</td>
|
||||||
|
<td class="right aligned">
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers/{{.ID}}/delete" style="display:inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="ui tiny red button{{if gt .LicenseCount 0}} disabled{{end}}" type="submit"
|
||||||
|
{{if gt .LicenseCount 0}}title="Cannot delete tier with active licenses"{{end}}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No product tiers defined. Create one to get started.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Tier Form (hidden by default) -->
|
||||||
|
<div id="new-tier-form" class="ui attached segment" style="display:none">
|
||||||
|
<h5>Create New Tier</h5>
|
||||||
|
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers" class="ui form">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Key</label>
|
||||||
|
<input type="text" name="tier_key" placeholder="e.g. pos, suite, enterprise" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Tier Name</label>
|
||||||
|
<input type="text" name="tier_name" placeholder="e.g. MokoSuite POS" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Max Domains (0 = unlimited)</label>
|
||||||
|
<input type="number" name="max_domains" value="3" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sort Order</label>
|
||||||
|
<input type="number" name="sort_order" value="50" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Repos (comma-separated)</label>
|
||||||
|
<input type="text" name="repos" placeholder="MokoSuite,MokoSuiteCRM,MokoSuiteERP">
|
||||||
|
<p class="help">Enter repo names separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button" type="submit">Create Tier</button>
|
||||||
|
<button class="ui button" type="button" id="btn-cancel-tier">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('btn-new-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = '';
|
||||||
|
this.style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-cancel-tier').addEventListener('click', function() {
|
||||||
|
document.getElementById('new-tier-form').style.display = 'none';
|
||||||
|
document.getElementById('btn-new-tier').style.display = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{template "admin/layout_footer" .}}
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user