Compare commits

..

3 Commits

24 changed files with 5640 additions and 1593 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.08.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
-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.08.00
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+5376
View File
File diff suppressed because it is too large Load Diff
+259 -127
View File
@@ -1,161 +1,293 @@
# Contributing to Moko Consulting Projects
# Contribution Guidelines
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
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.
## Branching Workflow
| 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:
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
// Copyright <current year> The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
```
### Step by step
Afterwards, copyright should only be modified when the copyright author changes.
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
## Testing
2. **Work and commit** on your feature branch. Push to origin.
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
Here's how to run the test suite:
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
- code lint
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`
| | |
| :-------------------- | :--------------------------------------------------------------------------- |
|``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 |
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
- run tests (we suggest running them on Linux)
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`
| 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 | |
### Branch summary
- E2E test environment variables
| 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 |
| 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) |
### Protected branches
## Translation
| 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) |
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.
## Version Policy
The tool `go run build/backport-locale.go` can be used to backport locales from the main branch to release branches that were missed.
### Format
## Code review
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md).
- **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)
### Pull request format
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
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:
### Stability suffixes
- 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.
Each branch appends a suffix to indicate stability:
### PR title and summary
| 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` |
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.
### Auto version bump
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:
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
```text
type(scope)!: subject
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
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.
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
Examples:
## Reporting Issues
```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
```
Use the repository's issue tracker with the appropriate template.
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.
*Moko Consulting <hello@mokoconsulting.tech>*
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.
-159
View File
@@ -1,159 +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)
}
// 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))
}
-74
View File
@@ -1,74 +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(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
}
// 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
}
-1
View File
@@ -412,7 +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),
}
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),
)
}
-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 -1
View File
@@ -81,7 +81,7 @@ 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"),
LandingPage: config.NewOption[LandingPageType]("instance.landing_page").WithFileConfig(config.CfgSecKey{Sec: "server", Key: "LANDING_PAGE"}),
},
}
}
-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"`
}
-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() {
-193
View File
@@ -1,193 +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,
}
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,
}
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)
}
+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
-107
View File
@@ -1,107 +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
}
}
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)
}
-7
View File
@@ -1494,13 +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
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
-111
View File
@@ -1,111 +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"
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,
}
// Track best release per channel.
bestByChannel := make(map[string]*repo_model.Release)
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := channelFromTag(rel.TagName, rel.IsPrerelease)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} {
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 := 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
}
-237
View File
@@ -1,237 +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"
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.
func channelFromTag(tagName string, isPrerelease bool) string {
lower := strings.ToLower(tagName)
switch {
case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"):
return "dev"
case strings.Contains(lower, "-alpha") || strings.Contains(lower, "alpha"):
return "alpha"
case strings.Contains(lower, "-beta") || strings.Contains(lower, "beta"):
return "beta"
case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"):
return "rc"
case isPrerelease:
return "rc"
default:
return "stable"
}
}
// 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)
// 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 := channelFromTag(rel.TagName, rel.IsPrerelease)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
// Build allowed channel set for filtering.
channelAllowed := make(map[string]bool)
if len(allowedChannels) > 0 {
for _, c := range allowedChannels {
channelAllowed[strings.ToLower(c)] = true
}
}
var updates xmlUpdates
for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} {
// 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 := channelSuffix(ch)
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 "dev":
return "-dev"
case "alpha":
return "-alpha"
case "beta":
return "-beta"
case "rc":
return "-rc"
default:
return ""
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?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.08.00
VERSION: 05.05.00
-->
<updates>