Compare commits

..

3 Commits

45 changed files with 5720 additions and 3202 deletions
-251
View File
@@ -1,251 +0,0 @@
# 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
+1 -1
View File
@@ -4,7 +4,7 @@
<name>MokoGitea</name>
<org>MokoConsulting</org>
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
<version>05.13.00</version>
<version>01.00.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
-48
View File
@@ -1,48 +0,0 @@
# 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
-10
View File
@@ -1,10 +0,0 @@
# 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"
-11
View File
@@ -103,17 +103,6 @@ jobs:
$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
$SSH_CMD "
set -e
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 05.13.00
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-236
View File
@@ -1,236 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+5376
View File
File diff suppressed because it is too large Load Diff
+293 -161
View File
@@ -1,161 +1,293 @@
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| 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) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **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`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **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
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
# Contribution Guidelines
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.
| Topic | Document |
| :---- | :------- |
| 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) |
| 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) |
<details><summary>Table of Contents</summary>
- [Contribution Guidelines](#contribution-guidelines)
- [Introduction](#introduction)
- [AI Contribution Policy](#ai-contribution-policy)
- [Issues](#issues)
- [How to report issues](#how-to-report-issues)
- [Types of issues](#types-of-issues)
- [Discuss your design before the implementation](#discuss-your-design-before-the-implementation)
- [Issue locking](#issue-locking)
- [Building Gitea](#building-gitea)
- [Styleguide](#styleguide)
- [Copyright](#copyright)
- [Testing](#testing)
- [Translation](#translation)
- [Code review](#code-review)
- [Pull request format](#pull-request-format)
- [PR title and summary](#pr-title-and-summary)
- [Breaking PRs](#breaking-prs)
- [What is a breaking PR?](#what-is-a-breaking-pr)
- [How to handle breaking PRs?](#how-to-handle-breaking-prs)
- [Maintaining open PRs](#maintaining-open-prs)
- [Reviewing PRs](#reviewing-prs)
- [For PR authors](#for-pr-authors)
- [Documentation](#documentation)
- [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco)
</details>
## Introduction
It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \
Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io).
For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/).
## AI Contribution Policy
Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly and disclose that use clearly.
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.
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.
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.
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.
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.
## Issues
### How to report issues
Please search the issues on the issue tracker with a variety of related keywords to ensure that your issue has not already been reported.
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. \
Please write clear and concise instructions so that we can reproduce the behavior — even if it seems obvious. \
The more detailed and specific you are, the faster we can fix the issue. \
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.
Please be kind—remember that Gitea comes at no cost to you, and you're getting free help.
### Types of issues
Typically, issues fall in one of the following categories:
- `bug`: Something in the frontend or backend behaves unexpectedly
- `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
- `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
- `enhancement`: An existing feature should get an upgrade
- `refactoring`: Parts of the code base don't conform with other parts and should be changed to improve Gitea's maintainability
### Discuss your design before the implementation
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!
Significant changes such as new features must go through the change proposal process before they can be accepted. \
This is mainly to save yourself the trouble of implementing it, only to find out that your proposed implementation has some potential problems. \
Furthermore, this process gives everyone a chance to validate the design, helps prevent duplication of effort, and ensures that the idea fits inside
the goals for the project and tools.
Pull requests should not be the place for architecture discussions.
### Issue locking
Commenting on closed or merged issues/PRs is strongly discouraged.
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.
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.
## Building Gitea
See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea).
## Styleguide
You should always run `make fmt` before committing to conform to Gitea's styleguide.
## Copyright
New code files that you contribute should use the standard copyright header:
```
// Copyright <current year> The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
```
Afterwards, copyright should only be modified when the copyright author changes.
## Testing
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere.
Here's how to run the test suite:
- code lint
| | |
| :-------------------- | :--------------------------------------------------------------------------- |
|``make lint`` | lint everything (not needed if you only change the front- **or** backend) |
|``make lint-frontend`` | lint frontend files |
|``make lint-backend`` | lint backend files |
- run tests (we suggest running them on Linux)
| Command | Action | |
|:----------------------------------------------|:-----------------------------------------------------| ------------------------------------------- |
| ``make test-backend[\#SpecificTestName]`` | run unit test(s) | |
| ``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 | |
- E2E test environment variables
| Variable | Description |
| :-------------------------------- | :---------------------------------------------------------- |
| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output |
| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` |
| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 4 on CI, 1 locally) |
## Translation
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. \
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.
-164
View File
@@ -1,164 +0,0 @@
// 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
}
-49
View File
@@ -1,49 +0,0 @@
// 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))
}
-90
View File
@@ -1,90 +0,0 @@
// 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
}
-89
View File
@@ -1,89 +0,0 @@
// 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
}
-180
View File
@@ -1,180 +0,0 @@
// 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"
}
-2
View File
@@ -412,8 +412,6 @@ func prepareMigrationTasks() []*migration {
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(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
}
-75
View File
@@ -1,75 +0,0 @@
// 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),
)
}
-29
View File
@@ -1,29 +0,0 @@
// 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))
}
-3
View File
@@ -405,11 +405,8 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
perm.units = repo.Units
// anonymous user visit private repo.
// Still process unit-level anonymous access so that units with
// AnonymousAccessMode (e.g. public wiki on a private repo) are visible.
if user == nil && repo.IsPrivate {
perm.AccessMode = perm_model.AccessModeNone
finalProcessRepoUnitPermission(user, &perm)
return perm, nil
}
-8
View File
@@ -673,14 +673,6 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu
cond = userAllPublicRepoCond(cond, orgVisibilityLimit)
}
// Include private repos that have at least one unit with public anonymous access.
// This enables discovery of repos where e.g. wiki or releases are public.
cond = cond.Or(builder.In("`repository`.id",
builder.Select("repo_id").From("repo_unit").Where(
builder.Gt{"anonymous_access_mode": 0},
),
))
if user != nil {
// 2. Be able to see all repositories that we have unit independent access to
// 3. Be able to see all repositories through team membership(s)
-1
View File
@@ -81,7 +81,6 @@ func initDefaultConfig() {
Instance: &InstanceStruct{
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
LandingPage: config.NewOption[LandingPageType]("instance.landing_page"),
},
}
}
-28
View File
@@ -52,35 +52,7 @@ func (m MaintenanceModeType) IsActive() bool {
return true
}
// LandingPageType configures the default page for unauthenticated visitors.
// Mode values: "home", "explore", "organizations", "login", or "custom".
// When Mode is "custom", CustomPath holds the redirect target (e.g. "/MokoConsulting").
type LandingPageType struct {
Mode string // home, explore, organizations, login, custom
CustomPath string // only used when Mode == "custom"
}
// URL returns the redirect path for the configured landing page.
func (lp LandingPageType) URL() string {
switch lp.Mode {
case "explore":
return "/explore"
case "organizations":
return "/explore/organizations"
case "login":
return "/user/login"
case "custom":
if lp.CustomPath != "" {
return lp.CustomPath
}
return "/"
default:
return "/"
}
}
type InstanceStruct struct {
WebBanner *config.Option[WebBannerType]
MaintenanceMode *config.Option[MaintenanceModeType]
LandingPage *config.Option[LandingPageType]
}
-107
View File
@@ -1,107 +0,0 @@
// 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"`
}
-46
View File
@@ -2144,10 +2144,6 @@
"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.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.projects_desc": "Enable Projects",
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
@@ -2612,40 +2608,6 @@
"repo.release.detail": "Release details",
"repo.release.tags": "Tags",
"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.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
@@ -3366,14 +3328,6 @@
"admin.config.common.start_time": "Start time",
"admin.config.common.end_time": "End time",
"admin.config.common.skip_time_check": "Leave time empty (clear the field) to skip time check",
"admin.config.instance_landing_page": "Default Landing Page",
"admin.config.landing_page.home": "Home — default home page",
"admin.config.landing_page.explore": "Explore — repository explore page",
"admin.config.landing_page.organizations": "Organizations — organization explore page",
"admin.config.landing_page.login": "Login — redirect to login page",
"admin.config.landing_page.custom": "Custom path — redirect to a specific URL path",
"admin.config.landing_page.custom_path": "Custom path",
"admin.config.landing_page.custom_path_help": "Internal path to redirect unauthenticated visitors to (e.g. /MokoConsulting or /MokoConsulting/MokoGitea/wiki).",
"admin.config.instance_maintenance": "Instance Maintenance",
"admin.config.instance_maintenance_mode.admin_web_access_only": "Only allow admin to access the web UI",
"admin.config.instance_web_banner.enabled": "Show banner",
-12
View File
@@ -1347,18 +1347,6 @@ func Routes() *web.Router {
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
})
}, reqRepoReader(unit.TypeReleases))
m.Group("/license-packages", func() {
m.Combo("").Get(repo.ListLicensePackages).
Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage)
}, reqToken(), 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("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync)
m.Group("/push_mirrors", func() {
-195
View File
@@ -1,195 +0,0 @@
// 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)
}
+3 -12
View File
@@ -48,18 +48,9 @@ func Home(ctx *context.Context) {
}
return
// Check non-logged users landing page.
} else {
// Dynamic landing page from admin config takes priority.
landingPage := setting.Config().Instance.LandingPage.Value(ctx)
if landingPage.Mode != "" && landingPage.Mode != "home" {
ctx.Redirect(setting.AppSubURL + landingPage.URL())
return
}
// Fall back to static app.ini setting.
if setting.LandingPageURL != setting.LandingPageHome {
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return
}
} else if setting.LandingPageURL != setting.LandingPageHome {
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return
}
// Check auto-login.
-179
View File
@@ -1,179 +0,0 @@
// 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")
}
+1 -9
View File
@@ -128,15 +128,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
}
// Only public pull don't need auth.
// For private repos, also allow anonymous pull if the specific unit
// (code or wiki) has AnonymousAccessMode >= Read.
isPublicPull := repoExist && isPull && !repo.IsPrivate
if repoExist && isPull && repo.IsPrivate {
repoUnit := repo.MustGetUnit(ctx, unitType)
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
isPublicPull = true
}
}
isPublicPull := repoExist && !repo.IsPrivate && isPull
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
// don't allow anonymous pulls if organization is not public
-181
View File
@@ -1,181 +0,0 @@
// 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")
}
+5 -25
View File
@@ -12,7 +12,6 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
@@ -511,17 +510,6 @@ func newRepoUnit(repo *repo_model.Repository, unitType unit_model.Type, config c
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) {
form := web.GetForm(ctx).(*forms.RepoSettingForm)
repo := ctx.Repo.Repository
@@ -539,9 +527,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
}
if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() {
u := newRepoUnit(repo, unit_model.TypeCode, nil)
applyUnitVisibility(&u, form.CodeVisibility)
units = append(units, u)
units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil))
} else if !unit_model.TypeCode.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode)
}
@@ -558,9 +544,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
}))
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
} else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
u := newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig))
applyUnitVisibility(&u, form.WikiVisibility)
units = append(units, u)
units = append(units, newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig)))
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
} else {
if !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
@@ -597,13 +581,11 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
}))
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
} else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() {
u := newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{
units = append(units, newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{
EnableTimetracker: form.EnableTimetracker,
AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
EnableDependencies: form.EnableIssueDependencies,
})
applyUnitVisibility(&u, form.IssuesVisibility)
units = append(units, u)
}))
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
} else {
if !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
@@ -623,9 +605,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
}
if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() {
u := newRepoUnit(repo, unit_model.TypeReleases, nil)
applyUnitVisibility(&u, form.ReleasesVisibility)
units = append(units, u)
units = append(units, newRepoUnit(repo, unit_model.TypeReleases, nil))
} else if !unit_model.TypeReleases.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases)
}
-111
View File
@@ -1,111 +0,0 @@
// 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)
}
-23
View File
@@ -1099,13 +1099,6 @@ 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
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("/heatmap", user.DashboardHeatmap)
@@ -1501,22 +1494,6 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
// 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.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
-9
View File
@@ -18,7 +18,6 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
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"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
@@ -606,14 +605,6 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
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["PageTitleCommon"] = repo.Name + " - " + setting.AppName
ctx.Data["Repository"] = repo
+7 -11
View File
@@ -110,14 +110,12 @@ type RepoSettingForm struct {
EnablePrune bool
// Advanced settings
EnableCode bool
CodeVisibility string
EnableCode bool
EnableWiki bool
EnableExternalWiki bool
DefaultWikiBranch string
ExternalWikiURL string
WikiVisibility string
EnableWiki bool
EnableExternalWiki bool
DefaultWikiBranch string
ExternalWikiURL string
EnableIssues bool
EnableExternalTracker bool
@@ -126,15 +124,13 @@ type RepoSettingForm struct {
TrackerIssueStyle string
ExternalTrackerRegexpPattern string
EnableCloseIssuesViaCommitInAnyBranch bool
IssuesVisibility string
EnableProjects bool
ProjectsMode string
EnableReleases bool
ReleasesVisibility string
EnableReleases bool
EnablePackages bool
EnablePackages bool
EnablePulls bool
PullsIgnoreWhitespace bool
-119
View File
@@ -1,119 +0,0 @@
// 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
}
-278
View File
@@ -1,278 +0,0 @@
// 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 ""
}
}
@@ -2,7 +2,6 @@
{{template "admin/config_settings/avatars" .}}
{{template "admin/config_settings/repository" .}}
{{template "admin/config_settings/landing_page" .}}
{{template "admin/config_settings/instance" .}}
{{template "admin/layout_footer" .}}
@@ -1,49 +0,0 @@
<h4 class="ui top attached header">{{ctx.Locale.Tr "admin.config.instance_landing_page"}}</h4>
<div class="ui attached segment">
<form class="ui form ignore-dirty system-config-form" method="post" action="{{AppSubUrl}}/-/admin/config">
{{$cfgOpt := $.SystemConfig.Instance.LandingPage}}
{{$cfgKey := $cfgOpt.DynKey}}
{{$landingPage := $cfgOpt.Value ctx}}
<input type="hidden" data-config-dyn-key="{{$cfgKey}}" data-config-value-json="{{JsonUtils.EncodeToString $landingPage}}">
<div class="grouped fields">
<div class="field">
<div class="ui radio checkbox">
<input name="{{$cfgKey}}.Mode" type="radio" value="home" {{if or (eq $landingPage.Mode "") (eq $landingPage.Mode "home")}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.landing_page.home"}}</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input name="{{$cfgKey}}.Mode" type="radio" value="explore" {{if eq $landingPage.Mode "explore"}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.landing_page.explore"}}</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input name="{{$cfgKey}}.Mode" type="radio" value="organizations" {{if eq $landingPage.Mode "organizations"}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.landing_page.organizations"}}</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input name="{{$cfgKey}}.Mode" type="radio" value="login" {{if eq $landingPage.Mode "login"}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.landing_page.login"}}</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input name="{{$cfgKey}}.Mode" type="radio" value="custom" {{if eq $landingPage.Mode "custom"}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.landing_page.custom"}}</label>
</div>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.landing_page.custom_path"}}</label>
<input type="text" name="{{$cfgKey}}.CustomPath" value="{{$landingPage.CustomPath}}" placeholder="/MokoConsulting">
<div class="help">{{ctx.Locale.Tr "admin.config.landing_page.custom_path_help"}}</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
-149
View File
@@ -1,149 +0,0 @@
{{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" .}}
-5
View File
@@ -25,11 +25,6 @@
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{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}}
<a class="{{if .IsCodePage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/code">
{{svg "octicon-code"}} {{ctx.Locale.Tr "org.code"}}
-9
View File
@@ -128,15 +128,6 @@
</a>
{{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}}
{{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">
-173
View File
@@ -1,173 +0,0 @@
{{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" .}}
-5
View File
@@ -16,11 +16,6 @@
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
</a>
{{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}}
<a class="ui small primary button" href="{{$.RepoLink}}/releases/new{{if .PageIsSingleTag}}?tag={{.SingleReleaseTagName}}{{end}}">
{{ctx.Locale.Tr "repo.release.new_release"}}
+1 -25
View File
@@ -330,13 +330,6 @@
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
</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 class="field">
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
@@ -396,13 +389,6 @@
<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>
</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 class="field">
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
@@ -501,20 +487,10 @@
<div class="inline field">
<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}}>
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
<input class="enable-system" name="enable_releases" type="checkbox" {{if $isReleasesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
</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}}
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
+32 -32
View File
@@ -1,23 +1,23 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 05.13.00
VERSION: 05.04.00
-->
<updates>
<update>
<name>MokoGitea</name>
<description>MokoGitea dev build.</description>
<name>Application - MokoGitea</name>
<description>Application - MokoGitea dev build.</description>
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.05.00-dev</version>
<version>05.04.00-dev</version>
<creationDate>2026-05-30</creationDate>
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/development</infourl>
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/development</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/development/mokogitea-05.05.00-dev.zip</downloadurl>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/development/mokogitea-05.04.00-dev.zip</downloadurl>
</downloads>
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
<sha256>4f2321bc5bab6fd2ef234b53ed5d511437d40a6ee8fb21bf097a4847c16eaf44</sha256>
<tags><tag>dev</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
@@ -25,18 +25,18 @@
<targetplatform name="go" version=".*"/>
</update>
<update>
<name>MokoGitea</name>
<description>MokoGitea alpha build.</description>
<name>Application - MokoGitea</name>
<description>Application - MokoGitea alpha build.</description>
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.05.00-alpha</version>
<version>05.04.00-alpha</version>
<creationDate>2026-05-30</creationDate>
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/alpha</infourl>
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/alpha</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/alpha/mokogitea-05.05.00-alpha.zip</downloadurl>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/alpha/mokogitea-05.04.00-alpha.zip</downloadurl>
</downloads>
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
<sha256>4f2321bc5bab6fd2ef234b53ed5d511437d40a6ee8fb21bf097a4847c16eaf44</sha256>
<tags><tag>alpha</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
@@ -44,18 +44,18 @@
<targetplatform name="go" version=".*"/>
</update>
<update>
<name>MokoGitea</name>
<description>MokoGitea beta build.</description>
<name>Application - MokoGitea</name>
<description>Application - MokoGitea beta build.</description>
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.05.00-beta</version>
<version>05.04.00-beta</version>
<creationDate>2026-05-30</creationDate>
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/beta</infourl>
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/beta</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/beta/mokogitea-05.05.00-beta.zip</downloadurl>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/beta/mokogitea-05.04.00-beta.zip</downloadurl>
</downloads>
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
<sha256>4f2321bc5bab6fd2ef234b53ed5d511437d40a6ee8fb21bf097a4847c16eaf44</sha256>
<tags><tag>beta</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
@@ -63,18 +63,18 @@
<targetplatform name="go" version=".*"/>
</update>
<update>
<name>MokoGitea</name>
<description>MokoGitea rc build.</description>
<name>Application - MokoGitea</name>
<description>Application - MokoGitea rc build.</description>
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.05.00-rc</version>
<version>05.04.00-rc</version>
<creationDate>2026-05-30</creationDate>
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/release-candidate</infourl>
<infourl title="Application - MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/release-candidate</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/release-candidate/mokogitea-05.05.00-rc.zip</downloadurl>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/release-candidate/mokogitea-05.04.00-rc.zip</downloadurl>
</downloads>
<sha256>4fee9eb03e4b819a63bce2ceb54fdce0d3eb8bf5b31460fcc42e5ecd75cc856e</sha256>
<sha256>4f2321bc5bab6fd2ef234b53ed5d511437d40a6ee8fb21bf097a4847c16eaf44</sha256>
<tags><tag>rc</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
@@ -82,18 +82,18 @@
<targetplatform name="go" version=".*"/>
</update>
<update>
<name>MokoGitea</name>
<description>MokoGitea stable build.</description>
<name>Application - MokoGitea</name>
<description>Application - MokoGitea stable build.</description>
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.13.00</version>
<creationDate>2026-05-31</creationDate>
<infourl title='MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<version>05.04.00</version>
<creationDate>2026-05-30</creationDate>
<infourl title='Application - MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.13.00.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.04.00.zip</downloadurl>
</downloads>
<sha256>b8d32fb99a0fde25ea0860b55ac5f3dfb8a15576863d9ddb80e67c5895722dcd</sha256>
<sha256>4f2321bc5bab6fd2ef234b53ed5d511437d40a6ee8fb21bf097a4847c16eaf44</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>