Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc745c5fa | |||
| 9dda78da7c | |||
| 6ceef765eb | |||
| 23d3528676 | |||
| 249b639c70 | |||
| 5c9db551dc | |||
| 408f2329b3 | |||
| 827025bd17 | |||
| 98da1644be | |||
| db596575a0 | |||
| 3c56dc8814 | |||
| dce712fabd | |||
| 78b0ce9650 | |||
| 500a5be6d7 | |||
| 95a747b1d5 | |||
| bb7e99ad40 | |||
| 6c6b7c888e | |||
| 2a1692d599 | |||
| 6984ac108f | |||
| 3fdbe94830 | |||
| e937dd8d8b | |||
| e7b70f54ed | |||
| b161561571 | |||
| b981cf72e3 | |||
| 9964c7e16c | |||
| ff27e77c37 | |||
| 04ce7dc896 | |||
| f87f904a21 | |||
| fc72d8e90a | |||
| 71d52e432e | |||
| 172303b61f | |||
| bfb4b53da3 | |||
| 9149fa100c | |||
| 6a2c80a8f3 | |||
| 28ee70a946 | |||
| 6d194f9bdf | |||
| 403db405cb | |||
| 39e4eb6ec8 | |||
| 79cc30e9a8 | |||
| 78ad2c999b | |||
| e3949077b0 | |||
| e469b4a857 | |||
| acae63f727 | |||
| e71ab8415f | |||
| 03ce66a4f4 | |||
| deafaeca65 | |||
| 5e74c22609 | |||
| 03f881c746 | |||
| 3a405033ae | |||
| 034795951f | |||
| 1d1b867df5 | |||
| 63b599f62c | |||
| 5bd449017c | |||
| fe3de3fbff | |||
| 3e909df6d4 | |||
| 30bb5e33e2 |
@@ -10,9 +10,9 @@
|
|||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
@@ -30,6 +30,15 @@ on:
|
|||||||
types: [opened, closed]
|
types: [opened, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.mokogitea/workflows/**'
|
||||||
|
- '*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.gitattributes'
|
||||||
|
- '.gitmessage'
|
||||||
|
- 'LICENSE'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -51,7 +60,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||||
promote-rc:
|
promote-rc:
|
||||||
name: Promote to RC
|
name: Promote to RC
|
||||||
runs-on: release
|
runs-on: release
|
||||||
@@ -149,7 +158,7 @@ jobs:
|
|||||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||||
release:
|
release:
|
||||||
name: Build & Release Pipeline
|
name: Build & Release Pipeline
|
||||||
runs-on: release
|
runs-on: release
|
||||||
@@ -205,6 +214,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 +243,54 @@ 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"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]]; then
|
||||||
|
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Published version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: "Create semver tag for non-Joomla repos"
|
||||||
|
id: semver
|
||||||
|
if: |
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
SEMVER_TAG="v${VERSION}"
|
||||||
|
|
||||||
|
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||||
|
|
||||||
|
# Create the git tag via API
|
||||||
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/tags" \
|
||||||
|
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Created semver tag: ${SEMVER_TAG}"
|
||||||
|
elif [ "$HTTP_CODE" = "409" ]; then
|
||||||
|
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- 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}"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
name: "Publish to Composer"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
- '[0-9]*.[0-9]*.[0-9]*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
|
!contains(github.event.head_commit.message, '[skip publish]')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Package version: ${VERSION}"
|
||||||
|
|
||||||
|
# Gitea Composer Registry — auto-publishes from tags
|
||||||
|
# The tag push itself registers the package at:
|
||||||
|
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||||
|
- name: Verify Gitea registry
|
||||||
|
run: |
|
||||||
|
echo "Gitea Composer registry auto-publishes from tags."
|
||||||
|
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||||
|
echo "Install: composer require mokoconsulting/mokocli"
|
||||||
|
|
||||||
|
# Packagist — notify of new version
|
||||||
|
- name: Notify Packagist
|
||||||
|
if: secrets.PACKAGIST_TOKEN != ''
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
echo "Notifying Packagist of version ${VERSION}..."
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||||
|
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||||
|
&& echo "Packagist notified" \
|
||||||
|
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -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
|
||||||
@@ -90,8 +88,20 @@ jobs:
|
|||||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Check platform eligibility (Joomla only)
|
||||||
|
id: eligibility
|
||||||
|
run: |
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Resolve metadata and bump version
|
- name: Resolve metadata and bump version
|
||||||
id: meta
|
id: meta
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
@@ -168,6 +178,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: release
|
id: release
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -178,6 +189,7 @@ jobs:
|
|||||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
- name: Update release notes from CHANGELOG.md
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -214,6 +226,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package and upload
|
- name: Build package and upload
|
||||||
id: package
|
id: package
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
@@ -227,6 +240,7 @@ jobs:
|
|||||||
# No need to build, commit, or sync updates.xml from workflows
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
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: 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}
|
||||||
@@ -10,6 +10,25 @@
|
|||||||
- 13 seeded product tiers from base to enterprise
|
- 13 seeded product tiers from base to enterprise
|
||||||
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||||
- Profile repo fallback chain: .mokogitea > .profile > .github
|
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||||
|
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
|
||||||
|
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
|
||||||
|
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
|
||||||
|
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
|
||||||
|
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
|
||||||
|
- Issue status API response includes is_required field
|
||||||
|
- Wiki recent changes page: cross-page edit activity with pagination (#670)
|
||||||
|
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Metadata settings template 500 error: removed reference to deleted Version field
|
||||||
|
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
|
||||||
|
- Wiki backlinks: proper URL encoding for subdirectory pages
|
||||||
|
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
|
||||||
|
- Issue statuses template: garbled em-dash character replaced
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
|
||||||
|
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
## [06.19.00] --- 2026-06-20
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||||
Description string `xorm:"TEXT"`
|
Description string `xorm:"TEXT"`
|
||||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||||
|
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
|
||||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
@@ -56,14 +57,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||||
|
// Open and Closed are required (is_required=true) and cannot be deleted.
|
||||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||||
defaults := []*IssueStatusDef{
|
defaults := []*IssueStatusDef{
|
||||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
|
||||||
|
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
|
||||||
}
|
}
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||||
@@ -111,13 +113,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStatusRequired is returned when trying to delete a required status.
|
||||||
|
type ErrStatusRequired struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrStatusRequired) Error() string {
|
||||||
|
return "status is required and cannot be deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrStatusRequired checks if an error is ErrStatusRequired.
|
||||||
|
func IsErrStatusRequired(err error) bool {
|
||||||
|
_, ok := err.(ErrStatusRequired)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||||
|
// Returns ErrStatusRequired if the status is marked as required.
|
||||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||||
|
def, err := GetIssueStatusDefByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if def.IsRequired {
|
||||||
|
return ErrStatusRequired{ID: def.ID, Name: def.Name}
|
||||||
|
}
|
||||||
// Clear status_id on all issues that reference this definition
|
// Clear status_id on all issues that reference this definition
|
||||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ClosesIssue bool `json:"closes_issue"`
|
ClosesIssue bool `json:"closes_issue"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-7
@@ -1480,12 +1480,10 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), repo.DeleteTopic)
|
Delete(reqToken(), repo.DeleteTopic)
|
||||||
}, reqAdmin())
|
}, reqAdmin())
|
||||||
}, reqAnyRepoReader())
|
}, reqAnyRepoReader())
|
||||||
m.Combo("/metadata", reqRepoReader(unit.TypeCode)).
|
m.Get("/metadata", repo.GetRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
m.Get("/manifest", repo.GetRepoMetadata) // backward compat
|
||||||
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat
|
m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
|
||||||
// MokoGitea badge engine
|
// MokoGitea badge engine
|
||||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||||
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||||
@@ -1860,9 +1858,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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,19 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkOrgVisibility returns true if the current user can view org metadata.
|
||||||
|
// Public orgs are visible to everyone. Private/limited orgs require authentication.
|
||||||
|
func checkOrgVisibility(ctx *context.APIContext) bool {
|
||||||
|
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ctx.Doer == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssueStatuses returns active issue status definitions for an org.
|
// ListIssueStatuses returns active issue status definitions for an org.
|
||||||
func ListIssueStatuses(ctx *context.APIContext) {
|
func ListIssueStatuses(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
||||||
@@ -34,6 +47,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -47,6 +64,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
Color: d.Color,
|
Color: d.Color,
|
||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
ClosesIssue: d.ClosesIssue,
|
ClosesIssue: d.ClosesIssue,
|
||||||
|
IsRequired: d.IsRequired,
|
||||||
SortOrder: d.SortOrder,
|
SortOrder: d.SortOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,6 +94,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -118,6 +140,10 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -103,6 +103,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
||||||
|
if issues_model.IsErrStatusRequired(err) {
|
||||||
|
ctx.Flash.Error("Cannot delete required status: " + def.Name)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("DeleteIssueStatusDef", err)
|
ctx.ServerError("DeleteIssueStatusDef", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
|
|||||||
SubURL string
|
SubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
|
||||||
|
type OrgWikiTreeNode struct {
|
||||||
|
Name string
|
||||||
|
SubURL string
|
||||||
|
IsDir bool
|
||||||
|
Children []*OrgWikiTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
// Wiki renders the org wiki tab.
|
// Wiki renders the org wiki tab.
|
||||||
func Wiki(ctx *context.Context) {
|
func Wiki(ctx *context.Context) {
|
||||||
org := ctx.Org.Organization
|
org := ctx.Org.Organization
|
||||||
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
||||||
|
|
||||||
// Build page list from repo root.
|
// Build folder tree for sidebar navigation.
|
||||||
entries, err := commit.ListEntries()
|
wikiTree := buildOrgWikiTree(commit)
|
||||||
if err != nil {
|
ctx.Data["WikiTree"] = wikiTree
|
||||||
ctx.ServerError("ListEntries", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pages := make([]OrgWikiPage, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if !isMarkdownFile(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
displayName := strings.TrimSuffix(name, path.Ext(name))
|
|
||||||
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages = append(pages, OrgWikiPage{
|
|
||||||
Name: displayName,
|
|
||||||
SubURL: displayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx.Data["Pages"] = pages
|
|
||||||
|
|
||||||
// Determine which page to render.
|
// Determine which page to render.
|
||||||
pageName := ctx.PathParamRaw("*")
|
pageName := ctx.PathParamRaw("*")
|
||||||
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
|
|||||||
ctx.HTML(http.StatusOK, tplOrgWiki)
|
ctx.HTML(http.StatusOK, tplOrgWiki)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
|
||||||
|
// Shows up to 2 levels deep (folders and their immediate children).
|
||||||
|
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevel []*OrgWikiTreeNode
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
node := &OrgWikiTreeNode{
|
||||||
|
Name: name,
|
||||||
|
SubURL: name,
|
||||||
|
IsDir: true,
|
||||||
|
}
|
||||||
|
// List children of this directory (1 level deep).
|
||||||
|
subTree := entry.Tree()
|
||||||
|
if subTree != nil {
|
||||||
|
children, _ := subTree.ListEntries()
|
||||||
|
for _, child := range children {
|
||||||
|
childName := child.Name()
|
||||||
|
if child.IsDir() {
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: childName,
|
||||||
|
SubURL: name + "/" + childName,
|
||||||
|
IsDir: true,
|
||||||
|
})
|
||||||
|
} else if isMarkdownFile(childName) {
|
||||||
|
displayName := strings.TrimSuffix(childName, path.Ext(childName))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: name + "/" + displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, node)
|
||||||
|
} else if isMarkdownFile(name) {
|
||||||
|
displayName := strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topLevel
|
||||||
|
}
|
||||||
|
|
||||||
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
||||||
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
||||||
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
||||||
|
|||||||
+988
-51
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{.Name}}</strong>
|
<strong>{{.Name}}</strong>
|
||||||
|
{{if .IsRequired}}<span class="ui mini blue label" title="Required status - cannot be deleted">{{svg "octicon-lock" 10}} required</span>{{end}}
|
||||||
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
|
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
|
||||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||||
</td>
|
</td>
|
||||||
@@ -40,10 +41,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{.SortOrder}}</td>
|
<td>{{.SortOrder}}</td>
|
||||||
<td class="tw-text-right">
|
<td class="tw-text-right">
|
||||||
|
{{if .IsRequired}}
|
||||||
|
<span class="ui tiny icon button disabled" title="Required - cannot be deleted">{{svg "octicon-lock" 14}}</span>
|
||||||
|
{{else}}
|
||||||
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
|
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
|
||||||
{{$.CsrfTokenHtml}}
|
{{$.CsrfTokenHtml}}
|
||||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
This organization doesn't have a wiki yet.
|
This organization doesn't have a wiki yet.
|
||||||
</div>
|
</div>
|
||||||
<p class="tw-text-center">
|
<p class="tw-text-center">
|
||||||
Enable the wiki on the <code>.profile</code> (public) or <code>.profile-private</code> (members-only)
|
Enable the wiki on the <code>.mokogitea</code> (public) or <code>.mokogitea-private</code> (members-only)
|
||||||
repository to get started.
|
repository to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,34 +47,59 @@
|
|||||||
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
|
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if .Pages}}
|
{{if .WikiTree}}
|
||||||
<h4>Available pages:</h4>
|
<h4>Available pages:</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Pages}}
|
{{range .WikiTree}}
|
||||||
|
{{if .IsDir}}
|
||||||
|
{{range .Children}}
|
||||||
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="wiki-content-parts">
|
<div class="wiki-content-parts">
|
||||||
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .Pages}}with-sidebar{{end}}">
|
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
||||||
{{.WikiContent}}
|
{{.WikiContent}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if or .WikiSidebarHTML .Pages}}
|
{{if or .WikiSidebarHTML .WikiTree}}
|
||||||
<div class="render-content markup wiki-content-sidebar">
|
<div class="render-content markup wiki-content-sidebar">
|
||||||
{{if .WikiSidebarHTML}}
|
{{if .WikiSidebarHTML}}
|
||||||
{{.WikiSidebarHTML}}
|
{{.WikiSidebarHTML}}
|
||||||
<div class="ui divider"></div>
|
{{else if .WikiTree}}
|
||||||
{{end}}
|
|
||||||
{{if .Pages}}
|
|
||||||
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
|
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
|
||||||
<ul class="wiki-tree-list">
|
<ul class="wiki-tree-list">
|
||||||
{{range .Pages}}
|
{{range .WikiTree}}
|
||||||
<li>
|
<li>
|
||||||
|
{{if .IsDir}}
|
||||||
|
<details open>
|
||||||
|
<summary>{{svg "octicon-file-directory" 14}} <strong>{{.Name}}</strong></summary>
|
||||||
|
{{if .Children}}
|
||||||
|
<ul>
|
||||||
|
{{range .Children}}
|
||||||
|
<li>
|
||||||
|
{{if .IsDir}}
|
||||||
|
{{svg "octicon-file-directory" 14}}
|
||||||
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{else}}
|
||||||
{{svg "octicon-file" 14}}
|
{{svg "octicon-file" 14}}
|
||||||
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</details>
|
||||||
|
{{else}}
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -21,11 +21,7 @@
|
|||||||
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
|
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="four fields">
|
<div class="three fields">
|
||||||
<div class="field">
|
|
||||||
<label>Version</label>
|
|
||||||
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Version Prefix</label>
|
<label>Version Prefix</label>
|
||||||
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
|
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row">
|
||||||
|
<div class="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to {{.title}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{svg "octicon-cross-reference" 20}} What links here: {{.title}}</h2>
|
||||||
|
|
||||||
|
{{if .Backlinks}}
|
||||||
|
<div class="ui relaxed divided list">
|
||||||
|
{{range .Backlinks}}
|
||||||
|
<div class="item">
|
||||||
|
<div class="content">
|
||||||
|
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="description">
|
||||||
|
<code class="tw-text-sm">{{.Context}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="tw-mt-4 text grey">{{.BacklinkCount}} {{if eq .BacklinkCount 1}}page{{else}}pages{{end}} linking here.</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-unlink" 48}}
|
||||||
|
<br>
|
||||||
|
No pages link to "{{.title}}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{svg "octicon-tag" 20}} Category: {{.CategoryName}}</h2>
|
||||||
|
|
||||||
|
{{if .CategoryPages}}
|
||||||
|
<div class="ui relaxed divided list">
|
||||||
|
{{range .CategoryPages}}
|
||||||
|
<div class="item">
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="tw-mt-4 text grey">{{.CategoryCount}} {{if eq .CategoryCount 1}}page{{else}}pages{{end}} in this category.</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-tag" 48}}
|
||||||
|
<br>
|
||||||
|
No pages in category "{{.CategoryName}}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<a href="{{.RepoLink}}/wiki/{{.PageURL}}">{{svg "octicon-arrow-left" 14}} {{.title}}</a>
|
||||||
|
·
|
||||||
|
<a href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision">Revision history</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui segment">
|
||||||
|
<h3>{{svg "octicon-diff" 20}} Changes in <code>{{.CommitID}}</code></h3>
|
||||||
|
<p>
|
||||||
|
<strong>{{.CommitAuthor}}</strong> — {{.CommitMessage}}
|
||||||
|
<br>
|
||||||
|
<small class="text grey">{{DateUtils.TimeSince .CommitWhen}}</small>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .IsNewPage}}
|
||||||
|
<div class="ui info message">New page created</div>
|
||||||
|
{{else if .IsDeletedPage}}
|
||||||
|
<div class="ui warning message">Page deleted</div>
|
||||||
|
{{else if not .HasDiff}}
|
||||||
|
<div class="ui info message">No content changes in this revision</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasDiff}}
|
||||||
|
<div class="diff-file-box" style="overflow-x: auto;">
|
||||||
|
<table class="chroma" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 13px;">
|
||||||
|
{{range .DiffLines}}
|
||||||
|
<tr class="{{if eq .Type "add"}}diff-line-add{{else if eq .Type "del"}}diff-line-del{{else}}diff-line-context{{end}}">
|
||||||
|
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
|
||||||
|
{{if .OldNum}}{{.OldNum}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
|
||||||
|
{{if .NewNum}}{{.NewNum}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0 8px; white-space: pre-wrap; word-break: break-all; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #fff;{{end}}">
|
||||||
|
{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}} {{.Content}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%; }
|
||||||
|
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||||
|
th { background: #f6f8fa; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
|
||||||
|
a { color: #0366d6; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
{{.WikiContentHTML}}
|
||||||
|
<hr>
|
||||||
|
<p style="font-size: 12px; color: #999;">
|
||||||
|
Printed from wiki · {{.Title}}
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
|
||||||
|
<div class="tw-flex-1">
|
||||||
|
<h2>{{svg "octicon-history" 20}} Recent changes</h2>
|
||||||
|
</div>
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/">
|
||||||
|
{{svg "octicon-arrow-left" 14}} Back to wiki
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .RecentChanges}}
|
||||||
|
<table class="ui compact table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Page</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Edit summary</th>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Commit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .RecentChanges}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{if .PageURL}}
|
||||||
|
{{svg "octicon-file" 14}}
|
||||||
|
<a href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
|
||||||
|
{{else if .PageName}}
|
||||||
|
{{svg "octicon-file" 14}} {{.PageName}}
|
||||||
|
{{else}}
|
||||||
|
<span class="text grey">—</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.Author}}</td>
|
||||||
|
<td class="gt-ellipsis" style="max-width: 400px;">{{.Message}}</td>
|
||||||
|
<td>{{DateUtils.TimeSince .When}}</td>
|
||||||
|
<td><code class="tw-text-xs">{{.SHA}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-justify-between tw-mt-4">
|
||||||
|
{{if .HasPrevPage}}
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "-" 1}}">
|
||||||
|
{{svg "octicon-chevron-left" 14}} Newer
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<span></span>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasNextPage}}
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "+" 1}}">
|
||||||
|
Older {{svg "octicon-chevron-right" 14}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{svg "octicon-history" 48}}
|
||||||
|
<br>
|
||||||
|
No recent changes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scrolling menu">
|
<div class="scrolling menu">
|
||||||
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
|
||||||
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_recent">{{svg "octicon-history" 14}} Recent changes</a>
|
||||||
|
t <a class="item muted" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_print" target="_blank">{{svg "octicon-browser" 14}} Print view</a>
|
||||||
|
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_export&format=zip">{{svg "octicon-download" 14}} Export wiki (ZIP)</a>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
{{range .Pages}}
|
{{range .Pages}}
|
||||||
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
<div class="flex-text-block tw-flex-wrap tw-justify-end">
|
<div class="flex-text-block tw-flex-wrap tw-justify-end">
|
||||||
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
|
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
|
||||||
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
|
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
|
||||||
|
<a class="ui basic button tw-px-3" title="What links here" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_backlinks">{{svg "octicon-cross-reference"}}</a>
|
||||||
|
{{if .LastCommitID}}<a class="ui basic button tw-px-3" title="View last change" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_diff&commit={{.LastCommitID}}">{{svg "octicon-diff"}}</a>{{end}}
|
||||||
<div class="tw-flex-1 gt-ellipsis">
|
<div class="tw-flex-1 gt-ellipsis">
|
||||||
{{$title}}
|
{{$title}}
|
||||||
<div class="ui sub header gt-ellipsis">
|
<div class="ui sub header gt-ellipsis">
|
||||||
@@ -47,7 +52,7 @@
|
|||||||
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
|
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
|
||||||
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
|
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
|
{{if and .CanWriteWiki (not .Repository.IsMirror) (not .WikiFolderProtected)}}
|
||||||
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
|
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
|
||||||
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
|
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
|
||||||
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
|
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
|
||||||
@@ -69,6 +74,12 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .WikiFolderProtected}}
|
||||||
|
<div class="ui warning message">
|
||||||
|
<p>{{svg "octicon-lock" 14}} This page is in a protected folder. Only users with the required role can edit it.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .FormatWarning}}
|
{{if .FormatWarning}}
|
||||||
<div class="ui negative message">
|
<div class="ui negative message">
|
||||||
<p>{{.FormatWarning}}</p>
|
<p>{{.FormatWarning}}</p>
|
||||||
@@ -103,13 +114,30 @@
|
|||||||
<div class="wiki-content-parts">
|
<div class="wiki-content-parts">
|
||||||
{{if .WikiSidebarTocHTML}}
|
{{if .WikiSidebarTocHTML}}
|
||||||
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
|
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
|
||||||
|
<details open>
|
||||||
|
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
|
||||||
{{.WikiSidebarTocHTML}}
|
{{.WikiSidebarTocHTML}}
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
|
||||||
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
|
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
|
||||||
|
{{if .WikiInlineTocHTML}}
|
||||||
|
<details open class="wiki-toc-inline tw-mb-4">
|
||||||
|
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
|
||||||
|
{{.WikiInlineTocHTML}}
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
{{.WikiContentHTML}}
|
{{.WikiContentHTML}}
|
||||||
|
{{if .WikiCategories}}
|
||||||
|
<div class="tw-mt-4 tw-pt-2" style="border-top: 1px solid var(--color-secondary);">
|
||||||
|
{{svg "octicon-tag" 14}} Categories:
|
||||||
|
{{range .WikiCategories}}
|
||||||
|
<a class="ui small label" href="{{$.RepoLink}}/wiki/?action=_category&name={{.}}">{{.}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .WikiTree}}
|
{{if .WikiTree}}
|
||||||
@@ -121,6 +149,7 @@
|
|||||||
{{if .IsDir}}
|
{{if .IsDir}}
|
||||||
{{svg "octicon-file-directory" 14}}
|
{{svg "octicon-file-directory" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
|
||||||
{{if .Children}}
|
{{if .Children}}
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Children}}
|
{{range .Children}}
|
||||||
@@ -128,6 +157,7 @@
|
|||||||
{{if .IsDir}}
|
{{if .IsDir}}
|
||||||
{{svg "octicon-file-directory" 14}}
|
{{svg "octicon-file-directory" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{svg "octicon-file" 14}}
|
{{svg "octicon-file" 14}}
|
||||||
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
|
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
|
||||||
|
|||||||
@@ -86,3 +86,34 @@
|
|||||||
max-width: unset;
|
max-width: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wikilinks: red links for non-existent pages */
|
||||||
|
.wiki .wiki-link-new {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki .wiki-link-new:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wiki inline ToC */
|
||||||
|
.wiki .wiki-toc-inline {
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-box-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki .wiki-toc-inline summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky sidebar ToC */
|
||||||
|
.wiki .wiki-content-toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user