Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d55da332cf | |||
| a04e237f17 | |||
| e7cc4c120f | |||
| aa54f3834e | |||
| 4d93f23037 | |||
| d7e2ffd02b | |||
| b9d81ca5c5 | |||
| 59c62dc687 | |||
| b14ffa083e | |||
| 2cc57bbbbc | |||
| 3cd7687c06 | |||
| c3b2643b0c | |||
| 0159e567e2 | |||
| f194b204b4 | |||
| f118f084ce | |||
| 2821c35326 | |||
| 5b02cf188e | |||
| 689173ecab | |||
| b2fe44fbc3 | |||
| 0e89ef9944 | |||
| 522dadecf0 | |||
| f1b9bb2f3d | |||
| 7bbaf218d5 | |||
| 33a550f838 | |||
| e29ee5f91b | |||
| 984a99188e | |||
| 92fc77a6d1 | |||
| ea411e09be | |||
| 9b141b39c5 | |||
| 85e4356fce | |||
| 1654181a9e | |||
| 282ef8f3e7 | |||
| a34eb53b2a | |||
| 75d53c11b4 | |||
| 8556314468 | |||
| 22624d662c | |||
| 91646c505b | |||
| b994fcdb9a | |||
| 6dc2c1dec7 | |||
| 4372e956de | |||
| a61cdbe2f1 | |||
| ac4092fbab | |||
| 30197e4e97 | |||
| 12132486a0 | |||
| 3f29562938 | |||
| 4a931dddab | |||
| c6f42487b5 | |||
| b101a2304a | |||
| 381952f6d2 | |||
| 1c667d9da9 | |||
| a88e3f8787 | |||
| 4012f3bea9 | |||
| 095b78b2a7 | |||
| ff72cd0cb0 | |||
| 50454db3fb | |||
| eab36f26aa | |||
| 4ce332d031 | |||
| 1b1ad35df4 | |||
| 426cffc224 | |||
| 0716ad0edd | |||
| 0572e6a164 | |||
| 4e51f48285 | |||
| aa56925bba | |||
| fc895aa70d | |||
| 1db8435737 | |||
| 71a486b534 | |||
| 90b9af6e3e | |||
| a99af91ab4 | |||
| 0eb81f9c1a | |||
| 6498459e49 | |||
| 2b82312b4e | |||
| 8808dfc3ce | |||
| 470364e50c | |||
| 69ad436ebb | |||
| 65c5e3d213 | |||
| d40c8e1b85 | |||
| 39c373975e | |||
| b14fcb11f9 | |||
| 60a686ce63 | |||
| 17ac356100 | |||
| 68845abd59 | |||
| ba0fdf3df1 | |||
| ba0b17d9b5 | |||
| 29341b2b9b | |||
| eef72a5b00 | |||
| 530cfc91b1 | |||
| 39249dd0e7 | |||
| aee484780b | |||
| e9ab1fd01d | |||
| 6e78d49e5a | |||
| 627a22ee53 | |||
| 3c5fc21976 | |||
| 23d453a786 | |||
| ef99c7461d | |||
| 658aa524c6 | |||
| 44f6823292 | |||
| 6c06384966 | |||
| d4f2dc33b9 | |||
| 3807dbbb2e | |||
| fd481329a5 | |||
| 04ed2c7ed5 | |||
| c322bfae23 | |||
| b0acd521e5 | |||
| 9c0e2b48cf | |||
| 1bff46b220 | |||
| 44fd865ee6 | |||
| 0438ed1b73 | |||
| 6045bf87d9 | |||
| 540e3e129a | |||
| 203d090123 | |||
| 7aa930227e | |||
| 0cb4ece382 | |||
| 8af880073f | |||
| 8ee7e9fcde | |||
| 7bd66ae74c | |||
| d10c6ece9b | |||
| aeda83c664 | |||
| e0698e73bc | |||
| 25257b9e31 | |||
| a5bdc89faa | |||
| 0ecba968a0 | |||
| bed7adcf1c | |||
| df59b5f6d5 | |||
| 5786f0dfc4 | |||
| 2de87d8ff4 | |||
| b241acf650 | |||
| 173dfd0f26 | |||
| 1ad277cd73 | |||
| e084c7f4b4 | |||
| eb15990510 | |||
| 411ba858f5 | |||
| bd899bcbb1 | |||
| 4c4d2ac956 | |||
| 47ddd6a277 |
@@ -0,0 +1,251 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Automation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.gitea/workflows/branch-protection.yml
|
||||||
|
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||||
|
#
|
||||||
|
# +========================================================================+
|
||||||
|
# | BRANCH PROTECTION SETUP |
|
||||||
|
# +========================================================================+
|
||||||
|
# | |
|
||||||
|
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||||
|
# | |
|
||||||
|
# | main — Require PR, block rejected reviews, no force push |
|
||||||
|
# | dev — Allow push, no force push, no delete |
|
||||||
|
# | rc — Allow push, no force push, no delete |
|
||||||
|
# | beta — Allow push, no force push, no delete |
|
||||||
|
# | alpha — Allow push, no force push, no delete |
|
||||||
|
# | |
|
||||||
|
# | jmiller has override authority on all branches. |
|
||||||
|
# | |
|
||||||
|
# +========================================================================+
|
||||||
|
|
||||||
|
name: Branch Protection Setup
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: 'Preview mode (no changes)'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
repos:
|
||||||
|
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.mokoconsulting.tech
|
||||||
|
GITEA_ORG: MokoConsulting
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
protect:
|
||||||
|
name: Apply Branch Protection Rules
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Determine target repos
|
||||||
|
id: repos
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
|
# Platform/standards/infra repos to exclude
|
||||||
|
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||||
|
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||||
|
|
||||||
|
if [ -n "${{ inputs.repos }}" ]; then
|
||||||
|
# User-specified repos
|
||||||
|
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||||
|
else
|
||||||
|
# Fetch all org repos
|
||||||
|
PAGE=1
|
||||||
|
REPOS=""
|
||||||
|
while true; do
|
||||||
|
BATCH=$(curl -sS \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||||
|
| jq -r '.[].name // empty')
|
||||||
|
[ -z "$BATCH" ] && break
|
||||||
|
REPOS="$REPOS $BATCH"
|
||||||
|
PAGE=$((PAGE + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Filter out excluded repos
|
||||||
|
FILTERED=""
|
||||||
|
for REPO in $REPOS; do
|
||||||
|
SKIP=false
|
||||||
|
for EX in $EXCLUDE; do
|
||||||
|
if [ "$REPO" = "$EX" ]; then
|
||||||
|
SKIP=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$SKIP" = "false" ]; then
|
||||||
|
FILTERED="$FILTERED $REPO"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
REPOS="$FILTERED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||||
|
COUNT=$(echo "$REPOS" | wc -w)
|
||||||
|
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||||
|
|
||||||
|
- name: Apply protection rules
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||||
|
run: |
|
||||||
|
API="${GITEA_URL}/api/v1"
|
||||||
|
REPOS="${{ steps.repos.outputs.repos }}"
|
||||||
|
|
||||||
|
SUCCESS=0
|
||||||
|
FAILED=0
|
||||||
|
SKIPPED=0
|
||||||
|
|
||||||
|
# ── Rule definitions ──────────────────────────────────────
|
||||||
|
# Only the CI bot (jmiller token) can push directly.
|
||||||
|
# All human contributors must use PRs.
|
||||||
|
# Force push disabled on all branches.
|
||||||
|
|
||||||
|
RULE_MAIN='{
|
||||||
|
"rule_name": "main",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"dismiss_stale_approvals": true,
|
||||||
|
"block_on_rejected_reviews": true,
|
||||||
|
"block_on_outdated_branch": false,
|
||||||
|
"priority": 1
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_DEV='{
|
||||||
|
"rule_name": "dev",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 2
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_RC='{
|
||||||
|
"rule_name": "rc",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 3
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_BETA='{
|
||||||
|
"rule_name": "beta",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 4
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULE_ALPHA='{
|
||||||
|
"rule_name": "alpha",
|
||||||
|
"enable_push": true,
|
||||||
|
"enable_push_whitelist": true,
|
||||||
|
"push_whitelist_usernames": ["jmiller"],
|
||||||
|
"enable_force_push": false,
|
||||||
|
"enable_force_push_allowlist": false,
|
||||||
|
"force_push_allowlist_usernames": [],
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"required_approvals": 0,
|
||||||
|
"block_on_rejected_reviews": false,
|
||||||
|
"priority": 5
|
||||||
|
}'
|
||||||
|
|
||||||
|
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||||
|
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||||
|
|
||||||
|
# ── Apply rules to each repo ──────────────────────────────
|
||||||
|
for REPO in $REPOS; do
|
||||||
|
echo ""
|
||||||
|
echo "═══ ${REPO} ═══"
|
||||||
|
|
||||||
|
for i in "${!RULES[@]}"; do
|
||||||
|
RULE="${RULES[$i]}"
|
||||||
|
NAME="${RULE_NAMES[$i]}"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete existing rule if present (idempotent recreate)
|
||||||
|
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||||
|
curl -sS -o /dev/null -w "" \
|
||||||
|
-X DELETE \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create rule
|
||||||
|
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$RULE" \
|
||||||
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||||
|
|
||||||
|
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP" = "201" ]; then
|
||||||
|
echo " ✅ ${NAME}"
|
||||||
|
SUCCESS=$((SUCCESS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo " ✅ Success: ${SUCCESS}"
|
||||||
|
echo " ❌ Failed: ${FAILED}"
|
||||||
|
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
|
||||||
|
if [ "$FAILED" -gt 0 ]; then
|
||||||
|
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||||
|
fi
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<name>MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<org>MokoConsulting</org>
|
<org>MokoConsulting</org>
|
||||||
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
|
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
|
||||||
<version>01.00.00</version>
|
<version>05.13.00</version>
|
||||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||||
</identity>
|
</identity>
|
||||||
<governance>
|
<governance>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Delete feature branches after PR merge
|
||||||
|
|
||||||
|
name: "Branch Cleanup"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
name: Delete merged branch
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
github.event.pull_request.head.ref != 'dev' &&
|
||||||
|
github.event.pull_request.head.ref != 'main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Delete source branch
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
|
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||||
|
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||||
|
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "204" ]; then
|
||||||
|
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "$STATUS" = "404" ]; then
|
||||||
|
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||||
|
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||||
|
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||||
|
name: "Cascade Main → Dev (DISABLED)"
|
||||||
|
on: workflow_dispatch
|
||||||
|
jobs:
|
||||||
|
noop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||||
@@ -103,6 +103,17 @@ jobs:
|
|||||||
|
|
||||||
$SSH_CMD "echo 'SSH connected'"
|
$SSH_CMD "echo 'SSH connected'"
|
||||||
|
|
||||||
|
# Pre-deploy cleanup: free disk and memory for the build
|
||||||
|
$SSH_CMD "
|
||||||
|
echo 'Cleaning Docker build cache and unused images...'
|
||||||
|
docker builder prune -af 2>/dev/null || true
|
||||||
|
docker image prune -af 2>/dev/null || true
|
||||||
|
echo 'Clearing swap...'
|
||||||
|
sudo swapoff -a && sudo swapon -a 2>/dev/null || true
|
||||||
|
echo 'Cleanup complete'
|
||||||
|
free -m | head -3
|
||||||
|
"
|
||||||
|
|
||||||
# Pull latest source
|
# Pull latest source
|
||||||
$SSH_CMD "
|
$SSH_CMD "
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: moko-platform.Automation
|
||||||
# VERSION: 01.00.00
|
# VERSION: 05.13.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"
|
||||||
|
|||||||
-5376
File diff suppressed because it is too large
Load Diff
+161
-293
@@ -1,293 +1,161 @@
|
|||||||
# Contribution Guidelines
|
# Contributing to Moko Consulting Projects
|
||||||
|
|
||||||
This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find.
|
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||||
|
|
||||||
| Topic | Document |
|
## Branching Workflow
|
||||||
| :---- | :------- |
|
|
||||||
| Backend (Go modules, API v1) | [docs/guideline-backend.md](docs/guideline-backend.md) |
|
```
|
||||||
| Frontend (npm, UI guidelines) | [docs/guideline-frontend.md](docs/guideline-frontend.md) |
|
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||||
| Maintainers, TOC, labels, merge queue, commit format for mergers | [docs/community-governance.md](docs/community-governance.md) |
|
```
|
||||||
| Release cycle, backports, tagging releases | [docs/release-management.md](docs/release-management.md) |
|
|
||||||
|
### Step by step
|
||||||
<details><summary>Table of Contents</summary>
|
|
||||||
|
1. **Create a feature branch** from `dev`:
|
||||||
- [Contribution Guidelines](#contribution-guidelines)
|
```bash
|
||||||
- [Introduction](#introduction)
|
git checkout dev && git pull
|
||||||
- [AI Contribution Policy](#ai-contribution-policy)
|
git checkout -b feature/my-change
|
||||||
- [Issues](#issues)
|
```
|
||||||
- [How to report issues](#how-to-report-issues)
|
|
||||||
- [Types of issues](#types-of-issues)
|
2. **Work and commit** on your feature branch. Push to origin.
|
||||||
- [Discuss your design before the implementation](#discuss-your-design-before-the-implementation)
|
|
||||||
- [Issue locking](#issue-locking)
|
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||||
- [Building Gitea](#building-gitea)
|
|
||||||
- [Styleguide](#styleguide)
|
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||||
- [Copyright](#copyright)
|
- This automatically renames the source branch to `rc` (release candidate)
|
||||||
- [Testing](#testing)
|
- An RC pre-release is built and uploaded
|
||||||
- [Translation](#translation)
|
|
||||||
- [Code review](#code-review)
|
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||||
- [Pull request format](#pull-request-format)
|
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||||
- [PR title and summary](#pr-title-and-summary)
|
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||||
- [Breaking PRs](#breaking-prs)
|
- When the draft PR is created, the branch is renamed to `rc`
|
||||||
- [What is a breaking PR?](#what-is-a-breaking-pr)
|
|
||||||
- [How to handle breaking PRs?](#how-to-handle-breaking-prs)
|
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||||
- [Maintaining open PRs](#maintaining-open-prs)
|
|
||||||
- [Reviewing PRs](#reviewing-prs)
|
7. **Merging to main** triggers the stable release pipeline:
|
||||||
- [For PR authors](#for-pr-authors)
|
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||||
- [Documentation](#documentation)
|
- Stability suffix stripped (clean version)
|
||||||
- [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco)
|
- Gitea release created with ZIP/tar.gz packages
|
||||||
|
- `updates.xml` updated (Joomla extensions)
|
||||||
</details>
|
- `dev` branch recreated from `main`
|
||||||
|
|
||||||
## Introduction
|
### Branch summary
|
||||||
|
|
||||||
It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \
|
| Branch | Purpose | Created by |
|
||||||
Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io).
|
|--------|---------|-----------|
|
||||||
|
| `feature/*` | New features and fixes | Developer |
|
||||||
For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/).
|
| `dev` | Integration branch | Auto-recreated after release |
|
||||||
|
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||||
## AI Contribution Policy
|
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||||
|
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||||
Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly and disclose that use clearly.
|
| `main` | Stable releases | Protected, merge only |
|
||||||
|
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||||
1. Review AI-generated code closely before marking a pull request ready for review.
|
|
||||||
2. Manually test the changes and add appropriate automated tests where feasible.
|
### Protected branches
|
||||||
3. Only use AI to assist in contributions that you understand well enough to explain, defend, and revise yourself during review.
|
|
||||||
4. Disclose AI-assisted content clearly.
|
| Branch | Direct push | Merge via |
|
||||||
5. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model.
|
|--------|------------|-----------|
|
||||||
6. AI may be used to help draft issues and pull requests, but contributors remain responsible for the accuracy, completeness, and intent of what they submit.
|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||||
|
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||||
Maintainers reserve the right to close pull requests and issues that do not disclose AI assistance, that appear to be low-quality AI-generated content, or where the contributor cannot explain or defend the proposed changes themselves.
|
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||||
|
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||||
We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process.
|
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||||
|
| `feature/*` | Open | N/A (source branch) |
|
||||||
## Issues
|
|
||||||
|
## Version Policy
|
||||||
### How to report issues
|
|
||||||
|
### Format
|
||||||
Please search the issues on the issue tracker with a variety of related keywords to ensure that your issue has not already been reported.
|
|
||||||
|
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||||
If your issue has not been reported yet, [open an issue](https://github.com/go-gitea/gitea/issues/new)
|
|
||||||
and answer the questions so we can understand and reproduce the problematic behavior. \
|
- **XX** — Major version (breaking changes)
|
||||||
Please write clear and concise instructions so that we can reproduce the behavior — even if it seems obvious. \
|
- **YY** — Minor version (new features, bumped on release to main)
|
||||||
The more detailed and specific you are, the faster we can fix the issue. \
|
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||||
It is really helpful if you can reproduce your problem on a site running on the latest commits, i.e. <https://demo.gitea.com>, as perhaps your problem has already been fixed on a current version. \
|
|
||||||
Please follow the guidelines described in [How to Report Bugs Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) for your report.
|
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||||
|
|
||||||
Please be kind—remember that Gitea comes at no cost to you, and you're getting free help.
|
### Stability suffixes
|
||||||
|
|
||||||
### Types of issues
|
Each branch appends a suffix to indicate stability:
|
||||||
|
|
||||||
Typically, issues fall in one of the following categories:
|
| Branch | Suffix | Example |
|
||||||
|
|--------|--------|---------|
|
||||||
- `bug`: Something in the frontend or backend behaves unexpectedly
|
| `main` | (none) | `02.09.00` |
|
||||||
- `security issue`: bug that has serious implications such as leaking another users data. Please do not file such issues on the public tracker and send a mail to security@gitea.io instead
|
| `dev` | `-dev` | `02.09.01-dev` |
|
||||||
- `feature`: Completely new functionality. You should describe this feature in enough detail that anyone who reads the issue can understand how it is supposed to be implemented
|
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||||
- `enhancement`: An existing feature should get an upgrade
|
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||||
- `refactoring`: Parts of the code base don't conform with other parts and should be changed to improve Gitea's maintainability
|
| `beta` | `-beta` | `02.09.01-beta` |
|
||||||
|
| `rc` | `-rc` | `02.09.01-rc` |
|
||||||
### Discuss your design before the implementation
|
|
||||||
|
### Auto version bump
|
||||||
We welcome submissions. \
|
|
||||||
If you want to change or add something, please let everyone know what you're working on — [file an issue](https://github.com/go-gitea/gitea/issues/new) or comment on an existing one before starting your work!
|
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||||
|
|
||||||
Significant changes such as new features must go through the change proposal process before they can be accepted. \
|
1. Patch version incremented
|
||||||
This is mainly to save yourself the trouble of implementing it, only to find out that your proposed implementation has some potential problems. \
|
2. Stability suffix `-dev` applied
|
||||||
Furthermore, this process gives everyone a chance to validate the design, helps prevent duplication of effort, and ensures that the idea fits inside
|
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||||
the goals for the project and tools.
|
4. Commit created with `[skip ci]` to avoid loops
|
||||||
|
|
||||||
Pull requests should not be the place for architecture discussions.
|
### Release version flow
|
||||||
|
|
||||||
### Issue locking
|
Version bumps happen at specific release events:
|
||||||
|
|
||||||
Commenting on closed or merged issues/PRs is strongly discouraged.
|
| Event | Bump | Example |
|
||||||
Such comments will likely be overlooked as some maintainers may not view notifications on closed issues, thinking that the item is resolved.
|
|-------|------|---------|
|
||||||
As such, commenting on closed/merged issues/PRs may be disabled prior to the scheduled auto-locking if a discussion starts or if unrelated comments are posted.
|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||||
If further discussion is needed, we encourage you to open a new issue instead and we recommend linking to the issue/PR in question for context.
|
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||||
|
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||||
## Building Gitea
|
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||||
|
|
||||||
See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea).
|
### Release stream copies
|
||||||
|
|
||||||
## Styleguide
|
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||||
|
|
||||||
You should always run `make fmt` before committing to conform to Gitea's styleguide.
|
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||||
|
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||||
## Copyright
|
|
||||||
|
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||||
New code files that you contribute should use the standard copyright header:
|
|
||||||
|
### Version files
|
||||||
```
|
|
||||||
// Copyright <current year> The Gitea Authors. All rights reserved.
|
The version tools update all files containing version stamps:
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
```
|
- `.mokogitea/manifest.xml` (canonical source)
|
||||||
|
- Joomla XML manifests (`<version>` tag)
|
||||||
Afterwards, copyright should only be modified when the copyright author changes.
|
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||||
|
- `package.json`, `pyproject.toml`
|
||||||
## Testing
|
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||||
|
|
||||||
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere.
|
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||||
|
|
||||||
Here's how to run the test suite:
|
## Code Standards
|
||||||
|
|
||||||
- code lint
|
- **PHP**: PSR-12, tabs for indentation
|
||||||
|
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||||
| | |
|
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||||
| :-------------------- | :--------------------------------------------------------------------------- |
|
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||||
|``make lint`` | lint everything (not needed if you only change the front- **or** backend) |
|
|
||||||
|``make lint-frontend`` | lint frontend files |
|
## Commit Messages
|
||||||
|``make lint-backend`` | lint backend files |
|
|
||||||
|
Use conventional commit format:
|
||||||
- run tests (we suggest running them on Linux)
|
|
||||||
|
```
|
||||||
| Command | Action | |
|
type(scope): short description
|
||||||
|:----------------------------------------------|:-----------------------------------------------------| ------------------------------------------- |
|
|
||||||
| ``make test-backend[\#SpecificTestName]`` | run unit test(s) | |
|
Optional body with context.
|
||||||
| ``make test-integration[\#SpecificTestName]`` | run [integration](tests/integration) test(s) | [More details](tests/integration/README.md) |
|
|
||||||
| ``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | |
|
Authored-by: Moko Consulting
|
||||||
|
```
|
||||||
- E2E test environment variables
|
|
||||||
|
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||||
| Variable | Description |
|
|
||||||
| :-------------------------------- | :---------------------------------------------------------- |
|
Special flags in commit messages:
|
||||||
| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output |
|
- `[skip ci]` — skip all CI workflows
|
||||||
| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` |
|
- `[skip bump]` — skip auto version bump only
|
||||||
| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 4 on CI, 1 locally) |
|
|
||||||
|
## Reporting Issues
|
||||||
## Translation
|
|
||||||
|
Use the repository's issue tracker with the appropriate template.
|
||||||
All translation work happens on [Crowdin](https://translate.gitea.com).
|
|
||||||
The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.json).
|
---
|
||||||
It is synced regularly with Crowdin. \
|
|
||||||
Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \
|
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||||
Once a language has reached a **satisfactory percentage** of translated keys (~25%), it will be synced back into this repo and included in the next released version.
|
|
||||||
|
|
||||||
The tool `go run build/backport-locale.go` can be used to backport locales from the main branch to release branches that were missed.
|
|
||||||
|
|
||||||
## Code review
|
|
||||||
|
|
||||||
How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md).
|
|
||||||
|
|
||||||
### Pull request format
|
|
||||||
|
|
||||||
Please try to make your pull request easy to review for us. \
|
|
||||||
For that, please read the [*Best Practices for Faster Reviews*](https://github.com/kubernetes/community/blob/261cb0fd089b64002c91e8eddceebf032462ccd6/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) guide. \
|
|
||||||
It has lots of useful tips for any project you may want to contribute to. \
|
|
||||||
Some of the key points:
|
|
||||||
|
|
||||||
- Make small pull requests. \
|
|
||||||
The smaller, the faster to review and the more likely it will be merged soon.
|
|
||||||
- Don't make changes unrelated to your PR. \
|
|
||||||
Maybe there are typos on some comments, maybe refactoring would be welcome on a function... \
|
|
||||||
but if that is not related to your PR, please make *another* PR for that.
|
|
||||||
- Split big pull requests into multiple small ones. \
|
|
||||||
An incremental change will be faster to review than a huge PR.
|
|
||||||
- Allow edits by maintainers. This way, the maintainers will take care of merging the PR later on instead of you.
|
|
||||||
|
|
||||||
### PR title and summary
|
|
||||||
|
|
||||||
In the PR title, describe the problem you are fixing, not how you are fixing it. \
|
|
||||||
Use the first comment as a summary of your PR. \
|
|
||||||
In the PR summary, you can describe exactly how you are fixing this problem.
|
|
||||||
|
|
||||||
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message:
|
|
||||||
|
|
||||||
```text
|
|
||||||
type(scope)!: subject
|
|
||||||
```
|
|
||||||
|
|
||||||
The allowed types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, and `test`. The generic `chore` type is intentionally not accepted; pick a more descriptive type instead.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```text
|
|
||||||
fix(web): prevent avatar upload crash on empty file
|
|
||||||
feat(api): add pagination to repo hooks list
|
|
||||||
ci(workflows): lint PR titles with commitlint
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep this summary up-to-date as the PR evolves. \
|
|
||||||
If your PR changes the UI, you must add **after** screenshots in the PR summary. \
|
|
||||||
If you are not implementing a new feature, you should also post **before** screenshots for comparison.
|
|
||||||
|
|
||||||
If you are implementing a new feature, your PR will only be merged if your screenshots are up to date.\
|
|
||||||
Furthermore, feature PRs will only be merged if their summary contains a clear usage description (understandable for users) and testing description (understandable for reviewers).
|
|
||||||
You should strive to combine both into a single description.
|
|
||||||
|
|
||||||
Another requirement for merging PRs is that the PR is labeled correctly.\
|
|
||||||
However, this is not your job as a contributor, but the job of the person merging your PR.\
|
|
||||||
If you think that your PR was labeled incorrectly, or notice that it was merged without labels, please let us know.
|
|
||||||
|
|
||||||
If your PR closes some issues, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like
|
|
||||||
|
|
||||||
```text
|
|
||||||
Fixes/Closes/Resolves #<ISSUE_NR_X>.
|
|
||||||
Fixes/Closes/Resolves #<ISSUE_NR_Y>.
|
|
||||||
```
|
|
||||||
|
|
||||||
to your summary. \
|
|
||||||
Each issue that will be closed must stand on a separate line.
|
|
||||||
|
|
||||||
### Breaking PRs
|
|
||||||
|
|
||||||
#### What is a breaking PR?
|
|
||||||
|
|
||||||
A PR is breaking if it meets one of the following criteria:
|
|
||||||
|
|
||||||
- It changes API output in an incompatible way for existing users
|
|
||||||
- It removes a setting that an admin could previously set (i.e. via `app.ini`)
|
|
||||||
- An admin must do something manually to restore the old behavior
|
|
||||||
|
|
||||||
In particular, this means that adding new settings is not breaking.\
|
|
||||||
Changing the default value of a setting or replacing the setting with another one is breaking, however.
|
|
||||||
|
|
||||||
#### How to handle breaking PRs?
|
|
||||||
|
|
||||||
If your PR has a breaking change, you must add two things to the summary of your PR:
|
|
||||||
|
|
||||||
1. A reasoning why this breaking change is necessary
|
|
||||||
2. A `BREAKING` section explaining in simple terms (understandable for a typical user) how this PR affects users and how to mitigate these changes. This section can look for example like
|
|
||||||
|
|
||||||
```md
|
|
||||||
## :warning: BREAKING :warning:
|
|
||||||
```
|
|
||||||
|
|
||||||
Breaking PRs will not be merged as long as not both of these requirements are met.
|
|
||||||
|
|
||||||
### Maintaining open PRs
|
|
||||||
|
|
||||||
Code review starts when you open a non-draft PR or move a draft out of draft state. After that, do not rebase or squash your branch; it makes new changes harder to review.
|
|
||||||
|
|
||||||
Merge the base branch into yours only when you need to, for example because of conflicting changes elsewhere. That limits unnecessary CI runs.
|
|
||||||
|
|
||||||
Every PR is squash-merged, so merge commits on your branch do not matter for final history. The squash produces a single commit; mergers follow the [commit message format](docs/community-governance.md#commit-messages) in the governance guide.
|
|
||||||
|
|
||||||
### Reviewing PRs
|
|
||||||
|
|
||||||
Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest.
|
|
||||||
|
|
||||||
#### For PR authors
|
|
||||||
|
|
||||||
- **Response**: When answering reviewer questions, use real-world cases or examples and avoid speculation.
|
|
||||||
- **Discussion**: A discussion is always welcome and should be used to clarify the changes and the intent of the PR.
|
|
||||||
- **Help**: If you need help with the PR or comments are unclear, ask for clarification.
|
|
||||||
|
|
||||||
Guidance for reviewers, the merge queue, and the squash commit message format is in [docs/community-governance.md](docs/community-governance.md).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs).
|
|
||||||
**The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.**
|
|
||||||
|
|
||||||
## Developer Certificate of Origin (DCO)
|
|
||||||
|
|
||||||
We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \
|
|
||||||
No further action is required. \
|
|
||||||
You can also decide to sign off your commits by adding the following line at the end of your commit messages:
|
|
||||||
|
|
||||||
```
|
|
||||||
Signed-off-by: Joe Smith <joe.smith@email.com>
|
|
||||||
```
|
|
||||||
|
|
||||||
If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`.
|
|
||||||
|
|
||||||
We assume in good faith that the information you provide is legally binding.
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licenses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(LicenseKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseKey represents an individual key issued from a LicensePackage.
|
||||||
|
type LicenseKey struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
PackageID int64 `xorm:"INDEX NOT NULL"` // FK to license_package
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that issued it
|
||||||
|
KeyHash string `xorm:"UNIQUE NOT NULL"` // SHA-256 of the raw key
|
||||||
|
KeyPrefix string `xorm:"NOT NULL"` // first 8 chars for display
|
||||||
|
LicenseeName string `xorm:""` // customer name
|
||||||
|
LicenseeEmail string `xorm:""` // customer email
|
||||||
|
DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains
|
||||||
|
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default
|
||||||
|
IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key
|
||||||
|
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
|
||||||
|
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LicenseKey) TableName() string {
|
||||||
|
return "license_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format.
|
||||||
|
func GenerateKeyString() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hex := strings.ToUpper(hex.EncodeToString(b))
|
||||||
|
return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashKey returns the SHA-256 hash of a raw key string.
|
||||||
|
func HashKey(rawKey string) string {
|
||||||
|
h := sha256.Sum256([]byte(rawKey))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicenseKey generates a new key, hashes it, stores it, and returns the raw key.
|
||||||
|
// The raw key is only available at creation time.
|
||||||
|
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
|
||||||
|
rawKey, err = GenerateKeyString()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GenerateKeyString: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key.KeyHash = HashKey(rawKey)
|
||||||
|
key.KeyPrefix = rawKey[:12] + "..."
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(key); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return rawKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseKeyByHash looks up a key by its SHA-256 hash.
|
||||||
|
func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) {
|
||||||
|
key := new(LicenseKey)
|
||||||
|
has, err := db.GetEngine(ctx).Where("key_hash = ?", hash).Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, db.ErrNotExist{Resource: "LicenseKey"}
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseKeyByID returns a key by its ID.
|
||||||
|
func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
|
||||||
|
key := new(LicenseKey)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, db.ErrNotExist{Resource: "LicenseKey", ID: id}
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenseKeys returns all keys for the given owner.
|
||||||
|
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
|
||||||
|
keys := make([]*LicenseKey, 0, 20)
|
||||||
|
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenseKeysByPackage returns all keys for a specific package.
|
||||||
|
func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) {
|
||||||
|
keys := make([]*LicenseKey, 0, 20)
|
||||||
|
return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountKeysByPackage returns the number of keys for a package.
|
||||||
|
func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) {
|
||||||
|
return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLicenseKey updates a license key.
|
||||||
|
func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicenseKey deletes a license key by ID.
|
||||||
|
func DeleteLicenseKey(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateLicenseKey validates a raw key string against the database.
|
||||||
|
// Returns the key record and its associated package, or an error.
|
||||||
|
func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, error) {
|
||||||
|
hash := HashKey(rawKey)
|
||||||
|
key, err := GetLicenseKeyByHash(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid license key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !key.IsActive {
|
||||||
|
return nil, nil, fmt.Errorf("license key is deactivated")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
if key.StartsUnix > 0 && now < key.StartsUnix {
|
||||||
|
return nil, nil, fmt.Errorf("license key not yet active")
|
||||||
|
}
|
||||||
|
if key.ExpiresUnix > 0 && now > key.ExpiresUnix {
|
||||||
|
return nil, nil, fmt.Errorf("license key has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := GetLicensePackageByID(ctx, key.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("license package not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pkg.IsActive {
|
||||||
|
return nil, nil, fmt.Errorf("license package is deactivated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, pkg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licenses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(LicenseKeyUsage))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseKeyUsage tracks update check activity for a license key.
|
||||||
|
type LicenseKeyUsage struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
KeyID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Domain string `xorm:""` // requesting domain from extra_query
|
||||||
|
IPAddress string `xorm:""`
|
||||||
|
UserAgent string `xorm:"TEXT"`
|
||||||
|
VersionFrom string `xorm:""` // version the client is updating from
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LicenseKeyUsage) TableName() string {
|
||||||
|
return "license_key_usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUsage inserts a usage tracking entry.
|
||||||
|
func RecordUsage(ctx context.Context, usage *LicenseKeyUsage) error {
|
||||||
|
_, err := db.GetEngine(ctx).Insert(usage)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentUsage returns the most recent usage entries for a key.
|
||||||
|
func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyUsage, error) {
|
||||||
|
usages := make([]*LicenseKeyUsage, 0, limit)
|
||||||
|
return usages, db.GetEngine(ctx).Where("key_id = ?", keyID).
|
||||||
|
OrderBy("created_unix DESC").Limit(limit).Find(&usages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsageByKey returns the total number of update checks for a key.
|
||||||
|
func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) {
|
||||||
|
return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage))
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licenses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(LicensePackage))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensePackage defines a purchasable subscription tier that determines
|
||||||
|
// what update streams a group of license keys can access.
|
||||||
|
type LicensePackage struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that owns this package
|
||||||
|
Name string `xorm:"NOT NULL"` // e.g. "Pro Annual", "Lifetime"
|
||||||
|
Description string `xorm:"TEXT"`
|
||||||
|
DurationDays int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited/lifetime
|
||||||
|
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited
|
||||||
|
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` // "all" = org-wide, or JSON array of repo IDs
|
||||||
|
// AllowedChannels defines which update streams keys from this package
|
||||||
|
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
|
||||||
|
AllowedChannels string `xorm:"TEXT"`
|
||||||
|
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LicensePackage) TableName() string {
|
||||||
|
return "license_package"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicensePackage creates a new license package.
|
||||||
|
func CreateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
|
||||||
|
_, err := db.GetEngine(ctx).Insert(pkg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicensePackageByID returns a license package by ID.
|
||||||
|
func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, error) {
|
||||||
|
pkg := new(LicensePackage)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(pkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, db.ErrNotExist{Resource: "LicensePackage", ID: id}
|
||||||
|
}
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLicensePackageOptions for db.Find/db.Count.
|
||||||
|
type FindLicensePackageOptions struct {
|
||||||
|
db.ListOptions
|
||||||
|
OwnerID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opts FindLicensePackageOptions) ToConds() builder.Cond {
|
||||||
|
cond := builder.NewCond()
|
||||||
|
if opts.OwnerID > 0 {
|
||||||
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
|
}
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicensePackages returns all packages for the given owner.
|
||||||
|
func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
|
||||||
|
pkgs := make([]*LicensePackage, 0, 10)
|
||||||
|
return pkgs, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&pkgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLicensePackage updates a license package.
|
||||||
|
func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(pkg.ID).AllCols().Update(pkg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicensePackage deletes a license package by ID.
|
||||||
|
func DeleteLicensePackage(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licenses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MasterPackageName = "Master (Internal)"
|
||||||
|
MasterPackageDesc = "Auto-created master package with unlimited access to all channels."
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureMasterKey ensures that a master license package and key exist for the given owner.
|
||||||
|
// Returns the master key's raw key string only if it was just created (empty string otherwise).
|
||||||
|
func EnsureMasterKey(ctx context.Context, ownerID int64) (rawKey string, err error) {
|
||||||
|
// Check if a master package already exists.
|
||||||
|
pkgs, err := ListLicensePackages(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var masterPkg *LicensePackage
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if pkg.Name == MasterPackageName {
|
||||||
|
masterPkg = pkg
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create master package if it doesn't exist.
|
||||||
|
if masterPkg == nil {
|
||||||
|
masterPkg = &LicensePackage{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: MasterPackageName,
|
||||||
|
Description: MasterPackageDesc,
|
||||||
|
DurationDays: 0, // lifetime
|
||||||
|
MaxSites: 0, // unlimited
|
||||||
|
RepoScope: "all",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := CreateLicensePackage(ctx, masterPkg); err != nil {
|
||||||
|
return "", fmt.Errorf("CreateLicensePackage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a master key already exists for this package.
|
||||||
|
keys, err := ListLicenseKeysByPackage(ctx, masterPkg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.IsInternal {
|
||||||
|
return "", nil // already exists, don't return raw key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the master key.
|
||||||
|
masterKey := &LicenseKey{
|
||||||
|
PackageID: masterPkg.ID,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
IsInternal: true,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
rawKey, err = CreateLicenseKey(ctx, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("CreateLicenseKey: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMasterKey returns the master key for an owner, if it exists.
|
||||||
|
func GetMasterKey(ctx context.Context, ownerID int64) (*LicenseKey, error) {
|
||||||
|
key := new(LicenseKey)
|
||||||
|
has, err := db.GetEngine(ctx).Where("owner_id = ? AND is_internal = ?", ownerID, true).Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licenses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(UpdateStreamConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStreamConfig stores update stream settings at org or repo level.
|
||||||
|
// When OwnerID is set and RepoID is 0, it's an org-level default.
|
||||||
|
// When RepoID is set, it's a per-repo override.
|
||||||
|
type UpdateStreamConfig struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default
|
||||||
|
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom
|
||||||
|
// CustomStreams is a JSON array of stream definitions.
|
||||||
|
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
|
||||||
|
CustomStreams string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UpdateStreamConfig) TableName() string {
|
||||||
|
return "update_stream_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDef defines a single update stream/channel.
|
||||||
|
type StreamDef struct {
|
||||||
|
Name string `json:"name"` // e.g. "stable", "lts", "nightly"
|
||||||
|
Suffix string `json:"suffix"` // tag suffix to match, e.g. "-lts", "-rc"
|
||||||
|
Description string `json:"description"` // human-readable label
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultJoomlaStreams returns the standard Joomla update streams.
|
||||||
|
func DefaultJoomlaStreams() []StreamDef {
|
||||||
|
return []StreamDef{
|
||||||
|
{Name: "stable", Suffix: "", Description: "Stable releases"},
|
||||||
|
{Name: "release-candidate", Suffix: "-rc", Description: "Release candidates"},
|
||||||
|
{Name: "beta", Suffix: "-beta", Description: "Beta testing"},
|
||||||
|
{Name: "alpha", Suffix: "-alpha", Description: "Alpha / early access"},
|
||||||
|
{Name: "development", Suffix: "-dev", Description: "Development builds"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomStreams parses the CustomStreams JSON field.
|
||||||
|
func (c *UpdateStreamConfig) GetCustomStreams() []StreamDef {
|
||||||
|
if c.CustomStreams == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var streams []StreamDef
|
||||||
|
if err := json.Unmarshal([]byte(c.CustomStreams), &streams); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveStreams returns the effective streams for this config.
|
||||||
|
func (c *UpdateStreamConfig) GetActiveStreams() []StreamDef {
|
||||||
|
if c.StreamMode == "custom" {
|
||||||
|
if custom := c.GetCustomStreams(); len(custom) > 0 {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultJoomlaStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgConfig returns the org-level update stream config.
|
||||||
|
func GetOrgConfig(ctx context.Context, ownerID int64) (*UpdateStreamConfig, error) {
|
||||||
|
cfg := new(UpdateStreamConfig)
|
||||||
|
has, err := db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", ownerID).Get(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return &UpdateStreamConfig{OwnerID: ownerID, StreamMode: "joomla"}, nil
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoConfig returns the repo-level override, or nil if none exists.
|
||||||
|
func GetRepoConfig(ctx context.Context, repoID int64) (*UpdateStreamConfig, error) {
|
||||||
|
cfg := new(UpdateStreamConfig)
|
||||||
|
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEffectiveStreams resolves the streams for a repo: repo override → org default → Joomla default.
|
||||||
|
func GetEffectiveStreams(ctx context.Context, ownerID, repoID int64) []StreamDef {
|
||||||
|
// Check repo-level override first.
|
||||||
|
repoCfg, err := GetRepoConfig(ctx, repoID)
|
||||||
|
if err == nil && repoCfg != nil {
|
||||||
|
return repoCfg.GetActiveStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to org-level config.
|
||||||
|
orgCfg, err := GetOrgConfig(ctx, ownerID)
|
||||||
|
if err == nil && orgCfg != nil {
|
||||||
|
return orgCfg.GetActiveStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultJoomlaStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig creates or updates an update stream config.
|
||||||
|
func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error {
|
||||||
|
existing := new(UpdateStreamConfig)
|
||||||
|
var has bool
|
||||||
|
var err error
|
||||||
|
if cfg.RepoID > 0 {
|
||||||
|
has, err = db.GetEngine(ctx).Where("repo_id = ?", cfg.RepoID).Get(existing)
|
||||||
|
} else {
|
||||||
|
has, err = db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", cfg.OwnerID).Get(existing)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if has {
|
||||||
|
cfg.ID = existing.ID
|
||||||
|
_, err = db.GetEngine(ctx).ID(cfg.ID).AllCols().Update(cfg)
|
||||||
|
} else {
|
||||||
|
_, err = db.GetEngine(ctx).Insert(cfg)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchStreamFromTag determines which stream a tag belongs to based on the given stream definitions.
|
||||||
|
func MatchStreamFromTag(tagName string, isPrerelease bool, streams []StreamDef) string {
|
||||||
|
lower := strings.ToLower(tagName)
|
||||||
|
|
||||||
|
// Check custom suffixes (longest match first to avoid "-rc" matching before "-rc-special").
|
||||||
|
var bestMatch string
|
||||||
|
bestLen := 0
|
||||||
|
for _, s := range streams {
|
||||||
|
if s.Suffix == "" {
|
||||||
|
continue // stable/default stream handled below
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, s.Suffix) && len(s.Suffix) > bestLen {
|
||||||
|
bestMatch = s.Name
|
||||||
|
bestLen = len(s.Suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestMatch != "" {
|
||||||
|
return bestMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// If prerelease and no suffix matched, use the first prerelease stream.
|
||||||
|
if isPrerelease {
|
||||||
|
for _, s := range streams {
|
||||||
|
if s.Suffix != "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: first stream with empty suffix (stable).
|
||||||
|
for _, s := range streams {
|
||||||
|
if s.Suffix == "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "stable"
|
||||||
|
}
|
||||||
@@ -412,6 +412,8 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
|
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
|
||||||
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
|
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
|
||||||
newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch),
|
newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch),
|
||||||
|
newMigration(335, "Add license key tables for update server", v1_27.AddLicenseKeyTables),
|
||||||
|
newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type licensePackage335 struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Name string `xorm:"NOT NULL"`
|
||||||
|
Description string `xorm:"TEXT"`
|
||||||
|
DurationDays int `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"`
|
||||||
|
AllowedChannels string `xorm:"TEXT"`
|
||||||
|
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (licensePackage335) TableName() string {
|
||||||
|
return "license_package"
|
||||||
|
}
|
||||||
|
|
||||||
|
type licenseKey335 struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
PackageID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
KeyHash string `xorm:"UNIQUE NOT NULL"`
|
||||||
|
KeyPrefix string `xorm:"NOT NULL"`
|
||||||
|
LicenseeName string `xorm:""`
|
||||||
|
LicenseeEmail string `xorm:""`
|
||||||
|
DomainRestriction string `xorm:"TEXT"`
|
||||||
|
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
IsInternal bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (licenseKey335) TableName() string {
|
||||||
|
return "license_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
type licenseKeyUsage335 struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
KeyID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Domain string `xorm:""`
|
||||||
|
IPAddress string `xorm:""`
|
||||||
|
UserAgent string `xorm:"TEXT"`
|
||||||
|
VersionFrom string `xorm:""`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (licenseKeyUsage335) TableName() string {
|
||||||
|
return "license_key_usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLicenseKeyTables creates the license_package, license_key, and
|
||||||
|
// license_key_usage tables for the update server license system.
|
||||||
|
func AddLicenseKeyTables(x *xorm.Engine) error {
|
||||||
|
return x.Sync(
|
||||||
|
new(licensePackage335),
|
||||||
|
new(licenseKey335),
|
||||||
|
new(licenseKeyUsage335),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateStreamConfig336 struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
|
||||||
|
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"`
|
||||||
|
CustomStreams string `xorm:"TEXT"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (updateStreamConfig336) TableName() string {
|
||||||
|
return "update_stream_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUpdateStreamConfigTable creates the update_stream_config table.
|
||||||
|
func AddUpdateStreamConfigTable(x *xorm.Engine) error {
|
||||||
|
return x.Sync(new(updateStreamConfig336))
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ func initDefaultConfig() {
|
|||||||
Instance: &InstanceStruct{
|
Instance: &InstanceStruct{
|
||||||
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
|
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
|
||||||
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
|
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
|
||||||
LandingPage: config.NewOption[LandingPageType]("instance.landing_page").WithFileConfig(config.CfgSecKey{Sec: "server", Key: "LANDING_PAGE"}),
|
LandingPage: config.NewOption[LandingPageType]("instance.landing_page"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// LicensePackage represents a license package (subscription tier).
|
||||||
|
type LicensePackage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OwnerID int64 `json:"owner_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
MaxSites int `json:"max_sites"`
|
||||||
|
RepoScope string `json:"repo_scope"`
|
||||||
|
AllowedChannels string `json:"allowed_channels"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicensePackageOption options for creating a license package.
|
||||||
|
type CreateLicensePackageOption struct {
|
||||||
|
Name string `json:"name" binding:"Required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
MaxSites int `json:"max_sites"`
|
||||||
|
RepoScope string `json:"repo_scope"`
|
||||||
|
AllowedChannels string `json:"allowed_channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditLicensePackageOption options for editing a license package.
|
||||||
|
type EditLicensePackageOption struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
DurationDays *int `json:"duration_days"`
|
||||||
|
MaxSites *int `json:"max_sites"`
|
||||||
|
RepoScope *string `json:"repo_scope"`
|
||||||
|
AllowedChannels *string `json:"allowed_channels"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseKey represents a license key (response — never includes raw key except on creation).
|
||||||
|
type LicenseKey struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PackageID int64 `json:"package_id"`
|
||||||
|
OwnerID int64 `json:"owner_id"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
LicenseeName string `json:"licensee_name"`
|
||||||
|
LicenseeEmail string `json:"licensee_email"`
|
||||||
|
DomainRestriction string `json:"domain_restriction"`
|
||||||
|
MaxSites int `json:"max_sites"`
|
||||||
|
IsInternal bool `json:"is_internal"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
StartsAt *time.Time `json:"starts_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseKeyCreated is the response when a key is first created (includes raw key).
|
||||||
|
type LicenseKeyCreated struct {
|
||||||
|
LicenseKey
|
||||||
|
// RawKey is the full license key string. Only returned on creation.
|
||||||
|
RawKey string `json:"raw_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicenseKeyOption options for creating a license key.
|
||||||
|
type CreateLicenseKeyOption struct {
|
||||||
|
PackageID int64 `json:"package_id" binding:"Required"`
|
||||||
|
LicenseeName string `json:"licensee_name"`
|
||||||
|
LicenseeEmail string `json:"licensee_email"`
|
||||||
|
DomainRestriction string `json:"domain_restriction"`
|
||||||
|
MaxSites int `json:"max_sites"`
|
||||||
|
// StartsAt is optional; defaults to now.
|
||||||
|
StartsAt *time.Time `json:"starts_at"`
|
||||||
|
// ExpiresAt is optional; auto-calculated from package duration if not set.
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditLicenseKeyOption options for editing a license key.
|
||||||
|
type EditLicenseKeyOption struct {
|
||||||
|
LicenseeName *string `json:"licensee_name"`
|
||||||
|
LicenseeEmail *string `json:"licensee_email"`
|
||||||
|
DomainRestriction *string `json:"domain_restriction"`
|
||||||
|
MaxSites *int `json:"max_sites"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseKeyUsage represents a usage tracking entry.
|
||||||
|
type LicenseKeyUsage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
KeyID int64 `json:"key_id"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
VersionFrom string `json:"version_from"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -2144,6 +2144,10 @@
|
|||||||
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",
|
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",
|
||||||
"repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default",
|
"repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default",
|
||||||
"repo.settings.releases_desc": "Enable Repository Releases",
|
"repo.settings.releases_desc": "Enable Repository Releases",
|
||||||
|
"repo.settings.unit_visibility": "Visibility",
|
||||||
|
"repo.settings.unit_visibility_private": "Private (follow repo visibility)",
|
||||||
|
"repo.settings.unit_visibility_public": "Public (anyone can read)",
|
||||||
|
"repo.settings.unit_visibility_releases_help": "Update feeds (updates.xml, dolibarr.json) are always accessible regardless of this setting. Set to Public to also show the releases page to anonymous visitors.",
|
||||||
"repo.settings.packages_desc": "Enable Repository Packages Registry",
|
"repo.settings.packages_desc": "Enable Repository Packages Registry",
|
||||||
"repo.settings.projects_desc": "Enable Projects",
|
"repo.settings.projects_desc": "Enable Projects",
|
||||||
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
|
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
|
||||||
@@ -2608,6 +2612,40 @@
|
|||||||
"repo.release.detail": "Release details",
|
"repo.release.detail": "Release details",
|
||||||
"repo.release.tags": "Tags",
|
"repo.release.tags": "Tags",
|
||||||
"repo.release.new_release": "New Release",
|
"repo.release.new_release": "New Release",
|
||||||
|
"repo.release.update_feed": "Update Feed",
|
||||||
|
"repo.licenses": "Licenses",
|
||||||
|
"repo.licenses.packages": "License Packages",
|
||||||
|
"repo.licenses.package_name": "Package",
|
||||||
|
"repo.licenses.duration": "Duration",
|
||||||
|
"repo.licenses.channels": "Channels",
|
||||||
|
"repo.licenses.keys_issued": "Keys",
|
||||||
|
"repo.licenses.status": "Status",
|
||||||
|
"repo.licenses.lifetime": "Lifetime",
|
||||||
|
"repo.licenses.days": "days",
|
||||||
|
"repo.licenses.all_channels": "All channels",
|
||||||
|
"repo.licenses.active": "Active",
|
||||||
|
"repo.licenses.inactive": "Inactive",
|
||||||
|
"repo.licenses.none": "No License Packages",
|
||||||
|
"repo.licenses.none_desc": "License packages can be created via the API to gate access to update streams.",
|
||||||
|
"repo.licenses.issued_keys": "Issued Keys",
|
||||||
|
"repo.licenses.key_prefix": "Key",
|
||||||
|
"repo.licenses.licensee": "Licensee",
|
||||||
|
"repo.licenses.expires": "Expires",
|
||||||
|
"repo.licenses.never": "Never",
|
||||||
|
"repo.licenses.new_package": "New Package",
|
||||||
|
"repo.licenses.description": "Description",
|
||||||
|
"repo.licenses.max_sites": "Max Sites",
|
||||||
|
"repo.licenses.channels_help": "Comma-separated channel names (e.g. stable,release-candidate). Leave empty for all channels.",
|
||||||
|
"repo.licenses.create_package": "Create Package",
|
||||||
|
"repo.licenses.package_created": "License package created successfully.",
|
||||||
|
"repo.licenses.generate_key": "Generate Key",
|
||||||
|
"repo.licenses.key_created": "License Key Created",
|
||||||
|
"repo.licenses.key_created_copy": "Copy this key now. It will not be shown again.",
|
||||||
|
"repo.licenses.revoke": "Revoke",
|
||||||
|
"repo.licenses.key_revoked": "License key revoked.",
|
||||||
|
"repo.licenses.master_key_created": "Master License Key Created",
|
||||||
|
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
|
||||||
|
"repo.licenses.update_feeds": "Update Feed URLs",
|
||||||
"repo.release.draft": "Draft",
|
"repo.release.draft": "Draft",
|
||||||
"repo.release.prerelease": "Pre-Release",
|
"repo.release.prerelease": "Pre-Release",
|
||||||
"repo.release.stable": "Stable",
|
"repo.release.stable": "Stable",
|
||||||
|
|||||||
@@ -1347,6 +1347,18 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
|
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
|
||||||
})
|
})
|
||||||
}, reqRepoReader(unit.TypeReleases))
|
}, reqRepoReader(unit.TypeReleases))
|
||||||
|
m.Group("/license-packages", func() {
|
||||||
|
m.Combo("").Get(repo.ListLicensePackages).
|
||||||
|
Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage)
|
||||||
|
}, reqToken(), reqAdmin())
|
||||||
|
m.Group("/license-keys", func() {
|
||||||
|
m.Combo("").Get(repo.ListLicenseKeys).
|
||||||
|
Post(bind(api.CreateLicenseKeyOption{}), repo.CreateLicenseKey)
|
||||||
|
m.Group("/{id}", func() {
|
||||||
|
m.Delete("", repo.DeleteLicenseKey)
|
||||||
|
m.Get("/usage", repo.GetLicenseKeyUsage)
|
||||||
|
})
|
||||||
|
}, reqToken(), reqAdmin())
|
||||||
m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync)
|
m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync)
|
||||||
m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync)
|
m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync)
|
||||||
m.Group("/push_mirrors", func() {
|
m.Group("/push_mirrors", func() {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage {
|
||||||
|
return &structs.LicensePackage{
|
||||||
|
ID: pkg.ID,
|
||||||
|
OwnerID: pkg.OwnerID,
|
||||||
|
Name: pkg.Name,
|
||||||
|
Description: pkg.Description,
|
||||||
|
DurationDays: pkg.DurationDays,
|
||||||
|
MaxSites: pkg.MaxSites,
|
||||||
|
RepoScope: pkg.RepoScope,
|
||||||
|
AllowedChannels: pkg.AllowedChannels,
|
||||||
|
IsActive: pkg.IsActive,
|
||||||
|
Created: time.Unix(int64(pkg.CreatedUnix), 0),
|
||||||
|
Updated: time.Unix(int64(pkg.UpdatedUnix), 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLicenseKeyAPI(key *licenses.LicenseKey) *structs.LicenseKey {
|
||||||
|
lk := &structs.LicenseKey{
|
||||||
|
ID: key.ID,
|
||||||
|
PackageID: key.PackageID,
|
||||||
|
OwnerID: key.OwnerID,
|
||||||
|
KeyPrefix: key.KeyPrefix,
|
||||||
|
LicenseeName: key.LicenseeName,
|
||||||
|
LicenseeEmail: key.LicenseeEmail,
|
||||||
|
DomainRestriction: key.DomainRestriction,
|
||||||
|
MaxSites: key.MaxSites,
|
||||||
|
IsInternal: key.IsInternal,
|
||||||
|
IsActive: key.IsActive,
|
||||||
|
Created: time.Unix(int64(key.CreatedUnix), 0),
|
||||||
|
}
|
||||||
|
if key.StartsUnix > 0 {
|
||||||
|
t := time.Unix(int64(key.StartsUnix), 0)
|
||||||
|
lk.StartsAt = &t
|
||||||
|
}
|
||||||
|
if key.ExpiresUnix > 0 {
|
||||||
|
t := time.Unix(int64(key.ExpiresUnix), 0)
|
||||||
|
lk.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
return lk
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicensePackages lists license packages for the repo owner.
|
||||||
|
func ListLicensePackages(ctx *context.APIContext) {
|
||||||
|
pkgs, err := licenses.ListLicensePackages(ctx, ctx.Repo.Repository.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*structs.LicensePackage, len(pkgs))
|
||||||
|
for i, pkg := range pkgs {
|
||||||
|
result[i] = toLicensePackageAPI(pkg)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicensePackage creates a new license package.
|
||||||
|
func CreateLicensePackage(ctx *context.APIContext) {
|
||||||
|
form := web.GetForm(ctx).(*structs.CreateLicensePackageOption)
|
||||||
|
|
||||||
|
pkg := &licenses.LicensePackage{
|
||||||
|
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||||
|
Name: form.Name,
|
||||||
|
Description: form.Description,
|
||||||
|
DurationDays: form.DurationDays,
|
||||||
|
MaxSites: form.MaxSites,
|
||||||
|
RepoScope: form.RepoScope,
|
||||||
|
AllowedChannels: form.AllowedChannels,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if pkg.RepoScope == "" {
|
||||||
|
pkg.RepoScope = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, toLicensePackageAPI(pkg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenseKeys lists license keys for the repo owner.
|
||||||
|
func ListLicenseKeys(ctx *context.APIContext) {
|
||||||
|
keys, err := licenses.ListLicenseKeys(ctx, ctx.Repo.Repository.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*structs.LicenseKey, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
result[i] = toLicenseKeyAPI(key)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicenseKey creates a new license key.
|
||||||
|
func CreateLicenseKey(ctx *context.APIContext) {
|
||||||
|
form := web.GetForm(ctx).(*structs.CreateLicenseKeyOption)
|
||||||
|
|
||||||
|
key := &licenses.LicenseKey{
|
||||||
|
PackageID: form.PackageID,
|
||||||
|
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||||
|
LicenseeName: form.LicenseeName,
|
||||||
|
LicenseeEmail: form.LicenseeEmail,
|
||||||
|
DomainRestriction: form.DomainRestriction,
|
||||||
|
MaxSites: form.MaxSites,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.StartsAt != nil {
|
||||||
|
key.StartsUnix = timeutil.TimeStamp(form.StartsAt.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.ExpiresAt != nil {
|
||||||
|
key.ExpiresUnix = timeutil.TimeStamp(form.ExpiresAt.Unix())
|
||||||
|
} else {
|
||||||
|
// Auto-calculate from package duration.
|
||||||
|
pkg, err := licenses.GetLicensePackageByID(ctx, form.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.DurationDays > 0 {
|
||||||
|
start := time.Now()
|
||||||
|
if form.StartsAt != nil {
|
||||||
|
start = *form.StartsAt
|
||||||
|
}
|
||||||
|
expires := start.AddDate(0, 0, pkg.DurationDays)
|
||||||
|
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, err := licenses.CreateLicenseKey(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &structs.LicenseKeyCreated{
|
||||||
|
LicenseKey: *toLicenseKeyAPI(key),
|
||||||
|
RawKey: rawKey,
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusCreated, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicenseKey deletes a license key.
|
||||||
|
func DeleteLicenseKey(ctx *context.APIContext) {
|
||||||
|
if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseKeyUsage returns usage logs for a license key.
|
||||||
|
func GetLicenseKeyUsage(ctx *context.APIContext) {
|
||||||
|
usages, err := licenses.GetRecentUsage(ctx, ctx.PathParamInt64("id"), 100)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*structs.LicenseKeyUsage, len(usages))
|
||||||
|
for i, u := range usages {
|
||||||
|
result[i] = &structs.LicenseKeyUsage{
|
||||||
|
ID: u.ID,
|
||||||
|
KeyID: u.KeyID,
|
||||||
|
RepoID: u.RepoID,
|
||||||
|
Domain: u.Domain,
|
||||||
|
IPAddress: u.IPAddress,
|
||||||
|
UserAgent: u.UserAgent,
|
||||||
|
VersionFrom: u.VersionFrom,
|
||||||
|
Created: time.Unix(int64(u.CreatedUnix), 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplOrgLicenses templates.TplName = "org/licenses"
|
||||||
|
|
||||||
|
// LicensePackageDisplay is used in templates.
|
||||||
|
type LicensePackageDisplay struct {
|
||||||
|
*licenses.LicensePackage
|
||||||
|
KeyCount int64
|
||||||
|
Created time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Licenses shows the org-level license packages and keys.
|
||||||
|
func Licenses(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.licenses")
|
||||||
|
ctx.Data["IsLicensesPage"] = true
|
||||||
|
|
||||||
|
org := ctx.Org.Organization
|
||||||
|
ownerID := org.ID
|
||||||
|
|
||||||
|
// Auto-create master key if org owner.
|
||||||
|
if ctx.Org.IsOwner {
|
||||||
|
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("EnsureMasterKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newMasterKey != "" {
|
||||||
|
ctx.Data["NewMasterKey"] = newMasterKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("ListLicensePackages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var display []LicensePackageDisplay
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
|
||||||
|
display = append(display, LicensePackageDisplay{
|
||||||
|
LicensePackage: pkg,
|
||||||
|
KeyCount: count,
|
||||||
|
Created: time.Unix(int64(pkg.CreatedUnix), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.Data["LicensePackages"] = display
|
||||||
|
|
||||||
|
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("ListLicenseKeys", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["LicenseKeys"] = keys
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplOrgLicenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesCreatePackage handles POST to create a new org-level license package.
|
||||||
|
func LicensesCreatePackage(ctx *context.Context) {
|
||||||
|
name := ctx.FormString("name")
|
||||||
|
if name == "" {
|
||||||
|
ctx.Flash.Error("Package name is required")
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||||
|
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||||
|
|
||||||
|
pkg := &licenses.LicensePackage{
|
||||||
|
OwnerID: ctx.Org.Organization.ID,
|
||||||
|
Name: name,
|
||||||
|
Description: ctx.FormString("description"),
|
||||||
|
DurationDays: durationDays,
|
||||||
|
MaxSites: maxSites,
|
||||||
|
AllowedChannels: ctx.FormString("allowed_channels"),
|
||||||
|
RepoScope: "all",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
|
||||||
|
ctx.ServerError("CreateLicensePackage", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.licenses.package_created"))
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesGenerateKey handles POST to generate a key from an org package.
|
||||||
|
func LicensesGenerateKey(ctx *context.Context) {
|
||||||
|
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
|
||||||
|
if packageID == 0 {
|
||||||
|
ctx.Flash.Error("Invalid package")
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := licenses.GetLicensePackageByID(ctx, packageID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetLicensePackageByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := &licenses.LicenseKey{
|
||||||
|
PackageID: packageID,
|
||||||
|
OwnerID: ctx.Org.Organization.ID,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.DurationDays > 0 {
|
||||||
|
expires := time.Now().AddDate(0, 0, pkg.DurationDays)
|
||||||
|
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, err := licenses.CreateLicenseKey(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("CreateLicenseKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render with the new key shown.
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.licenses")
|
||||||
|
ctx.Data["IsLicensesPage"] = true
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
|
||||||
|
ctx.Data["NewKeyCreated"] = rawKey
|
||||||
|
|
||||||
|
ownerID := ctx.Org.Organization.ID
|
||||||
|
pkgs, _ := licenses.ListLicensePackages(ctx, ownerID)
|
||||||
|
var display []LicensePackageDisplay
|
||||||
|
for _, p := range pkgs {
|
||||||
|
count, _ := licenses.CountKeysByPackage(ctx, p.ID)
|
||||||
|
display = append(display, LicensePackageDisplay{
|
||||||
|
LicensePackage: p,
|
||||||
|
KeyCount: count,
|
||||||
|
Created: time.Unix(int64(p.CreatedUnix), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.Data["LicensePackages"] = display
|
||||||
|
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
|
||||||
|
ctx.Data["LicenseKeys"] = keys
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplOrgLicenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesRevokeKey handles POST to revoke an org license key.
|
||||||
|
func LicensesRevokeKey(ctx *context.Context) {
|
||||||
|
keyID := ctx.PathParamInt64("id")
|
||||||
|
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetLicenseKeyByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key.IsActive = false
|
||||||
|
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
|
||||||
|
ctx.ServerError("UpdateLicenseKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked"))
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplLicenses templates.TplName = "repo/licenses"
|
||||||
|
|
||||||
|
// LicensePackageDisplay is used in templates.
|
||||||
|
type LicensePackageDisplay struct {
|
||||||
|
*licenses.LicensePackage
|
||||||
|
KeyCount int64
|
||||||
|
Created time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Licenses shows the license packages and keys for a repo.
|
||||||
|
func Licenses(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.licenses")
|
||||||
|
ctx.Data["PageIsLicenses"] = true
|
||||||
|
ctx.Data["IsLicensesPage"] = true
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||||
|
|
||||||
|
ownerID := ctx.Repo.Repository.OwnerID
|
||||||
|
|
||||||
|
// Auto-create master package + key if admin and none exist.
|
||||||
|
if ctx.Repo.Permission.IsAdmin() {
|
||||||
|
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("EnsureMasterKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newMasterKey != "" {
|
||||||
|
ctx.Data["NewMasterKey"] = newMasterKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("ListLicensePackages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var display []LicensePackageDisplay
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
|
||||||
|
display = append(display, LicensePackageDisplay{
|
||||||
|
LicensePackage: pkg,
|
||||||
|
KeyCount: count,
|
||||||
|
Created: time.Unix(int64(pkg.CreatedUnix), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.Data["LicensePackages"] = display
|
||||||
|
|
||||||
|
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("ListLicenseKeys", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["LicenseKeys"] = keys
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplLicenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesCreatePackage handles POST to create a new license package.
|
||||||
|
func LicensesCreatePackage(ctx *context.Context) {
|
||||||
|
name := ctx.FormString("name")
|
||||||
|
if name == "" {
|
||||||
|
ctx.Flash.Error("Package name is required")
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
|
||||||
|
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
|
||||||
|
|
||||||
|
pkg := &licenses.LicensePackage{
|
||||||
|
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||||
|
Name: name,
|
||||||
|
Description: ctx.FormString("description"),
|
||||||
|
DurationDays: durationDays,
|
||||||
|
MaxSites: maxSites,
|
||||||
|
AllowedChannels: ctx.FormString("allowed_channels"),
|
||||||
|
RepoScope: "all",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
|
||||||
|
ctx.ServerError("CreateLicensePackage", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.licenses.package_created"))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesGenerateKey handles POST to generate a new key from a package.
|
||||||
|
func LicensesGenerateKey(ctx *context.Context) {
|
||||||
|
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
|
||||||
|
if packageID == 0 {
|
||||||
|
ctx.Flash.Error("Invalid package")
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := licenses.GetLicensePackageByID(ctx, packageID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetLicensePackageByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := &licenses.LicenseKey{
|
||||||
|
PackageID: packageID,
|
||||||
|
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-calculate expiry from package duration.
|
||||||
|
if pkg.DurationDays > 0 {
|
||||||
|
expires := time.Now().AddDate(0, 0, pkg.DurationDays)
|
||||||
|
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, err := licenses.CreateLicenseKey(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("CreateLicenseKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.licenses")
|
||||||
|
ctx.Data["PageIsLicenses"] = true
|
||||||
|
ctx.Data["IsLicensesPage"] = true
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||||
|
ctx.Data["NewKeyCreated"] = rawKey
|
||||||
|
|
||||||
|
// Re-render the page with the new key displayed.
|
||||||
|
ownerID := ctx.Repo.Repository.OwnerID
|
||||||
|
pkgs, _ := licenses.ListLicensePackages(ctx, ownerID)
|
||||||
|
var display []LicensePackageDisplay
|
||||||
|
for _, p := range pkgs {
|
||||||
|
count, _ := licenses.CountKeysByPackage(ctx, p.ID)
|
||||||
|
display = append(display, LicensePackageDisplay{
|
||||||
|
LicensePackage: p,
|
||||||
|
KeyCount: count,
|
||||||
|
Created: time.Unix(int64(p.CreatedUnix), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.Data["LicensePackages"] = display
|
||||||
|
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
|
||||||
|
ctx.Data["LicenseKeys"] = keys
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplLicenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesRevokeKey handles POST to revoke a license key.
|
||||||
|
func LicensesRevokeKey(ctx *context.Context) {
|
||||||
|
keyID := ctx.PathParamInt64("id")
|
||||||
|
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetLicenseKeyByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key.IsActive = false
|
||||||
|
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
|
||||||
|
ctx.ServerError("UpdateLicenseKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked"))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
||||||
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||||
@@ -510,6 +511,17 @@ func newRepoUnit(repo *repo_model.Repository, unitType unit_model.Type, config c
|
|||||||
return repoUnit
|
return repoUnit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyUnitVisibility sets AnonymousAccessMode on a unit based on the form value.
|
||||||
|
// Values: "" or "not-set" = none, "anonymous-read" = anonymous read.
|
||||||
|
func applyUnitVisibility(unit *repo_model.RepoUnit, visibility string) {
|
||||||
|
switch visibility {
|
||||||
|
case "anonymous-read":
|
||||||
|
unit.AnonymousAccessMode = perm.AccessModeRead
|
||||||
|
default:
|
||||||
|
unit.AnonymousAccessMode = perm.AccessModeNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleSettingsPostAdvanced(ctx *context.Context) {
|
func handleSettingsPostAdvanced(ctx *context.Context) {
|
||||||
form := web.GetForm(ctx).(*forms.RepoSettingForm)
|
form := web.GetForm(ctx).(*forms.RepoSettingForm)
|
||||||
repo := ctx.Repo.Repository
|
repo := ctx.Repo.Repository
|
||||||
@@ -527,7 +539,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() {
|
if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() {
|
||||||
units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil))
|
u := newRepoUnit(repo, unit_model.TypeCode, nil)
|
||||||
|
applyUnitVisibility(&u, form.CodeVisibility)
|
||||||
|
units = append(units, u)
|
||||||
} else if !unit_model.TypeCode.UnitGlobalDisabled() {
|
} else if !unit_model.TypeCode.UnitGlobalDisabled() {
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode)
|
||||||
}
|
}
|
||||||
@@ -544,7 +558,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
|||||||
}))
|
}))
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
|
||||||
} else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
|
} else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
|
||||||
units = append(units, newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig)))
|
u := newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig))
|
||||||
|
applyUnitVisibility(&u, form.WikiVisibility)
|
||||||
|
units = append(units, u)
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
|
||||||
} else {
|
} else {
|
||||||
if !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
|
if !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
|
||||||
@@ -581,11 +597,13 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
|||||||
}))
|
}))
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
|
||||||
} else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() {
|
} else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() {
|
||||||
units = append(units, newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{
|
u := newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{
|
||||||
EnableTimetracker: form.EnableTimetracker,
|
EnableTimetracker: form.EnableTimetracker,
|
||||||
AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
|
AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
|
||||||
EnableDependencies: form.EnableIssueDependencies,
|
EnableDependencies: form.EnableIssueDependencies,
|
||||||
}))
|
})
|
||||||
|
applyUnitVisibility(&u, form.IssuesVisibility)
|
||||||
|
units = append(units, u)
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
|
||||||
} else {
|
} else {
|
||||||
if !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
|
if !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
|
||||||
@@ -605,7 +623,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() {
|
if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() {
|
||||||
units = append(units, newRepoUnit(repo, unit_model.TypeReleases, nil))
|
u := newRepoUnit(repo, unit_model.TypeReleases, nil)
|
||||||
|
applyUnitVisibility(&u, form.ReleasesVisibility)
|
||||||
|
units = append(units, u)
|
||||||
} else if !unit_model.TypeReleases.UnitGlobalDisabled() {
|
} else if !unit_model.TypeReleases.UnitGlobalDisabled() {
|
||||||
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases)
|
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/updateserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateUpdateKey checks for a license key in the request and validates it.
|
||||||
|
// Returns allowed channels (nil = all channels) and whether access is granted.
|
||||||
|
func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) {
|
||||||
|
rawKey := ctx.FormString("key")
|
||||||
|
if rawKey == "" {
|
||||||
|
rawKey = ctx.FormString("download_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawKey == "" {
|
||||||
|
// No key provided — allow public access (all channels).
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("License key validation failed: %v", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record usage.
|
||||||
|
_ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{
|
||||||
|
KeyID: key.ID,
|
||||||
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
Domain: ctx.FormString("domain"),
|
||||||
|
IPAddress: ctx.RemoteAddr(),
|
||||||
|
UserAgent: ctx.Req.UserAgent(),
|
||||||
|
VersionFrom: ctx.FormString("version"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse allowed channels from the package.
|
||||||
|
if pkg.AllowedChannels != "" {
|
||||||
|
channels := strings.Split(pkg.AllowedChannels, ",")
|
||||||
|
for i := range channels {
|
||||||
|
channels[i] = strings.TrimSpace(channels[i])
|
||||||
|
}
|
||||||
|
// Also try JSON array format.
|
||||||
|
if strings.HasPrefix(pkg.AllowedChannels, "[") {
|
||||||
|
var parsed []string
|
||||||
|
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil {
|
||||||
|
channels = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalize shorthand names to full Joomla convention.
|
||||||
|
for i := range channels {
|
||||||
|
channels[i] = updateserver.NormalizeChannel(channels[i])
|
||||||
|
}
|
||||||
|
return channels, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master/internal keys or packages with no channel restriction — all channels.
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
|
||||||
|
// from the repository's releases.
|
||||||
|
func ServeUpdatesXML(ctx *context.Context) {
|
||||||
|
allowedChannels, ok := validateUpdateKey(ctx)
|
||||||
|
if !ok {
|
||||||
|
// Return empty updates XML for invalid keys (Joomla-compatible).
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><updates></updates>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GenerateJoomlaXML", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = ctx.Resp.Write(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed
|
||||||
|
// from the repository's releases.
|
||||||
|
func ServeDolibarrJSON(ctx *context.Context) {
|
||||||
|
data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GenerateDolibarrJSON", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("json.Marshal", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = ctx.Resp.Write(jsonData)
|
||||||
|
}
|
||||||
@@ -1099,6 +1099,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
// at the moment, only editing "owner-level projects" need to "mention", maybe in the future we can relax the permission check
|
// at the moment, only editing "owner-level projects" need to "mention", maybe in the future we can relax the permission check
|
||||||
m.Get("/mentions-in-owner", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner)
|
m.Get("/mentions-in-owner", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner)
|
||||||
|
|
||||||
|
m.Group("/licenses", func() {
|
||||||
|
m.Get("", org.Licenses)
|
||||||
|
m.Post("/packages", org.LicensesCreatePackage)
|
||||||
|
m.Post("/keys/generate", org.LicensesGenerateKey)
|
||||||
|
m.Post("/keys/{id}/revoke", org.LicensesRevokeKey)
|
||||||
|
})
|
||||||
|
|
||||||
m.Get("/repositories", org.Repositories)
|
m.Get("/repositories", org.Repositories)
|
||||||
m.Get("/heatmap", user.DashboardHeatmap)
|
m.Get("/heatmap", user.DashboardHeatmap)
|
||||||
|
|
||||||
@@ -1494,6 +1501,22 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
|
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
|
||||||
// end "/{username}/{reponame}": repo releases
|
// end "/{username}/{reponame}": repo releases
|
||||||
|
|
||||||
|
// "/{username}/{reponame}": update server endpoints
|
||||||
|
m.Group("/{username}/{reponame}", func() {
|
||||||
|
m.Get("/updates.xml", repo.ServeUpdatesXML)
|
||||||
|
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
|
||||||
|
}, optSignIn, context.RepoAssignment)
|
||||||
|
// end "/{username}/{reponame}": update server
|
||||||
|
|
||||||
|
// "/{username}/{reponame}": licenses page
|
||||||
|
m.Group("/{username}/{reponame}/licenses", func() {
|
||||||
|
m.Get("", repo.Licenses)
|
||||||
|
m.Post("/packages", repo.LicensesCreatePackage)
|
||||||
|
m.Post("/keys/generate", repo.LicensesGenerateKey)
|
||||||
|
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
|
||||||
|
}, optSignIn, context.RepoAssignment)
|
||||||
|
// end "/{username}/{reponame}": licenses
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
|
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
|
||||||
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
|
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
|
||||||
}, optSignIn, context.RepoAssignment)
|
}, optSignIn, context.RepoAssignment)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
issues_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
issues_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
|
licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
access_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
access_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
||||||
@@ -605,6 +606,14 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if license packages exist for this repo's owner (enables Licenses tab).
|
||||||
|
numLicensePackages, _ := db.Count[licenses_model.LicensePackage](ctx, licenses_model.FindLicensePackageOptions{
|
||||||
|
OwnerID: repo.OwnerID,
|
||||||
|
})
|
||||||
|
ctx.Data["NumLicensePackages"] = numLicensePackages
|
||||||
|
ctx.Data["EnableLicenses"] = numLicensePackages > 0
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
|
||||||
|
|
||||||
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
|
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
|
||||||
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
|
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
|
||||||
ctx.Data["Repository"] = repo
|
ctx.Data["Repository"] = repo
|
||||||
|
|||||||
@@ -110,12 +110,14 @@ type RepoSettingForm struct {
|
|||||||
EnablePrune bool
|
EnablePrune bool
|
||||||
|
|
||||||
// Advanced settings
|
// Advanced settings
|
||||||
EnableCode bool
|
EnableCode bool
|
||||||
|
CodeVisibility string
|
||||||
|
|
||||||
EnableWiki bool
|
EnableWiki bool
|
||||||
EnableExternalWiki bool
|
EnableExternalWiki bool
|
||||||
DefaultWikiBranch string
|
DefaultWikiBranch string
|
||||||
ExternalWikiURL string
|
ExternalWikiURL string
|
||||||
|
WikiVisibility string
|
||||||
|
|
||||||
EnableIssues bool
|
EnableIssues bool
|
||||||
EnableExternalTracker bool
|
EnableExternalTracker bool
|
||||||
@@ -124,13 +126,15 @@ type RepoSettingForm struct {
|
|||||||
TrackerIssueStyle string
|
TrackerIssueStyle string
|
||||||
ExternalTrackerRegexpPattern string
|
ExternalTrackerRegexpPattern string
|
||||||
EnableCloseIssuesViaCommitInAnyBranch bool
|
EnableCloseIssuesViaCommitInAnyBranch bool
|
||||||
|
IssuesVisibility string
|
||||||
|
|
||||||
EnableProjects bool
|
EnableProjects bool
|
||||||
ProjectsMode string
|
ProjectsMode string
|
||||||
|
|
||||||
EnableReleases bool
|
EnableReleases bool
|
||||||
|
ReleasesVisibility string
|
||||||
|
|
||||||
EnablePackages bool
|
EnablePackages bool
|
||||||
|
|
||||||
EnablePulls bool
|
EnablePulls bool
|
||||||
PullsIgnoreWhitespace bool
|
PullsIgnoreWhitespace bool
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package updateserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DolibarrUpdate represents a single module update entry in Dolibarr format.
|
||||||
|
type DolibarrUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
DownloadURL string `json:"url"`
|
||||||
|
ChangelogURL string `json:"changelog"`
|
||||||
|
ReleaseURL string `json:"release_url"`
|
||||||
|
Requires string `json:"requires,omitempty"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DolibarrUpdates holds the full update feed response.
|
||||||
|
type DolibarrUpdates struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Updates []DolibarrUpdate `json:"updates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases.
|
||||||
|
func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, error) {
|
||||||
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
ListOptions: db.ListOptionsAll,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
IncludeTags: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FindReleases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.LoadOwner(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||||
|
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||||
|
|
||||||
|
result := &DolibarrUpdates{
|
||||||
|
Module: repo.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve effective streams.
|
||||||
|
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||||
|
|
||||||
|
// Track best release per channel.
|
||||||
|
bestByChannel := make(map[string]*repo_model.Release)
|
||||||
|
for _, rel := range releases {
|
||||||
|
if rel.IsDraft || rel.IsTag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
|
||||||
|
existing, ok := bestByChannel[ch]
|
||||||
|
if !ok || rel.CreatedUnix > existing.CreatedUnix {
|
||||||
|
bestByChannel[ch] = rel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stream := range streams {
|
||||||
|
ch := stream.Name
|
||||||
|
rel, ok := bestByChannel[ch]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rel.LoadAttributes(ctx); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadURL string
|
||||||
|
for _, att := range rel.Attachments {
|
||||||
|
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
|
||||||
|
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if downloadURL == "" {
|
||||||
|
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := extractVersion(rel.TagName)
|
||||||
|
suffix := stream.Suffix
|
||||||
|
if suffix == "" {
|
||||||
|
suffix = channelSuffix(ch)
|
||||||
|
}
|
||||||
|
if suffix != "" {
|
||||||
|
version = version + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Updates = append(result.Updates, DolibarrUpdate{
|
||||||
|
Name: repo.Name,
|
||||||
|
Version: version,
|
||||||
|
Channel: ch,
|
||||||
|
DownloadURL: downloadURL,
|
||||||
|
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
|
||||||
|
ReleaseURL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
|
||||||
|
Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package updateserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||||
|
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Joomla-compatible updates.xml structures for XML marshaling.
|
||||||
|
|
||||||
|
type xmlUpdates struct {
|
||||||
|
XMLName xml.Name `xml:"updates"`
|
||||||
|
Updates []xmlUpdate `xml:"update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlUpdate struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Element string `xml:"element"`
|
||||||
|
Type string `xml:"type"`
|
||||||
|
Client string `xml:"client"`
|
||||||
|
Version string `xml:"version"`
|
||||||
|
CreationDate string `xml:"creationDate"`
|
||||||
|
InfoURL xmlInfoURL `xml:"infourl"`
|
||||||
|
Downloads xmlDownloads `xml:"downloads"`
|
||||||
|
SHA256 string `xml:"sha256,omitempty"`
|
||||||
|
Tags xmlTags `xml:"tags"`
|
||||||
|
ChangelogURL string `xml:"changelogurl,omitempty"`
|
||||||
|
Maintainer string `xml:"maintainer,omitempty"`
|
||||||
|
MaintainerURL string `xml:"maintainerurl,omitempty"`
|
||||||
|
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlInfoURL struct {
|
||||||
|
Title string `xml:"title,attr"`
|
||||||
|
URL string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlDownloads struct {
|
||||||
|
DownloadURL []xmlDownloadURL `xml:"downloadurl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlDownloadURL struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Format string `xml:"format,attr"`
|
||||||
|
URL string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlTags struct {
|
||||||
|
Tag string `xml:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmlTargetPlat struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Version string `xml:"version,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelFromTag maps a release tag name to a Joomla update channel.
|
||||||
|
// Joomla update stream names (full convention).
|
||||||
|
const (
|
||||||
|
ChannelStable = "stable"
|
||||||
|
ChannelReleaseCandidate = "release-candidate"
|
||||||
|
ChannelBeta = "beta"
|
||||||
|
ChannelAlpha = "alpha"
|
||||||
|
ChannelDevelopment = "development"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllChannels in display order (most stable first).
|
||||||
|
var AllChannels = []string{ChannelStable, ChannelReleaseCandidate, ChannelBeta, ChannelAlpha, ChannelDevelopment}
|
||||||
|
|
||||||
|
// channelFromTag maps a release tag name to a Joomla update channel.
|
||||||
|
func channelFromTag(tagName string, isPrerelease bool) string {
|
||||||
|
lower := strings.ToLower(tagName)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"):
|
||||||
|
return ChannelDevelopment
|
||||||
|
case strings.Contains(lower, "-alpha"):
|
||||||
|
return ChannelAlpha
|
||||||
|
case strings.Contains(lower, "-beta"):
|
||||||
|
return ChannelBeta
|
||||||
|
case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"):
|
||||||
|
return ChannelReleaseCandidate
|
||||||
|
case isPrerelease:
|
||||||
|
return ChannelReleaseCandidate
|
||||||
|
default:
|
||||||
|
return ChannelStable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeChannel maps shorthand channel names to the full Joomla convention.
|
||||||
|
// Accepts both "rc" and "release-candidate", "dev" and "development", etc.
|
||||||
|
func NormalizeChannel(ch string) string {
|
||||||
|
switch strings.ToLower(ch) {
|
||||||
|
case "rc", "release-candidate":
|
||||||
|
return ChannelReleaseCandidate
|
||||||
|
case "dev", "development":
|
||||||
|
return ChannelDevelopment
|
||||||
|
case "alpha":
|
||||||
|
return ChannelAlpha
|
||||||
|
case "beta":
|
||||||
|
return ChannelBeta
|
||||||
|
case "stable":
|
||||||
|
return ChannelStable
|
||||||
|
default:
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
|
||||||
|
// It returns the raw XML bytes. The element, maintainer, and target platform
|
||||||
|
// are derived from the repo name and owner.
|
||||||
|
// allowedChannels optionally restricts output to specific channels (nil = all).
|
||||||
|
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
|
||||||
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
ListOptions: db.ListOptionsAll,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
IncludeTags: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetReleasesByRepoID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.LoadOwner(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("LoadOwner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := setting.AppURL
|
||||||
|
if strings.HasSuffix(baseURL, "/") {
|
||||||
|
baseURL = baseURL[:len(baseURL)-1]
|
||||||
|
}
|
||||||
|
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||||
|
|
||||||
|
element := strings.ToLower(repo.Name)
|
||||||
|
|
||||||
|
// Resolve effective streams (repo override → org default → Joomla default).
|
||||||
|
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||||
|
|
||||||
|
// Track best (latest) release per channel to emit one entry per channel.
|
||||||
|
bestByChannel := make(map[string]*repo_model.Release)
|
||||||
|
for _, rel := range releases {
|
||||||
|
if rel.IsDraft || rel.IsTag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
|
||||||
|
existing, ok := bestByChannel[ch]
|
||||||
|
if !ok || rel.CreatedUnix > existing.CreatedUnix {
|
||||||
|
bestByChannel[ch] = rel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build allowed channel set for filtering.
|
||||||
|
// Normalize shorthand names so both "rc" and "release-candidate" work.
|
||||||
|
channelAllowed := make(map[string]bool)
|
||||||
|
if len(allowedChannels) > 0 {
|
||||||
|
for _, c := range allowedChannels {
|
||||||
|
channelAllowed[NormalizeChannel(c)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates xmlUpdates
|
||||||
|
for _, stream := range streams {
|
||||||
|
ch := stream.Name
|
||||||
|
// Skip channels not in the allowed set (when filtering is active).
|
||||||
|
if len(channelAllowed) > 0 && !channelAllowed[ch] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel, ok := bestByChannel[ch]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load attachments for download URLs.
|
||||||
|
if err := rel.LoadAttributes(ctx); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first .zip attachment as the download URL.
|
||||||
|
var downloadURL string
|
||||||
|
for _, att := range rel.Attachments {
|
||||||
|
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
|
||||||
|
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to the release tag archive if no zip attachment.
|
||||||
|
if downloadURL == "" {
|
||||||
|
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := extractVersion(rel.TagName)
|
||||||
|
suffix := stream.Suffix
|
||||||
|
if suffix == "" {
|
||||||
|
suffix = channelSuffix(ch) // fallback for Joomla defaults
|
||||||
|
}
|
||||||
|
if suffix != "" {
|
||||||
|
version = version + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
u := xmlUpdate{
|
||||||
|
Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
|
||||||
|
Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch),
|
||||||
|
Element: element,
|
||||||
|
Type: "component",
|
||||||
|
Client: "site",
|
||||||
|
Version: version,
|
||||||
|
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
|
||||||
|
InfoURL: xmlInfoURL{
|
||||||
|
Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
|
||||||
|
URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
|
||||||
|
},
|
||||||
|
Downloads: xmlDownloads{
|
||||||
|
DownloadURL: []xmlDownloadURL{
|
||||||
|
{Type: "full", Format: "zip", URL: downloadURL},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tags: xmlTags{Tag: ch},
|
||||||
|
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
|
||||||
|
Maintainer: repo.Owner.Name,
|
||||||
|
MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name),
|
||||||
|
TargetPlatform: xmlTargetPlat{
|
||||||
|
Name: "joomla",
|
||||||
|
Version: ".*",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.Updates = append(updates.Updates, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := xml.MarshalIndent(updates, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]byte(xml.Header), output...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractVersion strips common tag prefixes (v, release-, etc.) to get the version.
|
||||||
|
func extractVersion(tagName string) string {
|
||||||
|
v := tagName
|
||||||
|
v = strings.TrimPrefix(v, "v")
|
||||||
|
v = strings.TrimPrefix(v, "release-")
|
||||||
|
v = strings.TrimPrefix(v, "release/")
|
||||||
|
// Strip channel suffixes to get base version.
|
||||||
|
for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
|
||||||
|
if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
|
||||||
|
v = v[:idx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelSuffix returns the version suffix for a channel.
|
||||||
|
func channelSuffix(channel string) string {
|
||||||
|
switch channel {
|
||||||
|
case ChannelDevelopment:
|
||||||
|
return "-dev"
|
||||||
|
case ChannelAlpha:
|
||||||
|
return "-alpha"
|
||||||
|
case ChannelBeta:
|
||||||
|
return "-beta"
|
||||||
|
case ChannelReleaseCandidate:
|
||||||
|
return "-rc"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content organization">
|
||||||
|
{{template "org/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
|
||||||
|
{{if .NewMasterKey}}
|
||||||
|
<div class="ui info message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
|
||||||
|
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .NewKeyCreated}}
|
||||||
|
<div class="ui success message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
|
||||||
|
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h4 class="ui top attached header tw-flex tw-justify-between tw-items-center">
|
||||||
|
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
|
||||||
|
{{if .IsRepoAdmin}}
|
||||||
|
<a class="ui small primary button" href="#new-package-form" onclick="this.parentElement.parentElement.nextElementSibling.querySelector('#new-package-form').style.display='block'; return false;">
|
||||||
|
{{ctx.Locale.Tr "repo.licenses.new_package"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .IsRepoAdmin}}
|
||||||
|
<div id="new-package-form" style="display: none;" class="tw-mb-4">
|
||||||
|
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="required field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
|
||||||
|
<input name="name" required placeholder="Pro Annual">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
|
||||||
|
<input name="description" placeholder="Annual pro subscription">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="three fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
|
||||||
|
<input name="duration_days" type="number" value="0" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||||
|
<input name="max_sites" type="number" value="0" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||||
|
<input name="allowed_channels" placeholder="stable,release-candidate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
|
||||||
|
</form>
|
||||||
|
<div class="divider"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .LicensePackages}}
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.duration"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||||
|
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LicensePackages}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||||
|
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
|
||||||
|
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
|
||||||
|
<td>{{.KeyCount}}</td>
|
||||||
|
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||||
|
{{if $.IsRepoAdmin}}
|
||||||
|
<td class="tw-text-right">
|
||||||
|
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="package_id" value="{{.ID}}">
|
||||||
|
<button class="ui tiny primary button" type="submit">
|
||||||
|
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-placeholder">
|
||||||
|
{{svg "octicon-key" 48}}
|
||||||
|
<h2>{{ctx.Locale.Tr "repo.licenses.none"}}</h2>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .LicenseKeys}}
|
||||||
|
<h4 class="ui top attached header tw-mt-4">
|
||||||
|
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||||
|
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LicenseKeys}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</span>{{end}}</td>
|
||||||
|
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
|
||||||
|
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
|
||||||
|
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||||
|
{{if $.IsRepoAdmin}}
|
||||||
|
<td class="tw-text-right">
|
||||||
|
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" class="tw-inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<button class="ui tiny red button" type="submit">
|
||||||
|
{{svg "octicon-x" 14}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .IsOrganizationMember}}
|
||||||
|
<a class="{{if .IsLicensesPage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/licenses">
|
||||||
|
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
{{if and .IsRepoIndexerEnabled .CanReadCode}}
|
{{if and .IsRepoIndexerEnabled .CanReadCode}}
|
||||||
<a class="{{if .IsCodePage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/code">
|
<a class="{{if .IsCodePage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/code">
|
||||||
{{svg "octicon-code"}} {{ctx.Locale.Tr "org.code"}}
|
{{svg "octicon-code"}} {{ctx.Locale.Tr "org.code"}}
|
||||||
|
|||||||
@@ -128,6 +128,15 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if or .EnableLicenses .IsRepoAdmin}}
|
||||||
|
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
|
||||||
|
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
|
||||||
|
{{if .NumLicensePackages}}
|
||||||
|
<span class="ui small label">{{CountFmt .NumLicensePackages}}</span>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||||
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
|
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
|
||||||
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
|
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
|
||||||
|
{{if .NewMasterKey}}
|
||||||
|
<div class="ui info message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
|
||||||
|
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .NewKeyCreated}}
|
||||||
|
<div class="ui success message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
|
||||||
|
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* ── License Packages ── */}}
|
||||||
|
<h4 class="ui top attached header tw-flex tw-justify-between tw-items-center">
|
||||||
|
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
|
||||||
|
{{if .IsRepoAdmin}}
|
||||||
|
<a class="ui small primary button" href="#new-package-form" onclick="document.getElementById('new-package-form').style.display=document.getElementById('new-package-form').style.display==='none'?'block':'none'; return false;">
|
||||||
|
{{ctx.Locale.Tr "repo.licenses.new_package"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .IsRepoAdmin}}
|
||||||
|
<div id="new-package-form" style="display: none;" class="tw-mb-4">
|
||||||
|
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="two fields">
|
||||||
|
<div class="required field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
|
||||||
|
<input name="name" required placeholder="Pro Annual">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
|
||||||
|
<input name="description" placeholder="Annual pro subscription">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="three fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
|
||||||
|
<input name="duration_days" type="number" value="0" min="0" placeholder="0 = lifetime">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
|
||||||
|
<input name="max_sites" type="number" value="0" min="0" placeholder="0 = unlimited">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
|
||||||
|
<input name="allowed_channels" placeholder='stable,release-candidate'>
|
||||||
|
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
|
||||||
|
</form>
|
||||||
|
<div class="divider"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .LicensePackages}}
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.duration"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||||
|
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LicensePackages}}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||||
|
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
|
||||||
|
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
|
||||||
|
<td>{{.KeyCount}}</td>
|
||||||
|
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||||
|
{{if $.IsRepoAdmin}}
|
||||||
|
<td class="tw-text-right">
|
||||||
|
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="package_id" value="{{.ID}}">
|
||||||
|
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
|
||||||
|
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-placeholder">
|
||||||
|
{{svg "octicon-key" 48}}
|
||||||
|
<h2>{{ctx.Locale.Tr "repo.licenses.none"}}</h2>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ── Issued Keys ── */}}
|
||||||
|
{{if .LicenseKeys}}
|
||||||
|
<h4 class="ui top attached header tw-mt-4">
|
||||||
|
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||||
|
{{if .IsRepoAdmin}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LicenseKeys}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.KeyPrefix}}</code></td>
|
||||||
|
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
|
||||||
|
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
|
||||||
|
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
|
||||||
|
{{if $.IsRepoAdmin}}
|
||||||
|
<td class="tw-text-right">
|
||||||
|
<form method="post" action="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" class="tw-inline">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
|
||||||
|
{{svg "octicon-x" 14}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* ── Update Feed Info ── */}}
|
||||||
|
<h4 class="ui top attached header tw-mt-4">
|
||||||
|
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "repo.licenses.update_feeds"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<div class="field">
|
||||||
|
<label>Joomla updates.xml</label>
|
||||||
|
<div class="ui action input tw-w-full">
|
||||||
|
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
|
||||||
|
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field tw-mt-2">
|
||||||
|
<label>Dolibarr JSON</label>
|
||||||
|
<div class="ui action input tw-w-full">
|
||||||
|
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
|
||||||
|
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
|
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if not .PageIsTagList}}
|
||||||
|
<a class="ui small button" href="{{.RepoLink}}/updates.xml" target="_blank">
|
||||||
|
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.release.update_feed"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
{{if and (not .PageIsTagList) .CanCreateRelease}}
|
{{if and (not .PageIsTagList) .CanCreateRelease}}
|
||||||
<a class="ui small primary button" href="{{$.RepoLink}}/releases/new{{if .PageIsSingleTag}}?tag={{.SingleReleaseTagName}}{{end}}">
|
<a class="ui small primary button" href="{{$.RepoLink}}/releases/new{{if .PageIsSingleTag}}?tag={{.SingleReleaseTagName}}{{end}}">
|
||||||
{{ctx.Locale.Tr "repo.release.new_release"}}
|
{{ctx.Locale.Tr "repo.release.new_release"}}
|
||||||
|
|||||||
@@ -330,6 +330,13 @@
|
|||||||
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
|
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
|
||||||
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
|
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inline field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||||
|
<select name="wiki_visibility" class="ui dropdown">
|
||||||
|
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||||
|
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||||
@@ -389,6 +396,13 @@
|
|||||||
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
|
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
|
||||||
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
|
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inline field tw-mt-2">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||||
|
<select name="issues_visibility" class="ui dropdown">
|
||||||
|
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||||
|
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||||
@@ -487,10 +501,20 @@
|
|||||||
<div class="inline field">
|
<div class="inline field">
|
||||||
<label>{{ctx.Locale.Tr "repo.releases"}}</label>
|
<label>{{ctx.Locale.Tr "repo.releases"}}</label>
|
||||||
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
|
||||||
<input class="enable-system" name="enable_releases" type="checkbox" {{if $isReleasesEnabled}}checked{{end}}>
|
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
|
||||||
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
|
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
|
||||||
|
<div class="inline field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
|
||||||
|
<select name="releases_visibility" class="ui dropdown">
|
||||||
|
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
|
||||||
|
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
|
||||||
|
</select>
|
||||||
|
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
|
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
|
||||||
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
|
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
|
||||||
|
|||||||
+32
-32
@@ -1,23 +1,23 @@
|
|||||||
<?xml version='1.0' encoding='UTF-8'?>
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
VERSION: 05.02.00
|
VERSION: 05.13.00
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<updates>
|
<updates>
|
||||||
<update>
|
<update>
|
||||||
<name>Application - MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<description>Application - MokoGitea dev build.</description>
|
<description>MokoGitea dev build.</description>
|
||||||
<element>mokogitea</element>
|
<element>mokogitea</element>
|
||||||
<type>application</type>
|
<type>application</type>
|
||||||
<client>site</client>
|
<client>site</client>
|
||||||
<version>05.02.00-dev</version>
|
<version>05.05.00-dev</version>
|
||||||
<creationDate>2026-05-30</creationDate>
|
<creationDate>2026-05-30</creationDate>
|
||||||
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/development</infourl>
|
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/development</infourl>
|
||||||
<downloads>
|
<downloads>
|
||||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/development/mokogitea-05.02.00-dev.zip</downloadurl>
|
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/development/mokogitea-05.05.00-dev.zip</downloadurl>
|
||||||
</downloads>
|
</downloads>
|
||||||
<sha256>a3ca6159a3ff878150906852dfa994eabe58f9e62fb5b224dea2a3593013bc8b</sha256>
|
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||||
<tags><tag>dev</tag></tags>
|
<tags><tag>dev</tag></tags>
|
||||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||||
<maintainer>Moko Consulting</maintainer>
|
<maintainer>Moko Consulting</maintainer>
|
||||||
@@ -25,18 +25,18 @@
|
|||||||
<targetplatform name="go" version=".*"/>
|
<targetplatform name="go" version=".*"/>
|
||||||
</update>
|
</update>
|
||||||
<update>
|
<update>
|
||||||
<name>Application - MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<description>Application - MokoGitea alpha build.</description>
|
<description>MokoGitea alpha build.</description>
|
||||||
<element>mokogitea</element>
|
<element>mokogitea</element>
|
||||||
<type>application</type>
|
<type>application</type>
|
||||||
<client>site</client>
|
<client>site</client>
|
||||||
<version>05.02.00-alpha</version>
|
<version>05.05.00-alpha</version>
|
||||||
<creationDate>2026-05-30</creationDate>
|
<creationDate>2026-05-30</creationDate>
|
||||||
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/alpha</infourl>
|
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/alpha</infourl>
|
||||||
<downloads>
|
<downloads>
|
||||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/alpha/mokogitea-05.02.00-alpha.zip</downloadurl>
|
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/alpha/mokogitea-05.05.00-alpha.zip</downloadurl>
|
||||||
</downloads>
|
</downloads>
|
||||||
<sha256>a3ca6159a3ff878150906852dfa994eabe58f9e62fb5b224dea2a3593013bc8b</sha256>
|
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||||
<tags><tag>alpha</tag></tags>
|
<tags><tag>alpha</tag></tags>
|
||||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||||
<maintainer>Moko Consulting</maintainer>
|
<maintainer>Moko Consulting</maintainer>
|
||||||
@@ -44,18 +44,18 @@
|
|||||||
<targetplatform name="go" version=".*"/>
|
<targetplatform name="go" version=".*"/>
|
||||||
</update>
|
</update>
|
||||||
<update>
|
<update>
|
||||||
<name>Application - MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<description>Application - MokoGitea beta build.</description>
|
<description>MokoGitea beta build.</description>
|
||||||
<element>mokogitea</element>
|
<element>mokogitea</element>
|
||||||
<type>application</type>
|
<type>application</type>
|
||||||
<client>site</client>
|
<client>site</client>
|
||||||
<version>05.02.00-beta</version>
|
<version>05.05.00-beta</version>
|
||||||
<creationDate>2026-05-30</creationDate>
|
<creationDate>2026-05-30</creationDate>
|
||||||
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/beta</infourl>
|
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/beta</infourl>
|
||||||
<downloads>
|
<downloads>
|
||||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/beta/mokogitea-05.02.00-beta.zip</downloadurl>
|
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/beta/mokogitea-05.05.00-beta.zip</downloadurl>
|
||||||
</downloads>
|
</downloads>
|
||||||
<sha256>a3ca6159a3ff878150906852dfa994eabe58f9e62fb5b224dea2a3593013bc8b</sha256>
|
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||||
<tags><tag>beta</tag></tags>
|
<tags><tag>beta</tag></tags>
|
||||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||||
<maintainer>Moko Consulting</maintainer>
|
<maintainer>Moko Consulting</maintainer>
|
||||||
@@ -63,18 +63,18 @@
|
|||||||
<targetplatform name="go" version=".*"/>
|
<targetplatform name="go" version=".*"/>
|
||||||
</update>
|
</update>
|
||||||
<update>
|
<update>
|
||||||
<name>Application - MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<description>Application - MokoGitea rc build.</description>
|
<description>MokoGitea rc build.</description>
|
||||||
<element>mokogitea</element>
|
<element>mokogitea</element>
|
||||||
<type>application</type>
|
<type>application</type>
|
||||||
<client>site</client>
|
<client>site</client>
|
||||||
<version>05.02.00-rc</version>
|
<version>05.05.00-rc</version>
|
||||||
<creationDate>2026-05-30</creationDate>
|
<creationDate>2026-05-30</creationDate>
|
||||||
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/release-candidate</infourl>
|
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/release-candidate</infourl>
|
||||||
<downloads>
|
<downloads>
|
||||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/release-candidate/mokogitea-05.02.00-rc.zip</downloadurl>
|
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/release-candidate/mokogitea-05.05.00-rc.zip</downloadurl>
|
||||||
</downloads>
|
</downloads>
|
||||||
<sha256>a3ca6159a3ff878150906852dfa994eabe58f9e62fb5b224dea2a3593013bc8b</sha256>
|
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
|
||||||
<tags><tag>rc</tag></tags>
|
<tags><tag>rc</tag></tags>
|
||||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||||
<maintainer>Moko Consulting</maintainer>
|
<maintainer>Moko Consulting</maintainer>
|
||||||
@@ -82,18 +82,18 @@
|
|||||||
<targetplatform name="go" version=".*"/>
|
<targetplatform name="go" version=".*"/>
|
||||||
</update>
|
</update>
|
||||||
<update>
|
<update>
|
||||||
<name>Application - MokoGitea</name>
|
<name>MokoGitea</name>
|
||||||
<description>Application - MokoGitea stable build.</description>
|
<description>MokoGitea stable build.</description>
|
||||||
<element>mokogitea</element>
|
<element>mokogitea</element>
|
||||||
<type>application</type>
|
<type>application</type>
|
||||||
<client>site</client>
|
<client>site</client>
|
||||||
<version>05.02.00</version>
|
<version>05.13.00</version>
|
||||||
<creationDate>2026-05-30</creationDate>
|
<creationDate>2026-05-31</creationDate>
|
||||||
<infourl title='Application - MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
|
<infourl title='MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
|
||||||
<downloads>
|
<downloads>
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.02.00.zip</downloadurl>
|
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.13.00.zip</downloadurl>
|
||||||
</downloads>
|
</downloads>
|
||||||
<sha256>a3ca6159a3ff878150906852dfa994eabe58f9e62fb5b224dea2a3593013bc8b</sha256>
|
<sha256>b8d32fb99a0fde25ea0860b55ac5f3dfb8a15576863d9ddb80e67c5895722dcd</sha256>
|
||||||
<tags><tag>stable</tag></tags>
|
<tags><tag>stable</tag></tags>
|
||||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||||
<maintainer>Moko Consulting</maintainer>
|
<maintainer>Moko Consulting</maintainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user