Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87f92fe1ab | |||
| 66aea89b40 | |||
| 7c75133ef1 | |||
| 37a62b5ab7 | |||
| 35ebbef489 | |||
| 193c705c05 | |||
| fa845164bb | |||
| fefdf1a1ec | |||
| 23bb025700 | |||
| 7913a05285 | |||
| 98301bc92b | |||
| c618ec9f87 | |||
| 5a25068d81 | |||
| 57894e25fd | |||
| 2857a1f6a1 | |||
| b9c04e51b4 | |||
| 0b44e92369 | |||
| cf25eef480 | |||
| 5da6a40f10 | |||
| 4e5aa5f3ce | |||
| 9a4aa0fafb | |||
| e947600ea7 | |||
| b0bbaab621 | |||
| 84df5d7932 | |||
| 7b334f94c0 | |||
| 805c566615 | |||
| f53bc895ba | |||
| 2ff0e4aa21 | |||
| c0f89a373d | |||
| 0dc858c15c | |||
| 94590bc834 | |||
| cbf34fb987 | |||
| 26ad4fd03f | |||
| bc578b7eba | |||
| 186ac68f03 | |||
| fedce235d5 | |||
| d3c6998d3e | |||
| e9648f367e | |||
| 725acbe112 | |||
| 884e568ea2 | |||
| e99658ddc0 | |||
| f627219ca8 | |||
| df9305758f | |||
| ebdf59d64f | |||
| 8ffd2ffe18 | |||
| d2a3827202 | |||
| 3cb45ba457 | |||
| 92cf6a8521 | |||
| 81d20e25bf | |||
| 2d0b746b5f | |||
| 035ecb94f8 | |||
| c47013edb0 | |||
| 4178e7f23e | |||
| 46cbf6600a | |||
| afe46361c7 | |||
| e66c75753d | |||
| d6e56460a4 | |||
| 322bd982bd | |||
| a48f44c901 | |||
| 882eb2cce7 | |||
| f962ae575a | |||
| 58074ac860 | |||
| 9db67cd554 | |||
| a063c3b2e4 | |||
| ac48c1d958 | |||
| ad06fa7bec | |||
| ed068f2964 | |||
| 03c8755fe1 | |||
| bd5c435f27 | |||
| a25a673d0c | |||
| 02ff660912 | |||
| bc6bd9bf45 | |||
| 03547194a4 | |||
| 6131739e39 | |||
| 86e1db1dc8 | |||
| 61e8b9deef | |||
| 9d3803a0b5 | |||
| 53fab919cf | |||
| a47c4c7f67 | |||
| 1d29460244 | |||
| 24cd2986ab | |||
| bf8b747ef9 | |||
| 467ea55ee8 | |||
| 1ac0449cde | |||
| d4221bcc9e | |||
| 6ef4a61eab | |||
| ba1427a19f | |||
| 90d9fa9482 | |||
| 699d8540d4 | |||
| 456235faaf | |||
| 5c2321b164 | |||
| 8798ccb478 | |||
| 5c1b4e6509 | |||
| 2a5a2dd845 | |||
| 2708388542 | |||
| f7c2b205c5 | |||
| d2d7c0a762 | |||
| 3eb0dfd011 | |||
| 49f6380fa4 | |||
| b545e7414c | |||
| 024b00a38e | |||
| 8f9a43f32c | |||
| 225ea65881 | |||
| 4c018fac62 | |||
| 41e48945fd | |||
| 7d28fe522d | |||
| 8780ec7c5f | |||
| 9106cfe254 | |||
| f7d70ae95a | |||
| 5c43cf1f02 | |||
| c8c74c7afe | |||
| 7b68963b67 | |||
| 22c2a99f8b | |||
| c974970118 | |||
| c9a8deee0e | |||
| ea05851d0a | |||
| 1178975be3 | |||
| b283fad8bd | |||
| a792772397 | |||
| 4d1be56bad | |||
| 2dc745c5fa | |||
| 9dda78da7c | |||
| 6ceef765eb | |||
| 23d3528676 | |||
| 249b639c70 | |||
| 5c9db551dc | |||
| 408f2329b3 | |||
| 827025bd17 | |||
| 98da1644be | |||
| db596575a0 | |||
| 3c56dc8814 | |||
| dce712fabd | |||
| 78b0ce9650 | |||
| 500a5be6d7 | |||
| 95a747b1d5 | |||
| bb7e99ad40 | |||
| 6c6b7c888e | |||
| 2a1692d599 | |||
| 6984ac108f | |||
| 3fdbe94830 | |||
| e937dd8d8b | |||
| e7b70f54ed | |||
| b161561571 | |||
| b981cf72e3 | |||
| 9964c7e16c | |||
| ff27e77c37 | |||
| 04ce7dc896 | |||
| f87f904a21 | |||
| fc72d8e90a | |||
| 71d52e432e | |||
| 172303b61f | |||
| bfb4b53da3 | |||
| 9149fa100c | |||
| 6a2c80a8f3 | |||
| 28ee70a946 | |||
| 6d194f9bdf | |||
| 403db405cb | |||
| 39e4eb6ec8 | |||
| 79cc30e9a8 | |||
| 78ad2c999b | |||
| e3949077b0 | |||
| e469b4a857 | |||
| acae63f727 | |||
| e71ab8415f | |||
| 03ce66a4f4 | |||
| deafaeca65 | |||
| 5e74c22609 | |||
| 03f881c746 | |||
| 3a405033ae | |||
| 034795951f | |||
| 1d1b867df5 | |||
| 63b599f62c | |||
| 5bd449017c | |||
| fe3de3fbff | |||
| 3e909df6d4 | |||
| 30bb5e33e2 |
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Determine target repos
|
- name: Determine target repos
|
||||||
id: repos
|
id: repos
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API="${GITEA_URL}/api/v1"
|
API="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
REPOS=""
|
REPOS=""
|
||||||
while true; do
|
while true; do
|
||||||
BATCH=$(curl -sS \
|
BATCH=$(curl -sS \
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||||
| jq -r '.[].name // empty')
|
| jq -r '.[].name // empty')
|
||||||
[ -z "$BATCH" ] && break
|
[ -z "$BATCH" ] && break
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Apply protection rules
|
- name: Apply protection rules
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||||
run: |
|
run: |
|
||||||
API="${GITEA_URL}/api/v1"
|
API="${GITEA_URL}/api/v1"
|
||||||
@@ -214,13 +214,13 @@ jobs:
|
|||||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||||
curl -sS -o /dev/null -w "" \
|
curl -sS -o /dev/null -w "" \
|
||||||
-X DELETE \
|
-X DELETE \
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||||
|
|
||||||
# Create rule
|
# Create rule
|
||||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
-H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$RULE" \
|
-d "$RULE" \
|
||||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||||
# VERSION: 09.02.00
|
# VERSION: 09.02.00
|
||||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||||
|
|
||||||
name: "Universal: Auto Version Bump"
|
name: "Universal: Auto Version Bump"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- rc
|
- rc
|
||||||
- 'feature/**'
|
- 'feature/**'
|
||||||
- 'patch/**'
|
- 'patch/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump:
|
bump:
|
||||||
name: Version Bump
|
name: Version Bump
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
run: |
|
run: |
|
||||||
if ! command -v composer &> /dev/null; then
|
if ! command -v composer &> /dev/null; then
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
if [ -d "/opt/mokocli/cli" ]; then
|
if [ -d "/opt/mokocli/cli" ]; then
|
||||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||||
/tmp/mokocli
|
/tmp/mokocli
|
||||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
run: |
|
run: |
|
||||||
php ${MOKO_CLI}/version_auto_bump.php \
|
php ${MOKO_CLI}/version_auto_bump.php \
|
||||||
--path . --branch "${GITHUB_REF_NAME}" \
|
--path . --branch "${GITHUB_REF_NAME}" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
@@ -21,15 +21,24 @@
|
|||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, closed]
|
types: [opened, synchronize, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.mokogitea/workflows/**'
|
||||||
|
- '*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.gitattributes'
|
||||||
|
- '.gitmessage'
|
||||||
|
- 'LICENSE'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -43,7 +52,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
@@ -51,12 +60,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||||
promote-rc:
|
promote-rc:
|
||||||
name: Promote to RC
|
name: Promote to RC
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -92,7 +102,7 @@ jobs:
|
|||||||
php ${MOKO_CLI}/branch_rename.php \
|
php ${MOKO_CLI}/branch_rename.php \
|
||||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
--pr "${{ github.event.pull_request.number }}"
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
|
|
||||||
- name: Checkout rc and configure git
|
- name: Checkout rc and configure git
|
||||||
@@ -111,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update RC release notes from CHANGELOG.md
|
- name: Update RC release notes from CHANGELOG.md
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Extract [Unreleased] section from changelog
|
# Extract [Unreleased] section from changelog
|
||||||
@@ -149,7 +159,7 @@ jobs:
|
|||||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||||
release:
|
release:
|
||||||
name: Build & Release Pipeline
|
name: Build & Release Pipeline
|
||||||
runs-on: release
|
runs-on: release
|
||||||
@@ -205,6 +215,12 @@ jobs:
|
|||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: "Detect platform"
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||||
|
|
||||||
- name: "Determine version bump level"
|
- name: "Determine version bump level"
|
||||||
id: bump
|
id: bump
|
||||||
run: |
|
run: |
|
||||||
@@ -228,9 +244,57 @@ jobs:
|
|||||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
- name: "Read published version"
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||||
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
|
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]]; then
|
||||||
|
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Published version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: "Create semver tag for non-Joomla repos"
|
||||||
|
id: semver
|
||||||
|
if: |
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
SEMVER_TAG="v${VERSION}"
|
||||||
|
|
||||||
|
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||||
|
|
||||||
|
# Create the git tag via API
|
||||||
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/tags" \
|
||||||
|
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Created semver tag: ${SEMVER_TAG}"
|
||||||
|
elif [ "$HTTP_CODE" = "409" ]; then
|
||||||
|
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Update release notes and promote changelog
|
- name: Update release notes and promote changelog
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Get the stable release info (version and ID)
|
# Get the stable release info (version and ID)
|
||||||
@@ -299,7 +363,7 @@ jobs:
|
|||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/release_mirror.php \
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
@@ -328,7 +392,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Delete rc branch (ephemeral — created by promote-rc)
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
@@ -352,7 +416,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
BRANCH_NAME="version/${VERSION}"
|
BRANCH_NAME="version/${VERSION}"
|
||||||
@@ -373,7 +437,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/version_reset_dev.php \
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
--branch dev --path . 2>&1 || true
|
--branch dev --path . 2>&1 || true
|
||||||
@@ -399,5 +463,5 @@ jobs:
|
|||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
name: "Generic: Project CI"
|
name: "Generic: Project CI"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev/**
|
||||||
|
- rc/**
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||||
|
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||||
|
|
||||||
|
name: "Universal: CI Issue Reporter"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
gate:
|
||||||
|
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
description: "Human-readable failure description"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
description: "error or warning"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "error"
|
||||||
|
workflow:
|
||||||
|
description: "Workflow name for the issue title"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
secrets:
|
||||||
|
MOKOGITEA_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
report:
|
||||||
|
name: "Report: ${{ inputs.gate }}"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone MokoCLI
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
|
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||||
|
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||||
|
|
||||||
|
- name: Report CI failure
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
run: |
|
||||||
|
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||||
|
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||||
|
--gate "${{ inputs.gate }}" \
|
||||||
|
--details "${{ inputs.details }}" \
|
||||||
|
--severity "${{ inputs.severity }}" \
|
||||||
|
--workflow "${{ inputs.workflow }}"
|
||||||
@@ -21,7 +21,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# List branches via API
|
||||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
echo " Deleting merged branch: ${BRANCH}"
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
echo "=== Workflow Run Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
# Get old completed runs
|
# Get old completed runs
|
||||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/actions/runs?status=completed&limit=50" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
for RUN_ID in $RUNS; do
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||||
|
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||||
|
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||||
|
name: "Cascade Main → Dev (DISABLED)"
|
||||||
|
on: workflow_dispatch
|
||||||
|
jobs:
|
||||||
|
noop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# BRIEF: Build and deploy MokoGitea to dev environment on push to dev branch.
|
||||||
|
# Production deploy (deploy-mokogitea.yml) only succeeds if dev is healthy.
|
||||||
|
|
||||||
|
name: Deploy MokoGitea (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-mokogitea-dev
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mokoconsulting.tech
|
||||||
|
IMAGE: mokoconsulting/mokogitea
|
||||||
|
DEPLOY_HOST: git.mokoconsulting.tech
|
||||||
|
DEPLOY_PORT: 2918
|
||||||
|
DEPLOY_USER: mokoconsulting
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-dev:
|
||||||
|
name: "Build & Deploy to Dev"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: config
|
||||||
|
run: |
|
||||||
|
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev-$(git rev-parse --short HEAD)")
|
||||||
|
echo "tag=${VERSION}-dev" >> $GITHUB_OUTPUT
|
||||||
|
echo "Version: ${VERSION}-dev"
|
||||||
|
|
||||||
|
- name: Write deploy key
|
||||||
|
env:
|
||||||
|
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
|
||||||
|
- name: Build and deploy to dev via SSH
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
TAG: ${{ steps.config.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
|
||||||
|
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
|
||||||
|
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
|
||||||
|
set -e
|
||||||
|
echo 'SSH connected to dev environment'
|
||||||
|
|
||||||
|
echo 'Cleaning Docker build cache...'
|
||||||
|
docker builder prune -af 2>/dev/null || true
|
||||||
|
docker image prune -af 2>/dev/null || true
|
||||||
|
|
||||||
|
echo 'Pulling source...'
|
||||||
|
SOURCE_DIR=/opt/gitea-dev/source
|
||||||
|
if [ ! -d \$SOURCE_DIR/.git ]; then
|
||||||
|
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
|
||||||
|
fi
|
||||||
|
cd \$SOURCE_DIR
|
||||||
|
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
|
||||||
|
git fetch origin dev
|
||||||
|
git reset --hard origin/dev
|
||||||
|
|
||||||
|
echo 'Building Docker image...'
|
||||||
|
docker build --no-cache --build-arg GOFLAGS='-p 1' \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
|
||||||
|
-f Dockerfile .
|
||||||
|
|
||||||
|
echo 'Pushing to registry...'
|
||||||
|
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
|
||||||
|
|
||||||
|
echo 'Restarting dev container...'
|
||||||
|
cd /opt/gitea-dev
|
||||||
|
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
|
||||||
|
docker compose up -d mokogitea-dev
|
||||||
|
|
||||||
|
echo 'Health check...'
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
sleep 15
|
||||||
|
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
|
||||||
|
echo 'Dev container healthy!'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting... (attempt \$i/8)"
|
||||||
|
done
|
||||||
|
echo 'Health check failed'
|
||||||
|
docker logs mokogitea-dev --tail 20
|
||||||
|
exit 1
|
||||||
|
DEPLOY_EOF
|
||||||
|
|
||||||
|
- name: Verify dev instance
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
curl -sf https://git.dev.mokoconsulting.tech/api/healthz && echo " Dev API healthy"
|
||||||
+11
@@ -39,6 +39,17 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Verify dev environment is healthy
|
||||||
|
run: |
|
||||||
|
echo "Checking git.dev.mokoconsulting.tech health..."
|
||||||
|
if curl -sf --max-time 10 https://git.dev.mokoconsulting.tech/api/healthz; then
|
||||||
|
echo " Dev environment is healthy — proceeding with production deploy"
|
||||||
|
else
|
||||||
|
echo "::error::Dev environment is NOT healthy — blocking production deploy"
|
||||||
|
echo "Deploy to dev first (push to dev branch) and verify it passes before merging to main."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checkout source (for version detection)
|
- name: Checkout source (for version detection)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup MokoStandards tools
|
- name: Setup MokoStandards tools
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 06.20.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
@@ -19,7 +19,7 @@ permissions:
|
|||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-branch:
|
create-branch:
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create branch and comment
|
- name: Create branch and comment
|
||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
echo "Created branch: ${BRANCH}"
|
echo "Created branch: ${BRANCH}"
|
||||||
|
|
||||||
# Comment on issue with branch link
|
# Comment on issue with branch link
|
||||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
|
||||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||||
|
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
name: Publish MCP to npm
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- '.mokogitea/mcp/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Install and build
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npx tsc
|
|
||||||
|
|
||||||
- name: Check version change
|
|
||||||
id: version
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
|
||||||
NPM_VERSION=$(npm view @mokoconsulting/mokogitea-mcp version 2>/dev/null || echo "0.0.0")
|
|
||||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
|
||||||
else
|
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version unchanged: $LOCAL_VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
if: steps.version.outputs.changed == 'true'
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: npm publish --access public
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
|
||||||
if: steps.version.outputs.changed == 'true'
|
|
||||||
working-directory: .mokogitea/mcp
|
|
||||||
run: |
|
|
||||||
npm publish --registry ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ \
|
|
||||||
--//$(echo "${{ github.server_url }}" | sed 's|https://||')/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.MOKOGITEA_TOKEN }}
|
|
||||||
+531
-489
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
# INGROUP: mokocli.Release
|
# INGROUP: mokocli.Release
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||||
# VERSION: 05.01.00
|
# VERSION: 05.02.00
|
||||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
name: "Universal: Pre-Release"
|
||||||
@@ -49,10 +49,8 @@ jobs:
|
|||||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'push') &&
|
github.event_name == 'push'
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip bump]')
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -61,6 +59,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Update submodules to main
|
||||||
|
run: |
|
||||||
|
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
|
||||||
|
|
||||||
- name: Setup mokocli tools
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
@@ -90,8 +93,20 @@ jobs:
|
|||||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Check platform eligibility (Joomla only)
|
||||||
|
id: eligibility
|
||||||
|
run: |
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Resolve metadata and bump version
|
- name: Resolve metadata and bump version
|
||||||
id: meta
|
id: meta
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
@@ -168,6 +183,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: release
|
id: release
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -178,6 +194,7 @@ jobs:
|
|||||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
- name: Update release notes from CHANGELOG.md
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
@@ -214,6 +231,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package and upload
|
- name: Build package and upload
|
||||||
id: package
|
id: package
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
@@ -227,6 +245,7 @@ jobs:
|
|||||||
# No need to build, commit, or sync updates.xml from workflows
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoPlatform.Universal
|
# INGROUP: mokocli.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
@@ -29,12 +29,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Rename branch
|
- name: Rename branch
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
set -euo pipefail
|
||||||
|
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
|
||||||
|
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
|
||||||
|
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
|
||||||
|
fi
|
||||||
SUFFIX="${BRANCH#rc/}"
|
SUFFIX="${BRANCH#rc/}"
|
||||||
DEV_BRANCH="dev/${SUFFIX}"
|
DEV_BRANCH="dev/${SUFFIX}"
|
||||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
|
||||||
|
|
||||||
# Create dev/ branch from rc/ branch
|
# Create dev/ branch from rc/ branch
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||||
@@ -42,25 +50,22 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||||
"${API}" 2>/dev/null || true)
|
"${API}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "201" ]; then
|
if [ "$STATUS" = "201" ]; then
|
||||||
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete rc/ branch
|
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
|
||||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
|
||||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "204" ]; then
|
if [ "$STATUS" = "204" ]; then
|
||||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
else
|
else
|
||||||
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +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.Security
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
|
||||||
# VERSION: 01.00.00
|
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
|
||||||
|
|
||||||
name: "Universal: Security Audit"
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'composer.json'
|
|
||||||
- 'composer.lock'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
|
||||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
name: Dependency Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Composer audit
|
|
||||||
if: hashFiles('composer.lock') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== Composer Security Audit ==="
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
|
||||||
RESULT=$?
|
|
||||||
if [ $RESULT -ne 0 ]; then
|
|
||||||
echo "::warning::Composer vulnerabilities found"
|
|
||||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "No known vulnerabilities in composer dependencies"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: NPM audit
|
|
||||||
if: hashFiles('package-lock.json') != ''
|
|
||||||
run: |
|
|
||||||
echo "=== NPM Security Audit ==="
|
|
||||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
|
||||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
|
||||||
echo "No known vulnerabilities in npm dependencies"
|
|
||||||
else
|
|
||||||
echo "::warning::NPM vulnerabilities found"
|
|
||||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on vulnerabilities
|
|
||||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
|
||||||
run: |
|
|
||||||
REPO="${{ github.event.repository.name }}"
|
|
||||||
curl -sS \
|
|
||||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
|
||||||
-H "Tags: lock,warning" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow.Template
|
||||||
|
# INGROUP: MokoStandards.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||||
|
# PATH: /.mokogitea/workflows/version-set.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||||
|
|
||||||
|
name: "Joomla: Set Version"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version number (e.g. 01.00.00)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
branch:
|
||||||
|
description: "Branch to update (default: current)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
set-version:
|
||||||
|
name: Set Version to ${{ inputs.version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate version format
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||||
|
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
|
ref: ${{ inputs.branch || github.ref }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Update manifest version
|
||||||
|
run: |
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||||
|
else
|
||||||
|
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||||
|
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||||
|
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update README.md version
|
||||||
|
run: |
|
||||||
|
if [ -f "README.md" ]; then
|
||||||
|
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||||
|
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||||
|
echo "README.md version updated to ${VERSION}"
|
||||||
|
else
|
||||||
|
echo "::warning::No VERSION line found in README.md — skipping"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
# Check if this version already has an entry
|
||||||
|
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||||
|
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||||
|
else
|
||||||
|
# Insert new version entry after [Unreleased] or at the top after header
|
||||||
|
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||||
|
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||||
|
else
|
||||||
|
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||||
|
fi
|
||||||
|
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "::warning::No CHANGELOG.md found — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update FILE INFORMATION blocks
|
||||||
|
run: |
|
||||||
|
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||||
|
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||||
|
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||||
|
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||||
|
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
git config user.name "Moko Consulting [bot]"
|
||||||
|
git config user.email "hello@mokoconsulting.tech"
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No version changes detected — nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||||
|
|
||||||
|
Authored-by: Moko Consulting"
|
||||||
|
git push
|
||||||
|
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||||
|
# VERSION: 01.01.00
|
||||||
|
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||||
|
|
||||||
|
name: "Universal: Workflow Sync Trigger"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
name: Sync workflows to live repos
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.pull_request.merged == true &&
|
||||||
|
!contains(github.event.pull_request.title, '[skip sync]'))
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Determine platform from repo name
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
REPO="${{ github.event.repository.name }}"
|
||||||
|
case "$REPO" in
|
||||||
|
Template-Joomla) PLATFORM="joomla" ;;
|
||||||
|
Template-Dolibarr) PLATFORM="dolibarr" ;;
|
||||||
|
Template-Go) PLATFORM="go" ;;
|
||||||
|
Template-MCP) PLATFORM="mcp" ;;
|
||||||
|
Template-Generic) PLATFORM="" ;;
|
||||||
|
*) PLATFORM="" ;;
|
||||||
|
esac
|
||||||
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Platform: ${PLATFORM:-all}"
|
||||||
|
|
||||||
|
- name: Clone mokocli
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
|
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||||
|
|
||||||
|
- name: Install PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd /tmp/mokocli
|
||||||
|
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run workflow sync
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
ARGS="--token ${MOKOGITEA_TOKEN}"
|
||||||
|
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
|
||||||
|
ARGS="${ARGS} --phase repos"
|
||||||
|
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [ -n "$PLATFORM" ]; then
|
||||||
|
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||||
@@ -3,6 +3,28 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552)
|
||||||
|
- Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460)
|
||||||
|
- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507)
|
||||||
|
- Cross-org status migration: copy status definitions from one org to another via API (#507)
|
||||||
|
- Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513)
|
||||||
|
- Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696)
|
||||||
|
- Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693)
|
||||||
|
- API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697)
|
||||||
|
- Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697)
|
||||||
|
- Wiki full-text search: case-insensitive search across all wiki page titles and content (#550)
|
||||||
|
- Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550)
|
||||||
|
- Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692)
|
||||||
|
- Security scanning API: REST endpoints for alerts, config, and on-demand scans (GET/PATCH /security/alerts, /security/config, POST /security/scan) (#692)
|
||||||
|
- Pre-receive hook secret blocking: push rejection when block_on_push enabled and secrets detected in commits (#692)
|
||||||
|
- Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all
|
||||||
|
- Wiki revision diff: line-by-line diff view per commit in wiki page history (#667)
|
||||||
|
- Wiki categories: YAML frontmatter `categories:` with category index page (#668)
|
||||||
|
- Wiki template transclusion: `{{template:Name|key=val}}` with `_Template/` folder (#671)
|
||||||
|
- Wiki enhanced ToC: collapsible, inline via frontmatter, sticky sidebar (#673)
|
||||||
|
- Wiki folder ACL: `_access.yml` per-folder write protection (#674)
|
||||||
|
- Wiki print view and ZIP export of all wiki pages (#675)
|
||||||
|
- Wiki features documentation page in org wiki (standards/Wiki-Features)
|
||||||
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
|
||||||
- License CRUD with CRC32-checksummed DLID generation and format validation
|
- License CRUD with CRC32-checksummed DLID generation and format validation
|
||||||
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
- Entitlement model with tier-based rebuild and custom entitlement preservation
|
||||||
@@ -10,6 +32,61 @@
|
|||||||
- 13 seeded product tiers from base to enterprise
|
- 13 seeded product tiers from base to enterprise
|
||||||
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
|
||||||
- Profile repo fallback chain: .mokogitea > .profile > .github
|
- Profile repo fallback chain: .mokogitea > .profile > .github
|
||||||
|
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
|
||||||
|
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
|
||||||
|
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
|
||||||
|
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
|
||||||
|
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
|
||||||
|
- Issue status API response includes is_required field
|
||||||
|
- Wiki recent changes page: cross-page edit activity with pagination (#670)
|
||||||
|
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Cherry-pick upstream v1.26.3: LFS reject unknown SSH sub-verbs to prevent auth bypass (#38015)
|
||||||
|
- Cherry-pick upstream v1.26.3: bound CODEOWNERS regex match time — ReDoS prevention (#38025)
|
||||||
|
- Cherry-pick upstream v1.26.3: require merged PR to bypass fork PR approval gate (#38041)
|
||||||
|
- Cherry-pick upstream v1.26.3: LFS require Code-unit access for cross-repo object reuse (#38050)
|
||||||
|
- Cherry-pick upstream v1.26.3: hostmatcher block reserved IP ranges — SSRF prevention (#38059)
|
||||||
|
- Cherry-pick upstream v1.26.3: bound debian ParseControlFile — DoS prevention (#38055)
|
||||||
|
- Cherry-pick upstream v1.26.3: feed token scope, migration SSRF, notification redaction (#38147)
|
||||||
|
- Cherry-pick upstream v1.26.3: OIDC ignore stale external login links to organizations (#38141)
|
||||||
|
- Cherry-pick upstream v1.26.3: 2FA timing, branch delete auth, org labels visibility, merge upstream auth (#38151)
|
||||||
|
- Cherry-pick upstream v1.26.3: allow git clone of private repos with anonymous code access (#38146)
|
||||||
|
- Cherry-pick upstream v1.26.3: hostmatcher patch incorrect private IP list (#38173)
|
||||||
|
- Cherry-pick upstream v1.26.4: do not auto-reactivate disabled users on OAuth2 callback (#38183)
|
||||||
|
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PR check: platform detection now queries metadata API instead of removed manifest.xml
|
||||||
|
- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783)
|
||||||
|
- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622)
|
||||||
|
- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885)
|
||||||
|
- Cherry-pick upstream: reject workflow_dispatch for workflows without that trigger (#37660)
|
||||||
|
- Cherry-pick upstream: keep action run title clickable when commit subject is a URL (#37867)
|
||||||
|
- Cherry-pick upstream: exclude workflow_call from workflow trigger detection (#37894)
|
||||||
|
- API token edit: reject empty scope update requests with 400 instead of silently succeeding
|
||||||
|
- Workflow token auth: pr-check.yml pre-release dispatch was silently failing due to env var / curl reference mismatch
|
||||||
|
- Workflow tokens: standardize all GA_TOKEN/GITEA_TOKEN/GITEA_URL env vars to MOKOGITEA_TOKEN/MOKOGITEA_URL across all workflow files in 5 template repos + MokoCLI (65+ files)
|
||||||
|
- CI issue reporter: rename GITEA_TOKEN/GITEA_URL to MOKOGITEA_TOKEN/MOKOGITEA_URL in automation/ci-issue-reporter.sh
|
||||||
|
- Workflow sync trigger: add workflow_dispatch event, fix if-condition to allow manual dispatch, add PHP install step for non-PHP runners
|
||||||
|
- Deploy workflow: merge dev health check into deploy job to avoid runner status reporting failures on inter-job handoff
|
||||||
|
- Licensing API: handle DB write errors in UpdateLicense, UpdateTier, DeleteTier instead of silently discarding
|
||||||
|
- Wiki API: fix findEntryForFile URL-decode fallback for non-ASCII page names
|
||||||
|
- Metadata settings template 500 error: removed reference to deleted Version field
|
||||||
|
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
|
||||||
|
- Wiki backlinks: proper URL encoding for subdirectory pages
|
||||||
|
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
|
||||||
|
- Issue statuses template: garbled em-dash character replaced
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Custom workflows moved to `.mokogitea/workflows/custom/`: deploy-mokogitea, deploy-dev, cascade-dev, pr-rc-release, test-mokogitea, upstream-bug-sync
|
||||||
|
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
|
||||||
|
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
|
||||||
|
- CI issue reporter: moved to MokoCLI (cli/ci_issue_reporter.sh), pr-check and repo-health now use ci-issue-reporter.yml reusable workflow
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Workflows: gitleaks.yml, npm-publish.yml, notify.yml, workflow-sync-trigger.yml, composer-publish.yml, deploy-manual.yml, security-audit.yml (not applicable to Go repo)
|
||||||
|
- automation/ci-issue-reporter.sh: moved to MokoCLI as centralized CLI tool
|
||||||
|
|
||||||
## [06.19.00] --- 2026-06-20
|
## [06.19.00] --- 2026-06-20
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
# MokoGitea
|
# MokoGitea
|
||||||
|
|
||||||
Moko fork of Gitea — adding project board REST API endpoints and custom enhancements
|
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API.
|
||||||
|
|
||||||
  
|
 
|
||||||
|
|
||||||
|
|
||||||
Custom Gitea fork with Project Board API
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pages
|
## Key Features
|
||||||
|
|
||||||
- [Branding](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Branding)
|
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
|
||||||
- [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment)
|
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
|
||||||
- [Project API](Project API)
|
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
|
||||||
- [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap)
|
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration
|
||||||
|
- **Cascade Merge** -- auto-create PRs to downstream branches after merge with configurable rules per repo
|
||||||
---
|
- **Security Scanning** -- secret detection (pre-receive blocking) + code analysis (SQL injection, XSS, command injection, path traversal, and more) with REST API for alerts, config, and on-demand scans
|
||||||
|
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
|
||||||
**Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
|
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
|
||||||
|
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
|
||||||
---
|
- **Project Board API** -- REST endpoints for project columns and cards
|
||||||
|
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
|
||||||
|
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full documentation is available on the [Wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki).
|
- [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs
|
||||||
|
- [Wiki Features](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features) -- all 10 wiki enhancements
|
||||||
|
- [Licensing API](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/api/Licensing-API)
|
||||||
|
- [Repo Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork/wiki/) -- feature docs, API reference, operations
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See the wiki for development guidelines and contribution instructions.
|
See the [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -40,4 +38,4 @@ This project is licensed under the GNU General Public License v3.0 or later -- s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki/Home)*
|
*[Moko Consulting](https://mokoconsulting.tech)*
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ============================================================================
|
|
||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Automation.CI
|
|
||||||
# INGROUP: moko-platform.Automation
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
||||||
# PATH: /automation/ci-issue-reporter.sh
|
|
||||||
# VERSION: 09.23.00
|
|
||||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
|
||||||
# Deduplicates by searching open issues with the "ci-auto" label
|
|
||||||
# whose title matches the gate. If a matching issue exists, a comment
|
|
||||||
# is appended instead of opening a duplicate.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
|
||||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
|
||||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
|
||||||
REPO="${GITHUB_REPOSITORY:-}"
|
|
||||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
|
||||||
LABEL_NAME="ci-auto"
|
|
||||||
LABEL_COLOR="#e11d48"
|
|
||||||
|
|
||||||
GATE=""
|
|
||||||
DETAILS=""
|
|
||||||
SEVERITY="error"
|
|
||||||
WORKFLOW=""
|
|
||||||
|
|
||||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
|
||||||
|
|
||||||
Required:
|
|
||||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
|
||||||
--details Human-readable failure description
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
--severity "error" (default) or "warning"
|
|
||||||
--workflow Workflow name for the issue title
|
|
||||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
|
||||||
--run-url URL to the CI run (auto-detected from env)
|
|
||||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
|
||||||
--url Gitea base URL (default: \$GITEA_URL)
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--gate) GATE="$2"; shift 2 ;;
|
|
||||||
--details) DETAILS="$2"; shift 2 ;;
|
|
||||||
--severity) SEVERITY="$2"; shift 2 ;;
|
|
||||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
|
||||||
--repo) REPO="$2"; shift 2 ;;
|
|
||||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
|
||||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
|
||||||
--url) GITEA_URL="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) echo "Unknown option: $1"; usage ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
|
||||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
|
||||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
|
||||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
|
||||||
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
|
||||||
|
|
||||||
# ── Build title ─────────────────────────────────────────────────────────────
|
|
||||||
if [[ -n "$WORKFLOW" ]]; then
|
|
||||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
|
||||||
else
|
|
||||||
TITLE="[CI] ${GATE} failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
|
||||||
ensure_label() {
|
|
||||||
local exists
|
|
||||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [[ "$exists" == "200" ]]; then
|
|
||||||
# Check if label already exists
|
|
||||||
local found
|
|
||||||
found=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null \
|
|
||||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
|
||||||
|
|
||||||
if [[ -z "$found" ]]; then
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/labels" \
|
|
||||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
|
||||||
> /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Search for existing open issue ──────────────────────────────────────────
|
|
||||||
find_existing_issue() {
|
|
||||||
# URL-encode the gate name for the query
|
|
||||||
local query
|
|
||||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
|
||||||
|
|
||||||
local response
|
|
||||||
response=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
|
||||||
2>/dev/null || echo "[]")
|
|
||||||
|
|
||||||
# Extract the first matching issue number
|
|
||||||
echo "$response" \
|
|
||||||
| grep -oP '"number":\s*\K[0-9]+' \
|
|
||||||
| head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Build issue body ────────────────────────────────────────────────────────
|
|
||||||
build_body() {
|
|
||||||
local severity_badge
|
|
||||||
if [[ "$SEVERITY" == "error" ]]; then
|
|
||||||
severity_badge="**Severity:** Error"
|
|
||||||
else
|
|
||||||
severity_badge="**Severity:** Warning"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<BODY
|
|
||||||
## CI Gate Failure: ${GATE}
|
|
||||||
|
|
||||||
${severity_badge}
|
|
||||||
**Workflow:** ${WORKFLOW:-unknown}
|
|
||||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
|
||||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
|
||||||
**Run:** [View CI run](${RUN_URL})
|
|
||||||
|
|
||||||
### Details
|
|
||||||
|
|
||||||
${DETAILS}
|
|
||||||
|
|
||||||
### Resolution
|
|
||||||
|
|
||||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
|
||||||
BODY
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
|
||||||
build_comment() {
|
|
||||||
cat <<COMMENT
|
|
||||||
### CI failure recurrence
|
|
||||||
|
|
||||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
|
||||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
|
||||||
**Run:** [View CI run](${RUN_URL})
|
|
||||||
|
|
||||||
${DETAILS}
|
|
||||||
COMMENT
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
ensure_label
|
|
||||||
|
|
||||||
EXISTING=$(find_existing_issue)
|
|
||||||
|
|
||||||
if [[ -n "$EXISTING" ]]; then
|
|
||||||
# Append comment to existing issue
|
|
||||||
COMMENT_BODY=$(build_comment)
|
|
||||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
|
||||||
|
|
||||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues/${EXISTING}/comments" \
|
|
||||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [[ "$HTTP" == "201" ]]; then
|
|
||||||
echo "Commented on existing issue #${EXISTING}"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Create new issue
|
|
||||||
ISSUE_BODY=$(build_body)
|
|
||||||
ISSUE_JSON=$(python3 -c "
|
|
||||||
import sys, json
|
|
||||||
body = sys.stdin.read()
|
|
||||||
print(json.dumps({
|
|
||||||
'title': sys.argv[1],
|
|
||||||
'body': body,
|
|
||||||
'labels': []
|
|
||||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
|
||||||
|
|
||||||
# Create the issue
|
|
||||||
RESPONSE=$(curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues" \
|
|
||||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
|
||||||
|
|
||||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
|
||||||
|
|
||||||
if [[ -n "$ISSUE_NUM" ]]; then
|
|
||||||
# Apply label (separate call — more reliable across Gitea versions)
|
|
||||||
LABEL_ID=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null \
|
|
||||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
|
||||||
| head -1 || true)
|
|
||||||
|
|
||||||
if [[ -n "$LABEL_ID" ]]; then
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
|
||||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
|
||||||
> /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to create issue"
|
|
||||||
echo "Response: ${RESPONSE}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
+14
-9
@@ -113,23 +113,25 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
|
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
|
||||||
|
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
|
||||||
|
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
|
||||||
|
// check in routers/private/serv.go and grant access.
|
||||||
|
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
|
||||||
switch verb {
|
switch verb {
|
||||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
case git.CmdVerbReceivePack:
|
case git.CmdVerbReceivePack:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||||
switch lfsVerb {
|
switch lfsVerb {
|
||||||
case git.CmdSubVerbLfsUpload:
|
case git.CmdSubVerbLfsUpload:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdSubVerbLfsDownload:
|
case git.CmdSubVerbLfsDownload:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// should be unreachable
|
return perm.AccessModeNone, false
|
||||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
|
||||||
return perm.AccessModeNone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServ(ctx context.Context, c *cli.Command) error {
|
func runServ(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -247,7 +249,10 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedMode := getAccessMode(verb, lfsVerb)
|
requestedMode, ok := getAccessMode(verb, lfsVerb)
|
||||||
|
if !ok {
|
||||||
|
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
|
||||||
|
}
|
||||||
|
|
||||||
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||||
if extra.HasError() {
|
if extra.HasError() {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetAccessMode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
verb, lfsVerb string
|
||||||
|
expected perm.AccessMode
|
||||||
|
}{
|
||||||
|
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
|
||||||
|
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
|
||||||
|
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||||
|
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||||
|
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, tc.expected, mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
|
||||||
|
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
|
||||||
|
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
|
||||||
|
// it would pass the `userMode < mode` permission check in routers/private/serv.go
|
||||||
|
// and hand out valid LFS JWTs for any private repository.
|
||||||
|
func TestGetAccessModeUnknownVerb(t *testing.T) {
|
||||||
|
cases := []struct{ verb, lfsVerb string }{
|
||||||
|
{git.CmdVerbLfsAuthenticate, ""},
|
||||||
|
{git.CmdVerbLfsAuthenticate, "badverb"},
|
||||||
|
{git.CmdVerbLfsTransfer, "badverb"},
|
||||||
|
{"git-unknown-verb", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||||
|
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, perm.AccessModeNone, mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ type FindRunOptions struct {
|
|||||||
Ref string // the commit/tag/… that caused this workflow
|
Ref string // the commit/tag/… that caused this workflow
|
||||||
TriggerUserID int64
|
TriggerUserID int64
|
||||||
TriggerEvent webhook_module.HookEventType
|
TriggerEvent webhook_module.HookEventType
|
||||||
Approved bool // not util.OptionalBool, it works only when it's true
|
|
||||||
Status []Status
|
Status []Status
|
||||||
ConcurrencyGroup string
|
ConcurrencyGroup string
|
||||||
CommitSHA string
|
CommitSHA string
|
||||||
@@ -81,9 +80,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
|||||||
if opts.TriggerUserID > 0 {
|
if opts.TriggerUserID > 0 {
|
||||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
||||||
}
|
}
|
||||||
if opts.Approved {
|
|
||||||
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
|
|
||||||
}
|
|
||||||
if len(opts.Status) > 0 {
|
if len(opts.Status) > 0 {
|
||||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
AccessTokenScopeCategoryIssue
|
AccessTokenScopeCategoryIssue
|
||||||
AccessTokenScopeCategoryRepository
|
AccessTokenScopeCategoryRepository
|
||||||
AccessTokenScopeCategoryUser
|
AccessTokenScopeCategoryUser
|
||||||
|
AccessTokenScopeCategoryLicensing
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllAccessTokenScopeCategories contains all access token scope categories
|
// AllAccessTokenScopeCategories contains all access token scope categories
|
||||||
@@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{
|
|||||||
AccessTokenScopeCategoryIssue,
|
AccessTokenScopeCategoryIssue,
|
||||||
AccessTokenScopeCategoryRepository,
|
AccessTokenScopeCategoryRepository,
|
||||||
AccessTokenScopeCategoryUser,
|
AccessTokenScopeCategoryUser,
|
||||||
|
AccessTokenScopeCategoryLicensing,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessTokenScopeLevel represents the access levels without a given scope category
|
// AccessTokenScopeLevel represents the access levels without a given scope category
|
||||||
@@ -82,6 +84,9 @@ const (
|
|||||||
|
|
||||||
AccessTokenScopeReadUser AccessTokenScope = "read:user"
|
AccessTokenScopeReadUser AccessTokenScope = "read:user"
|
||||||
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
|
AccessTokenScopeWriteUser AccessTokenScope = "write:user"
|
||||||
|
|
||||||
|
AccessTokenScopeReadLicensing AccessTokenScope = "read:licensing"
|
||||||
|
AccessTokenScopeWriteLicensing AccessTokenScope = "write:licensing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// accessTokenScopeBitmap represents a bitmap of access token scopes.
|
// accessTokenScopeBitmap represents a bitmap of access token scopes.
|
||||||
@@ -93,7 +98,8 @@ const (
|
|||||||
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits |
|
accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits |
|
||||||
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits |
|
accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits |
|
||||||
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits |
|
accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits |
|
||||||
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits
|
accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits |
|
||||||
|
accessTokenScopeWriteLicensingBits
|
||||||
|
|
||||||
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota
|
accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota
|
||||||
|
|
||||||
@@ -124,6 +130,9 @@ const (
|
|||||||
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
|
accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota
|
||||||
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
|
accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadUserBits
|
||||||
|
|
||||||
|
accessTokenScopeReadLicensingBits accessTokenScopeBitmap = 1 << iota
|
||||||
|
accessTokenScopeWriteLicensingBits accessTokenScopeBitmap = 1<<iota | accessTokenScopeReadLicensingBits
|
||||||
|
|
||||||
// The current implementation only supports up to 64 token scopes.
|
// The current implementation only supports up to 64 token scopes.
|
||||||
// If we need to support > 64 scopes,
|
// If we need to support > 64 scopes,
|
||||||
// refactoring the whole implementation in this file (and only this file) is needed.
|
// refactoring the whole implementation in this file (and only this file) is needed.
|
||||||
@@ -142,6 +151,7 @@ var allAccessTokenScopes = []AccessTokenScope{
|
|||||||
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
|
AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue,
|
||||||
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
|
AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository,
|
||||||
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
|
AccessTokenScopeWriteUser, AccessTokenScopeReadUser,
|
||||||
|
AccessTokenScopeWriteLicensing, AccessTokenScopeReadLicensing,
|
||||||
}
|
}
|
||||||
|
|
||||||
// allAccessTokenScopeBits contains all access token scopes.
|
// allAccessTokenScopeBits contains all access token scopes.
|
||||||
@@ -166,6 +176,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{
|
|||||||
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
|
AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits,
|
||||||
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
|
AccessTokenScopeReadUser: accessTokenScopeReadUserBits,
|
||||||
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
|
AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits,
|
||||||
|
AccessTokenScopeReadLicensing: accessTokenScopeReadLicensingBits,
|
||||||
|
AccessTokenScopeWriteLicensing: accessTokenScopeWriteLicensingBits,
|
||||||
}
|
}
|
||||||
|
|
||||||
// readAccessTokenScopes maps a scope category to the read permission scope
|
// readAccessTokenScopes maps a scope category to the read permission scope
|
||||||
@@ -180,6 +192,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
|||||||
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
|
AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue,
|
||||||
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
|
AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository,
|
||||||
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
|
AccessTokenScopeCategoryUser: AccessTokenScopeReadUser,
|
||||||
|
AccessTokenScopeCategoryLicensing: AccessTokenScopeReadLicensing,
|
||||||
},
|
},
|
||||||
Write: {
|
Write: {
|
||||||
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
|
AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub,
|
||||||
@@ -191,6 +204,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
|
|||||||
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
|
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
|
||||||
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
|
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
|
||||||
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
|
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
|
||||||
|
AccessTokenScopeCategoryLicensing: AccessTokenScopeWriteLicensing,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +384,7 @@ func (bitmap accessTokenScopeBitmap) toScope() AccessTokenScope {
|
|||||||
scope := AccessTokenScope(strings.Join(scopes, ","))
|
scope := AccessTokenScope(strings.Join(scopes, ","))
|
||||||
scope = AccessTokenScope(strings.ReplaceAll(
|
scope = AccessTokenScope(strings.ReplaceAll(
|
||||||
string(scope),
|
string(scope),
|
||||||
"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user",
|
"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing",
|
||||||
"all",
|
"all",
|
||||||
))
|
))
|
||||||
return scope
|
return scope
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ type scopeTestNormalize struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAccessTokenScope_Normalize(t *testing.T) {
|
func TestAccessTokenScope_Normalize(t *testing.T) {
|
||||||
assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
|
assert.Equal(t, []string{"activitypub", "admin", "issue", "licensing", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories())
|
||||||
tests := []scopeTestNormalize{
|
tests := []scopeTestNormalize{
|
||||||
{"", "", nil},
|
{"", "", nil},
|
||||||
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
{"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil},
|
||||||
{"all", "all", nil},
|
{"all", "all", nil},
|
||||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil},
|
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing", "all", nil},
|
||||||
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil},
|
{"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing,public-only", "public-only,all", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scope := range GetAccessTokenCategories() {
|
for _, scope := range GetAccessTokenCategories() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateTOTP validates the provided passcode.
|
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
|
||||||
|
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||||
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
|
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
|
||||||
}
|
}
|
||||||
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
||||||
}
|
}
|
||||||
secretStr := string(secretBytes)
|
secretStr := string(secretBytes)
|
||||||
return totp.Validate(passcode, secretStr), nil
|
return totp.Validate(passcode, secretStr), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||||
|
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||||
|
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||||
|
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||||
|
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||||
|
ok, err := t.validateTOTP(passcode)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||||
|
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||||
|
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||||
|
t.LastUsedPasscode = passcode
|
||||||
|
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||||
|
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||||
|
Cols("last_used_passcode").Update(t)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewTwoFactor creates a new two-factor authentication token.
|
// NewTwoFactor creates a new two-factor authentication token.
|
||||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||||
_, err := db.GetEngine(ctx).Insert(t)
|
_, err := db.GetEngine(ctx).Insert(t)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tfa := &auth_model.TwoFactor{UID: 1}
|
||||||
|
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||||
|
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||||
|
|
||||||
|
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// first use of a valid passcode succeeds
|
||||||
|
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||||
|
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// an invalid passcode is rejected without consuming anything
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
|||||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
// LFS objects are repository code content, so authorization must require
|
||||||
|
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||||
|
// reuse of an existing LFS object across repositories.
|
||||||
|
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
|||||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||||
cond := builder.In(
|
cond := builder.In(
|
||||||
"`lfs_meta_object`.repository_id",
|
"`lfs_meta_object`.repository_id",
|
||||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||||
)
|
)
|
||||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ type ProtectedBranch struct {
|
|||||||
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
WhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
MergeWhitelistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
ForcePushAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
||||||
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
@@ -194,6 +200,46 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
|
|||||||
return in && protectBranch.CanUserPush(ctx, user)
|
return in && protectBranch.CanUserPush(ctx, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanUserDelete returns if some user could delete this protected branch
|
||||||
|
func (protectBranch *ProtectedBranch) CanUserDelete(ctx context.Context, user *user_model.User) bool {
|
||||||
|
if !protectBranch.CanDelete {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsActions() && protectBranch.DeleteAllowlistActionsUser {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !protectBranch.EnableDeleteAllowlist {
|
||||||
|
if err := protectBranch.LoadRepo(ctx); err != nil {
|
||||||
|
log.Error("LoadRepo: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeAdmin)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("HasAccessUnit: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(protectBranch.DeleteAllowlistUserIDs, user.ID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(protectBranch.DeleteAllowlistTeamIDs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.DeleteAllowlistTeamIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("IsUserInTeams: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||||
func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
|
func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
|
||||||
// Allow the actions bot user if explicitly whitelisted.
|
// Allow the actions bot user if explicitly whitelisted.
|
||||||
@@ -365,6 +411,9 @@ type WhitelistOptions struct {
|
|||||||
|
|
||||||
ApprovalsUserIDs []int64
|
ApprovalsUserIDs []int64
|
||||||
ApprovalsTeamIDs []int64
|
ApprovalsTeamIDs []int64
|
||||||
|
|
||||||
|
DeleteUserIDs []int64
|
||||||
|
DeleteTeamIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProtectBranch saves branch protection options of repository.
|
// UpdateProtectBranch saves branch protection options of repository.
|
||||||
@@ -430,6 +479,18 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
|
|||||||
}
|
}
|
||||||
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
|
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
|
||||||
|
|
||||||
|
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.DeleteAllowlistUserIDs, opts.DeleteUserIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
protectBranch.DeleteAllowlistUserIDs = whitelist
|
||||||
|
|
||||||
|
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.DeleteAllowlistTeamIDs, opts.DeleteTeamIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
protectBranch.DeleteAllowlistTeamIDs = whitelist
|
||||||
|
|
||||||
// Looks like it's a new rule
|
// Looks like it's a new rule
|
||||||
if protectBranch.ID == 0 {
|
if protectBranch.ID == 0 {
|
||||||
// as it's a new rule and if priority was not set, we need to calc it.
|
// as it's a new rule and if priority was not set, we need to calc it.
|
||||||
@@ -574,14 +635,15 @@ func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id
|
|||||||
|
|
||||||
// removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
|
// removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
|
||||||
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
|
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
|
||||||
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
|
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs, lenDeleteIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs), len(p.DeleteAllowlistUserIDs)
|
||||||
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
|
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs, lenDeleteTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs), len(p.DeleteAllowlistTeamIDs)
|
||||||
|
|
||||||
if userID > 0 {
|
if userID > 0 {
|
||||||
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
||||||
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
||||||
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
||||||
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
||||||
|
p.DeleteAllowlistUserIDs = util.SliceRemoveAll(p.DeleteAllowlistUserIDs, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if teamID > 0 {
|
if teamID > 0 {
|
||||||
@@ -589,16 +651,19 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI
|
|||||||
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
||||||
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
||||||
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
||||||
|
p.DeleteAllowlistTeamIDs = util.SliceRemoveAll(p.DeleteAllowlistTeamIDs, teamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
||||||
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
||||||
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
||||||
lenMergeIDs != len(p.MergeWhitelistUserIDs)) ||
|
lenMergeIDs != len(p.MergeWhitelistUserIDs) ||
|
||||||
|
lenDeleteIDs != len(p.DeleteAllowlistUserIDs)) ||
|
||||||
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
||||||
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
||||||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
|
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
|
||||||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) {
|
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs) ||
|
||||||
|
lenDeleteTeamIDs != len(p.DeleteAllowlistTeamIDs)) {
|
||||||
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
|
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
|
||||||
return fmt.Errorf("updateProtectedBranches: %v", err)
|
return fmt.Errorf("updateProtectedBranches: %v", err)
|
||||||
}
|
}
|
||||||
@@ -613,6 +678,7 @@ func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, us
|
|||||||
"force_push_allowlist_user_i_ds",
|
"force_push_allowlist_user_i_ds",
|
||||||
"merge_whitelist_user_i_ds",
|
"merge_whitelist_user_i_ds",
|
||||||
"approvals_whitelist_user_i_ds",
|
"approvals_whitelist_user_i_ds",
|
||||||
|
"delete_allowlist_user_i_ds",
|
||||||
}
|
}
|
||||||
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
|
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
|
||||||
}
|
}
|
||||||
@@ -624,6 +690,7 @@ func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, te
|
|||||||
"force_push_allowlist_team_i_ds",
|
"force_push_allowlist_team_i_ds",
|
||||||
"merge_whitelist_team_i_ds",
|
"merge_whitelist_team_i_ds",
|
||||||
"approvals_whitelist_team_i_ds",
|
"approvals_whitelist_team_i_ds",
|
||||||
|
"delete_allowlist_team_i_ds",
|
||||||
}
|
}
|
||||||
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
|
|||||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||||
Description string `xorm:"TEXT"`
|
Description string `xorm:"TEXT"`
|
||||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||||
|
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
|
||||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
@@ -32,6 +33,211 @@ func (IssueStatusDef) TableName() string {
|
|||||||
return "issue_status_def"
|
return "issue_status_def"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Presets
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// StatusPresetEntry defines a single status in a preset template.
|
||||||
|
type StatusPresetEntry struct {
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
Description string
|
||||||
|
ClosesIssue bool
|
||||||
|
IsRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusPreset defines a named collection of status definitions.
|
||||||
|
type StatusPreset struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Statuses []StatusPresetEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusPresets is the registry of built-in status presets.
|
||||||
|
var StatusPresets = map[string]*StatusPreset{
|
||||||
|
"default": {
|
||||||
|
Name: "default",
|
||||||
|
Description: "General-purpose workflow (default seed)",
|
||||||
|
Statuses: []StatusPresetEntry{
|
||||||
|
{Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true},
|
||||||
|
{Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done"},
|
||||||
|
{Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input"},
|
||||||
|
{Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review"},
|
||||||
|
{Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true},
|
||||||
|
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"software-development": {
|
||||||
|
Name: "software-development",
|
||||||
|
Description: "Software development lifecycle",
|
||||||
|
Statuses: []StatusPresetEntry{
|
||||||
|
{Name: "Open", Color: "#2563eb", Description: "New or active issue", IsRequired: true},
|
||||||
|
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on this"},
|
||||||
|
{Name: "In Review", Color: "#0891b2", Description: "Pull request submitted, awaiting review"},
|
||||||
|
{Name: "Testing", Color: "#d97706", Description: "Being tested or in QA"},
|
||||||
|
{Name: "Closed", Color: "#16a34a", Description: "Completed, merged, and deployed", ClosesIssue: true, IsRequired: true},
|
||||||
|
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"support-tickets": {
|
||||||
|
Name: "support-tickets",
|
||||||
|
Description: "Customer support ticket workflow",
|
||||||
|
Statuses: []StatusPresetEntry{
|
||||||
|
{Name: "New", Color: "#2563eb", Description: "Ticket received, not yet triaged", IsRequired: true},
|
||||||
|
{Name: "Assigned", Color: "#7c3aed", Description: "Assigned to a support agent"},
|
||||||
|
{Name: "Waiting for Customer", Color: "#f59e0b", Description: "Awaiting customer response"},
|
||||||
|
{Name: "In Progress", Color: "#0891b2", Description: "Agent is actively working on this"},
|
||||||
|
{Name: "Resolved", Color: "#16a34a", Description: "Issue resolved, awaiting confirmation", ClosesIssue: true},
|
||||||
|
{Name: "Closed", Color: "#059669", Description: "Confirmed resolved", ClosesIssue: true, IsRequired: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bug-tracking": {
|
||||||
|
Name: "bug-tracking",
|
||||||
|
Description: "Bug lifecycle tracking",
|
||||||
|
Statuses: []StatusPresetEntry{
|
||||||
|
{Name: "New", Color: "#2563eb", Description: "Bug reported, not yet triaged", IsRequired: true},
|
||||||
|
{Name: "Confirmed", Color: "#dc2626", Description: "Bug confirmed and reproducible"},
|
||||||
|
{Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on a fix"},
|
||||||
|
{Name: "Fixed", Color: "#0891b2", Description: "Fix implemented, awaiting verification"},
|
||||||
|
{Name: "Verified", Color: "#16a34a", Description: "Fix verified by QA"},
|
||||||
|
{Name: "Closed", Color: "#059669", Description: "Bug resolved and closed", ClosesIssue: true, IsRequired: true},
|
||||||
|
{Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to fix", ClosesIssue: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusPresetNames returns the list of available preset names in display order.
|
||||||
|
func StatusPresetNames() []string {
|
||||||
|
return []string{"default", "software-development", "support-tickets", "bug-tracking"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyStatusPreset replaces all non-required statuses for an org with a preset.
|
||||||
|
// Required statuses (Open/Closed) are preserved if they already exist; the preset's
|
||||||
|
// required entries are created if missing. Non-required statuses are soft-deleted
|
||||||
|
// (is_active=false) and the preset's non-required entries are inserted.
|
||||||
|
func ApplyStatusPreset(ctx context.Context, orgID int64, presetName string) error {
|
||||||
|
preset, ok := StatusPresets[presetName]
|
||||||
|
if !ok {
|
||||||
|
return db.ErrNotExist{Resource: "StatusPreset", ID: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := GetAllIssueStatusDefsByOrg(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup of existing statuses by name
|
||||||
|
existingByName := make(map[string]*IssueStatusDef, len(existing))
|
||||||
|
for _, d := range existing {
|
||||||
|
existingByName[d.Name] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate all non-required existing statuses
|
||||||
|
for _, d := range existing {
|
||||||
|
if d.IsRequired {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.IsActive {
|
||||||
|
d.IsActive = false
|
||||||
|
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preset entries
|
||||||
|
for i, entry := range preset.Statuses {
|
||||||
|
if ex, found := existingByName[entry.Name]; found {
|
||||||
|
// Update existing status to match preset
|
||||||
|
ex.Color = entry.Color
|
||||||
|
ex.Description = entry.Description
|
||||||
|
ex.ClosesIssue = entry.ClosesIssue
|
||||||
|
ex.SortOrder = i
|
||||||
|
ex.IsActive = true
|
||||||
|
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new status
|
||||||
|
def := &IssueStatusDef{
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: entry.Name,
|
||||||
|
Color: entry.Color,
|
||||||
|
Description: entry.Description,
|
||||||
|
ClosesIssue: entry.ClosesIssue,
|
||||||
|
IsRequired: entry.IsRequired,
|
||||||
|
SortOrder: i,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyStatusesFromOrg copies all active status definitions from srcOrgID to dstOrgID.
|
||||||
|
// Existing non-required statuses in dstOrgID are deactivated first.
|
||||||
|
func CopyStatusesFromOrg(ctx context.Context, srcOrgID, dstOrgID int64) error {
|
||||||
|
srcDefs, err := GetIssueStatusDefsByOrg(ctx, srcOrgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := GetAllIssueStatusDefsByOrg(ctx, dstOrgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingByName := make(map[string]*IssueStatusDef, len(existing))
|
||||||
|
for _, d := range existing {
|
||||||
|
existingByName[d.Name] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate non-required existing statuses
|
||||||
|
for _, d := range existing {
|
||||||
|
if d.IsRequired {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.IsActive {
|
||||||
|
d.IsActive = false
|
||||||
|
if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy source statuses
|
||||||
|
for _, src := range srcDefs {
|
||||||
|
if ex, found := existingByName[src.Name]; found {
|
||||||
|
ex.Color = src.Color
|
||||||
|
ex.Description = src.Description
|
||||||
|
ex.ClosesIssue = src.ClosesIssue
|
||||||
|
ex.SortOrder = src.SortOrder
|
||||||
|
ex.IsActive = true
|
||||||
|
if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
def := &IssueStatusDef{
|
||||||
|
OrgID: dstOrgID,
|
||||||
|
Name: src.Name,
|
||||||
|
Color: src.Color,
|
||||||
|
Description: src.Description,
|
||||||
|
ClosesIssue: src.ClosesIssue,
|
||||||
|
IsRequired: src.IsRequired,
|
||||||
|
SortOrder: src.SortOrder,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Queries
|
// Queries
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -56,14 +262,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||||
|
// Open and Closed are required (is_required=true) and cannot be deleted.
|
||||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||||
defaults := []*IssueStatusDef{
|
defaults := []*IssueStatusDef{
|
||||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
{OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
|
||||||
|
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
|
||||||
}
|
}
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||||
@@ -111,13 +318,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStatusRequired is returned when trying to delete a required status.
|
||||||
|
type ErrStatusRequired struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrStatusRequired) Error() string {
|
||||||
|
return "status is required and cannot be deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrStatusRequired checks if an error is ErrStatusRequired.
|
||||||
|
func IsErrStatusRequired(err error) bool {
|
||||||
|
_, ok := err.(ErrStatusRequired)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||||
|
// Returns ErrStatusRequired if the status is marked as required.
|
||||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||||
|
def, err := GetIssueStatusDefByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if def.IsRequired {
|
||||||
|
return ErrStatusRequired{ID: def.ID, Name: def.Name}
|
||||||
|
}
|
||||||
// Clear status_id on all issues that reference this definition
|
// Clear status_id on all issues that reference this definition
|
||||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
_, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
|||||||
return rules, warnings
|
return rules, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||||
|
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||||
|
// enforced by the caller across the whole rules×files match loop.
|
||||||
|
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||||
|
|
||||||
type CodeOwnerRule struct {
|
type CodeOwnerRule struct {
|
||||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||||
Negative bool
|
Negative bool
|
||||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
|||||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||||
return nil, warnings
|
return nil, warnings
|
||||||
}
|
}
|
||||||
|
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||||
|
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||||
|
|
||||||
for _, user := range tokens[1:] {
|
for _, user := range tokens[1:] {
|
||||||
user = strings.TrimPrefix(user, "@")
|
user = strings.TrimPrefix(user, "@")
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package issues_test
|
package issues_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
|||||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||||
|
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||||
t.Run("GetApprovers", testGetApprovers)
|
t.Run("GetApprovers", testGetApprovers)
|
||||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||||
@@ -370,6 +373,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||||
|
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||||
|
// each compiled rule must enforce a bounded match time.
|
||||||
|
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||||
|
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
|
||||||
|
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||||
|
start := time.Now()
|
||||||
|
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||||
|
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
func testGetApprovers(t *testing.T) {
|
func testGetApprovers(t *testing.T) {
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||||
|
|||||||
@@ -436,6 +436,9 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
|
||||||
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
|
||||||
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
|
||||||
|
newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest),
|
||||||
|
newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch),
|
||||||
|
newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddDeployFieldsToRepoManifest adds deploy configuration columns to repo_manifest.
|
||||||
|
func AddDeployFieldsToRepoManifest(x *xorm.Engine) error {
|
||||||
|
type RepoManifest struct {
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"`
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"`
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"`
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"`
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"`
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"`
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"`
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(RepoManifest))
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddDeleteAllowlistToProtectedBranch(x *xorm.Engine) error {
|
||||||
|
type ProtectedBranch struct {
|
||||||
|
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||||
|
DeleteAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
DeleteAllowlistActionsUser bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(ProtectedBranch))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddCascadeMergeRuleTable(x *xorm.Engine) error {
|
||||||
|
type CascadeMergeRule struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||||
|
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||||
|
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||||
|
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(CascadeMergeRule))
|
||||||
|
}
|
||||||
@@ -323,6 +323,60 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrganization creates record of a new organization.
|
// CreateOrganization creates record of a new organization.
|
||||||
|
// DefaultTeamSpec defines a team to auto-create when a new organization is created.
|
||||||
|
type DefaultTeamSpec struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
AccessMode perm.AccessMode
|
||||||
|
IncludesAllRepositories bool
|
||||||
|
CanCreateOrgRepo bool
|
||||||
|
Units map[unit.Type]perm.AccessMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOrgTeams is the list of teams created for every new organization
|
||||||
|
// (in addition to the mandatory Owners team). Override in tests or via init.
|
||||||
|
var DefaultOrgTeams = []DefaultTeamSpec{
|
||||||
|
{
|
||||||
|
Name: "Developers",
|
||||||
|
Description: "Members with write access to code, issues, and pull requests",
|
||||||
|
AccessMode: perm.AccessModeWrite,
|
||||||
|
IncludesAllRepositories: true,
|
||||||
|
Units: map[unit.Type]perm.AccessMode{
|
||||||
|
unit.TypeCode: perm.AccessModeWrite,
|
||||||
|
unit.TypeIssues: perm.AccessModeWrite,
|
||||||
|
unit.TypePullRequests: perm.AccessModeWrite,
|
||||||
|
unit.TypeReleases: perm.AccessModeRead,
|
||||||
|
unit.TypeWiki: perm.AccessModeWrite,
|
||||||
|
unit.TypeProjects: perm.AccessModeWrite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Reviewers",
|
||||||
|
Description: "Members with read access for code review",
|
||||||
|
AccessMode: perm.AccessModeRead,
|
||||||
|
IncludesAllRepositories: true,
|
||||||
|
Units: map[unit.Type]perm.AccessMode{
|
||||||
|
unit.TypeCode: perm.AccessModeRead,
|
||||||
|
unit.TypeIssues: perm.AccessModeRead,
|
||||||
|
unit.TypePullRequests: perm.AccessModeRead,
|
||||||
|
unit.TypeReleases: perm.AccessModeRead,
|
||||||
|
unit.TypeWiki: perm.AccessModeRead,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "CI/CD",
|
||||||
|
Description: "Members with write access to actions and packages",
|
||||||
|
AccessMode: perm.AccessModeWrite,
|
||||||
|
IncludesAllRepositories: true,
|
||||||
|
Units: map[unit.Type]perm.AccessMode{
|
||||||
|
unit.TypeCode: perm.AccessModeRead,
|
||||||
|
unit.TypeActions: perm.AccessModeWrite,
|
||||||
|
unit.TypePackages: perm.AccessModeWrite,
|
||||||
|
unit.TypeReleases: perm.AccessModeWrite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) {
|
func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) {
|
||||||
if !owner.CanCreateOrganization() {
|
if !owner.CanCreateOrganization() {
|
||||||
return ErrUserNotAllowedCreateOrg{}
|
return ErrUserNotAllowedCreateOrg{}
|
||||||
@@ -348,7 +402,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
|||||||
}
|
}
|
||||||
org.UseCustomAvatar = true
|
org.UseCustomAvatar = true
|
||||||
org.MaxRepoCreation = -1
|
org.MaxRepoCreation = -1
|
||||||
org.NumTeams = 1
|
org.NumTeams = 1 + len(DefaultOrgTeams)
|
||||||
org.NumMembers = 1
|
org.NumMembers = 1
|
||||||
org.Type = user_model.UserTypeOrganization
|
org.Type = user_model.UserTypeOrganization
|
||||||
|
|
||||||
@@ -413,6 +467,37 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("insert team-user relation: %w", err)
|
return fmt.Errorf("insert team-user relation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, spec := range DefaultOrgTeams {
|
||||||
|
dt := &Team{
|
||||||
|
OrgID: org.ID,
|
||||||
|
LowerName: strings.ToLower(spec.Name),
|
||||||
|
Name: spec.Name,
|
||||||
|
Description: spec.Description,
|
||||||
|
AccessMode: spec.AccessMode,
|
||||||
|
IncludesAllRepositories: spec.IncludesAllRepositories,
|
||||||
|
CanCreateOrgRepo: spec.CanCreateOrgRepo,
|
||||||
|
}
|
||||||
|
if err = db.Insert(ctx, dt); err != nil {
|
||||||
|
return fmt.Errorf("insert default team %q: %w", spec.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dtUnits := make([]TeamUnit, 0, len(spec.Units))
|
||||||
|
for tp, am := range spec.Units {
|
||||||
|
dtUnits = append(dtUnits, TeamUnit{
|
||||||
|
OrgID: org.ID,
|
||||||
|
TeamID: dt.ID,
|
||||||
|
Type: tp,
|
||||||
|
AccessMode: am,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(dtUnits) > 0 {
|
||||||
|
if err = db.Insert(ctx, &dtUnits); err != nil {
|
||||||
|
return fmt.Errorf("insert default team %q units: %w", spec.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CascadeMergeRule struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||||
|
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||||
|
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||||
|
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||||
|
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(CascadeMergeRule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCascadeRulesByRepoID(ctx context.Context, repoID int64) ([]*CascadeMergeRule, error) {
|
||||||
|
rules := make([]*CascadeMergeRule, 0)
|
||||||
|
return rules, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCascadeRulesForBranch(ctx context.Context, repoID int64, sourceBranch string) ([]*CascadeMergeRule, error) {
|
||||||
|
rules := make([]*CascadeMergeRule, 0)
|
||||||
|
return rules, db.GetEngine(ctx).Where("repo_id = ? AND source_branch = ? AND enabled = ?", repoID, sourceBranch, true).Find(&rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCascadeRuleByID(ctx context.Context, id int64) (*CascadeMergeRule, error) {
|
||||||
|
rule := &CascadeMergeRule{ID: id}
|
||||||
|
has, err := db.GetEngine(ctx).Get(rule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||||
|
_, err := db.GetEngine(ctx).Insert(rule)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(rule.ID).Cols("source_branch", "target_branch", "enabled", "auto_merge").Update(rule)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCascadeRule(ctx context.Context, repoID, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", id, repoID).Delete(&CascadeMergeRule{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -50,6 +50,16 @@ type RepoMetadata struct {
|
|||||||
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
|
||||||
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
||||||
|
|
||||||
|
// deploy section
|
||||||
|
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
|
||||||
|
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
|
||||||
|
DeployUser string `xorm:"VARCHAR(100) 'deploy_user'"` // SSH user
|
||||||
|
DeployPath string `xorm:"TEXT 'deploy_path'"` // remote path for source/compose
|
||||||
|
DockerImage string `xorm:"VARCHAR(255) 'docker_image'"` // e.g. mokoconsulting/mokogitea
|
||||||
|
DockerRegistry string `xorm:"VARCHAR(255) 'docker_registry'"` // e.g. git.mokoconsulting.tech
|
||||||
|
ContainerName string `xorm:"VARCHAR(100) 'container_name'"` // Docker container name
|
||||||
|
HealthURL string `xorm:"TEXT 'health_url'"` // health check URL after deploy
|
||||||
|
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
||||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
|
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
|
||||||
for _, condition := range conditions {
|
for _, condition := range conditions {
|
||||||
switch cond := condition.(type) {
|
switch cond := condition.(type) {
|
||||||
case *testCond:
|
case *testCond:
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
||||||
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
|
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
|
||||||
return db.GetEngine(ctx).Get(externalLoginUser)
|
return db.Get[ExternalLoginUser](ctx, builder.Eq{
|
||||||
|
"external_id": externalID,
|
||||||
|
"login_source_id": loginSourceID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkExternalToUser link the external user to the user
|
// LinkExternalToUser link the external user to the user
|
||||||
@@ -118,6 +121,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveExternalLoginByExternalID removes a specific external login link by its provider-side identifier.
|
||||||
|
func RemoveExternalLoginByExternalID(ctx context.Context, loginSourceID int64, externalID string) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", externalID, loginSourceID).Delete(new(ExternalLoginUser))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserIDByExternalUserID get user id according to provider and userID
|
// GetUserIDByExternalUserID get user id according to provider and userID
|
||||||
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext {
|
|||||||
return gitContext
|
return gitContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
|
||||||
|
const workflowCallEvent = "workflow_call"
|
||||||
|
|
||||||
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||||
switch rawOn.Kind {
|
switch rawOn.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
@@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if val == workflowCallEvent {
|
||||||
|
return []*Event{}, nil
|
||||||
|
}
|
||||||
return []*Event{
|
return []*Event{
|
||||||
{Name: val},
|
{Name: val},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
for _, v := range val {
|
for _, v := range val {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
if t == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
res = append(res, &Event{Name: t})
|
res = append(res, &Event{Name: t})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid type %T", t)
|
return nil, fmt.Errorf("invalid type %T", t)
|
||||||
@@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
}
|
}
|
||||||
res := make([]*Event, 0, len(events))
|
res := make([]*Event, 0, len(events))
|
||||||
for i, k := range events {
|
for i, k := range events {
|
||||||
|
if k == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v := triggers[i]
|
v := triggers[i]
|
||||||
switch v.Kind {
|
switch v.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
|
|||||||
@@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
|
||||||
|
input: `on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
env:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
sha:
|
||||||
|
value: ${{ jobs.build.outputs.commit }}
|
||||||
|
secrets:
|
||||||
|
DEPLOY_KEY:
|
||||||
|
required: true
|
||||||
|
`,
|
||||||
|
result: []*Event{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
|
||||||
|
input: `on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
env:
|
||||||
|
type: string
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
`,
|
||||||
|
result: []*Event{
|
||||||
|
{
|
||||||
|
Name: "push",
|
||||||
|
acts: map[string][]string{"branches": {"main"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Scalar form: a purely reusable workflow has no event triggers.
|
||||||
|
input: "on: workflow_call",
|
||||||
|
result: []*Event{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Sequence form: `workflow_call` is excluded while sibling events are kept.
|
||||||
|
input: "on:\n - push\n - workflow_call\n - pull_request",
|
||||||
|
result: []*Event{
|
||||||
|
{Name: "push"},
|
||||||
|
{Name: "pull_request"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, kase := range kases {
|
for _, kase := range kases {
|
||||||
t.Run(kase.input, func(t *testing.T) {
|
t.Run(kase.input, func(t *testing.T) {
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ func TestIsWorkflow(t *testing.T) {
|
|||||||
path: ".gitea/workflows2/test.yml",
|
path: ".gitea/workflows2/test.yml",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "subdirectory workflow",
|
||||||
|
dirs: []string{".gitea/workflows"},
|
||||||
|
path: ".gitea/workflows/custom/deploy.yml",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested subdirectory workflow",
|
||||||
|
dirs: []string{".mokogitea/workflows"},
|
||||||
|
path: ".mokogitea/workflows/custom/deploy/staging.yaml",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unrelated path",
|
name: "unrelated path",
|
||||||
dirs: []string{".gitea/workflows", ".github/workflows"},
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
|
|||||||
// GetLastCommitForPaths returns last commit information
|
// GetLastCommitForPaths returns last commit information
|
||||||
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
||||||
// We read backwards from the commit to obtain all of the commits
|
// We read backwards from the commit to obtain all of the commits
|
||||||
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !gogit
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
|
||||||
|
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
entries, err := commit.Tree.ListEntries()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
|
||||||
|
for _, info := range infos {
|
||||||
|
nilCommits += util.Iif(info.Commit == nil, 1, 0)
|
||||||
|
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
|
||||||
|
}
|
||||||
|
return nilCommits, nonNilCommits
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
|
||||||
|
|
||||||
|
walkGitLogDebugBeforeNext = cancel
|
||||||
|
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
|
||||||
|
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
|
||||||
|
assert.Equal(t, 3, nilCommits)
|
||||||
|
|
||||||
|
walkGitLogDebugBeforeNext = nil
|
||||||
|
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
|
||||||
|
assert.Equal(t, 3, nonNilCommits)
|
||||||
|
assert.Equal(t, 0, nilCommits)
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
|
|||||||
entryPaths[i] = entry.Name()
|
entryPaths[i] = entry.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !gogit
|
||||||
|
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,10 +20,8 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
|
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
|
||||||
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
|
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
|
||||||
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
|
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand()
|
cmd := gitcmd.NewCommand()
|
||||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
||||||
|
|
||||||
@@ -54,77 +54,62 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
|
|||||||
ctx, ctxCancel := context.WithCancel(ctx)
|
ctx, ctxCancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||||
|
return &logNameStatusRepoParser{
|
||||||
return bufReader, func() {
|
treepath: treepath,
|
||||||
ctxCancel()
|
paths: paths,
|
||||||
stdoutReaderClose()
|
rd: bufReader,
|
||||||
|
close: func() {
|
||||||
|
ctxCancel()
|
||||||
|
stdoutReaderClose()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||||
type LogNameStatusRepoParser struct {
|
type logNameStatusRepoParser struct {
|
||||||
treepath string
|
treepath string
|
||||||
paths []string
|
paths []string
|
||||||
next []byte
|
next []byte
|
||||||
buffull bool
|
buffull bool
|
||||||
rd *bufio.Reader
|
rd *bufio.Reader
|
||||||
cancel func()
|
close func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
// logNameStatusCommitData represents a commit artifact from git log raw
|
||||||
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
|
type logNameStatusCommitData struct {
|
||||||
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
|
|
||||||
return &LogNameStatusRepoParser{
|
|
||||||
treepath: treepath,
|
|
||||||
paths: paths,
|
|
||||||
rd: rd,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogNameStatusCommitData represents a commit artefact from git log raw
|
|
||||||
type LogNameStatusCommitData struct {
|
|
||||||
CommitID string
|
CommitID string
|
||||||
ParentIDs []string
|
ParentIDs []string
|
||||||
Paths []bool
|
Paths []bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next returns the next LogStatusCommitData
|
// walkNext returns the next LogStatusCommitData
|
||||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
|
||||||
var err error
|
var err error
|
||||||
if len(g.next) == 0 {
|
if len(g.next) == 0 {
|
||||||
g.buffull = false
|
g.buffull = false
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := LogNameStatusCommitData{}
|
ret := logNameStatusCommitData{}
|
||||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +258,10 @@ diffloop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the parser
|
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
|
||||||
func (g *LogNameStatusRepoParser) Close() {
|
|
||||||
g.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
// walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
||||||
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||||
headRef := head.ID.String()
|
headRef := head.ID.String()
|
||||||
|
|
||||||
tree, err := head.SubTree(treepath)
|
tree, err := head.SubTree(treepath)
|
||||||
@@ -322,11 +304,9 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||||
// don't use defer g.Close() here as g may change its value - instead wrap in a func
|
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
|
||||||
defer func() {
|
defer func() { g.close() }()
|
||||||
g.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
results := make([]string, len(paths))
|
results := make([]string, len(paths))
|
||||||
remaining := len(paths)
|
remaining := len(paths)
|
||||||
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
|||||||
|
|
||||||
heaploop:
|
heaploop:
|
||||||
for {
|
for {
|
||||||
select {
|
if walkGitLogDebugBeforeNext != nil {
|
||||||
case <-ctx.Done():
|
walkGitLogDebugBeforeNext()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
break heaploop
|
|
||||||
}
|
|
||||||
g.Close()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
|
||||||
if err != nil {
|
if ctx.Err() != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
|
||||||
break heaploop
|
} else if errors.Is(err, io.EOF) {
|
||||||
}
|
break heaploop // reached to the end of log output
|
||||||
g.Close()
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err // other unknown errors
|
||||||
}
|
|
||||||
if current == nil {
|
|
||||||
break heaploop
|
|
||||||
}
|
}
|
||||||
parentRemaining.Remove(current.CommitID)
|
parentRemaining.Remove(current.CommitID)
|
||||||
for i, found := range current.Paths {
|
for i, found := range current.Paths {
|
||||||
@@ -395,14 +366,14 @@ heaploop:
|
|||||||
if remaining <= nextRestart {
|
if remaining <= nextRestart {
|
||||||
commitSinceNextRestart++
|
commitSinceNextRestart++
|
||||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||||
g.Close()
|
|
||||||
remainingPaths := make([]string, 0, len(paths))
|
remainingPaths := make([]string, 0, len(paths))
|
||||||
for i, pth := range paths {
|
for i, pth := range paths {
|
||||||
if results[i] == "" {
|
if results[i] == "" {
|
||||||
remainingPaths = append(remainingPaths, pth)
|
remainingPaths = append(remainingPaths, pth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
g.close()
|
||||||
|
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||||
parentRemaining = make(container.Set[string])
|
parentRemaining = make(container.Set[string])
|
||||||
nextRestart = (remaining * 3) / 4
|
nextRestart = (remaining * 3) / 4
|
||||||
continue heaploop
|
continue heaploop
|
||||||
@@ -410,7 +381,6 @@ heaploop:
|
|||||||
}
|
}
|
||||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||||
}
|
}
|
||||||
g.Close()
|
|
||||||
|
|
||||||
resultsMap := map[string]string{}
|
resultsMap := map[string]string{}
|
||||||
for i, pth := range paths {
|
for i, pth := range paths {
|
||||||
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||||
|
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||||
|
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||||
|
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||||
if opts.SkipTLSVerify {
|
if opts.SkipTLSVerify {
|
||||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, isEmpty)
|
assert.True(t, isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||||
|
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||||
|
func TestCloneRefusesRedirects(t *testing.T) {
|
||||||
|
var targetHit atomic.Bool
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetHit.Store(true)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer redirect.Close()
|
||||||
|
|
||||||
|
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostMatchList is used to check if a host or IP is in a list.
|
// HostMatchList is used to check if a host or IP is in a list.
|
||||||
@@ -23,10 +24,61 @@ type HostMatchList struct {
|
|||||||
ipNets []*net.IPNet
|
ipNets []*net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||||
|
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||||
const MatchBuiltinExternal = "external"
|
const MatchBuiltinExternal = "external"
|
||||||
|
|
||||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||||
|
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||||
|
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||||
|
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||||
|
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||||
|
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||||
|
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||||
|
var nets []*net.IPNet
|
||||||
|
for _, cidr := range []string{
|
||||||
|
// IPv4
|
||||||
|
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||||
|
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||||
|
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||||
|
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||||
|
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||||
|
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||||
|
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||||
|
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||||
|
// IPv6
|
||||||
|
"100::/64", // RFC 6666 discard-only
|
||||||
|
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||||
|
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||||
|
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||||
|
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||||
|
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||||
|
"2001:db8::/32", // RFC 3849 documentation
|
||||||
|
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||||
|
} {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||||
|
}
|
||||||
|
nets = append(nets, ipNet)
|
||||||
|
}
|
||||||
|
return nets
|
||||||
|
})
|
||||||
|
|
||||||
|
// isReservedIP reports whether ip falls in reserved special-purpose
|
||||||
|
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||||
|
func isReservedIP(ip net.IP) bool {
|
||||||
|
for _, ipNet := range reservedIPNets() {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||||
|
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||||
|
// Also called LAN/Intranet.
|
||||||
const MatchBuiltinPrivate = "private"
|
const MatchBuiltinPrivate = "private"
|
||||||
|
|
||||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||||
@@ -93,18 +145,22 @@ func (hl *HostMatchList) checkPattern(host string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
// matchesIP determines if the given IP matches any of the configured rules
|
||||||
|
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
|
||||||
if slices.Contains(hl.patterns, "*") {
|
if slices.Contains(hl.patterns, "*") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, builtin := range hl.builtins {
|
for _, builtin := range hl.builtins {
|
||||||
switch builtin {
|
switch builtin {
|
||||||
case MatchBuiltinExternal:
|
case MatchBuiltinExternal:
|
||||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
// External address must be a global unicast, must not be in reserved range and must not be in private range
|
||||||
|
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinPrivate:
|
case MatchBuiltinPrivate:
|
||||||
if ip.IsPrivate() {
|
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
|
||||||
|
// and must be in private range
|
||||||
|
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinLoopback:
|
case MatchBuiltinLoopback:
|
||||||
@@ -135,7 +191,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if ip := net.ParseIP(hostname); ip != nil {
|
if ip := net.ParseIP(hostname); ip != nil {
|
||||||
return hl.checkIP(ip)
|
return hl.matchesIP(ip)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -146,7 +202,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
||||||
return hl.checkPattern(host) || hl.checkIP(ip)
|
return hl.checkPattern(host) || hl.matchesIP(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
||||||
|
|||||||
@@ -159,3 +159,60 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
test(cases)
|
test(cases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
|
||||||
|
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
|
||||||
|
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
|
||||||
|
func TestReservedRanges(t *testing.T) {
|
||||||
|
external := ParseHostMatchList("", "external")
|
||||||
|
private := ParseHostMatchList("", "private")
|
||||||
|
|
||||||
|
// legitimate public destinations: external, not private
|
||||||
|
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
|
||||||
|
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
|
||||||
|
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
|
||||||
|
for _, ip := range []string{
|
||||||
|
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
|
||||||
|
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
|
||||||
|
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
|
||||||
|
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
|
||||||
|
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
|
||||||
|
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
|
||||||
|
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
|
||||||
|
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
|
||||||
|
for _, ip := range []string{
|
||||||
|
"100.64.0.1", // CGNAT
|
||||||
|
"100.127.255.254", // CGNAT
|
||||||
|
"168.63.129.16", // Azure WireServer
|
||||||
|
"192.0.2.1", // TEST-NET-1
|
||||||
|
"198.18.0.1", // benchmarking
|
||||||
|
"198.51.100.1", // TEST-NET-2
|
||||||
|
"203.0.113.1", // TEST-NET-3
|
||||||
|
"169.254.169.254", // Cloud metadata
|
||||||
|
"192.88.99.1", // 6to4 relay anycast
|
||||||
|
"64:ff9b::1", // NAT64
|
||||||
|
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
|
||||||
|
"2001::1", // Teredo
|
||||||
|
"2002::1", // 6to4
|
||||||
|
"2001:db8::1", // documentation
|
||||||
|
"fe80::1", // link local address
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||||
|
assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-3
@@ -175,16 +175,25 @@ var emojiProcessors = []processor{
|
|||||||
emojiProcessor,
|
emojiProcessor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
|
||||||
|
// is entirely a single URL, ignoring leading/trailing whitespace.
|
||||||
|
func isBareURLSubject(content string) bool {
|
||||||
|
s := strings.TrimSpace(html.UnescapeString(content))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
|
||||||
|
return m != nil && m[0] == 0 && m[1] == len(s)
|
||||||
|
}
|
||||||
|
|
||||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||||
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
|
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||||
// which changes every text node into a link to the passed default link.
|
|
||||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||||
procs := []processor{
|
procs := []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
fullHashPatternProcessor,
|
fullHashPatternProcessor,
|
||||||
linkProcessor,
|
|
||||||
mentionProcessor,
|
mentionProcessor,
|
||||||
issueIndexPatternProcessor,
|
issueIndexPatternProcessor,
|
||||||
commitCrossReferencePatternProcessor,
|
commitCrossReferencePatternProcessor,
|
||||||
@@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
|||||||
emojiShortCodeProcessor,
|
emojiShortCodeProcessor,
|
||||||
emojiProcessor,
|
emojiProcessor,
|
||||||
}
|
}
|
||||||
|
// When the whole subject is a bare URL, linkProcessor would turn it into
|
||||||
|
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
|
||||||
|
// the subject visually unclickable. Match GitHub: render such subjects as
|
||||||
|
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||||
|
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||||
|
// naturally breaks on that span, same as on GitHub).
|
||||||
|
if !isBareURLSubject(content) {
|
||||||
|
procs = append(procs, linkProcessor)
|
||||||
|
}
|
||||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||||
node.Type = html.ElementNode
|
node.Type = html.ElementNode
|
||||||
|
|||||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
|||||||
var depends strings.Builder
|
var depends strings.Builder
|
||||||
var control strings.Builder
|
var control strings.Builder
|
||||||
|
|
||||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||||
|
s := bufio.NewScanner(r)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
|
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
// A binary package control file holds exactly one stanza. Stop at the
|
||||||
|
// blank line that terminates it, otherwise a crafted control file could
|
||||||
|
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||||
|
// fields) into the generated repository "Packages" index.
|
||||||
|
if control.Len() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
control.WriteString(line)
|
||||||
|
control.WriteByte('\n')
|
||||||
|
|
||||||
if line[0] == ' ' || line[0] == '\t' {
|
if line[0] == ' ' || line[0] == '\t' {
|
||||||
switch key {
|
switch key {
|
||||||
case "Description":
|
case "Description":
|
||||||
|
|||||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
|||||||
assert.NotNil(t, p)
|
assert.NotNil(t, p)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("SingleStanzaOnly", func(t *testing.T) {
|
||||||
|
// A control file with a trailing stanza must not leak the extra fields into
|
||||||
|
// p.Control, otherwise buildPackagesIndices would emit a second package entry
|
||||||
|
// with an attacker-chosen Filename into the repository "Packages" index.
|
||||||
|
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
|
||||||
|
|
||||||
|
p, err := ParseControlFile(content)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, p)
|
||||||
|
assert.Equal(t, "realpkg", p.Name)
|
||||||
|
assert.Equal(t, "1.0.0", p.Version)
|
||||||
|
assert.NotContains(t, p.Control, "openssl")
|
||||||
|
assert.NotContains(t, p.Control, "evil.deb")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-20
@@ -14,6 +14,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
@@ -197,32 +198,38 @@ func loadLoginNotificationFrom(cfg ConfigProvider) {
|
|||||||
|
|
||||||
func loadRunModeFrom(rootCfg ConfigProvider) {
|
func loadRunModeFrom(rootCfg ConfigProvider) {
|
||||||
rootSec := rootCfg.Section("")
|
rootSec := rootCfg.Section("")
|
||||||
|
mustNotRunAsRoot(rootSec)
|
||||||
|
|
||||||
|
runModeValue := os.Getenv("GITEA_RUN_MODE")
|
||||||
|
runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String())
|
||||||
|
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
|
||||||
|
IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future
|
||||||
|
RunMode = util.Iif(IsProd, "prod", "dev")
|
||||||
|
|
||||||
|
// there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser)
|
||||||
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
|
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustNotRunAsRoot(rootSec ConfigSection) {
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0
|
||||||
|
if mustRunAsRoot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches.
|
// The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches.
|
||||||
// Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
|
// Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
|
||||||
unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")
|
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config
|
||||||
unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
|
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var
|
||||||
RunMode = os.Getenv("GITEA_RUN_MODE")
|
|
||||||
if RunMode == "" {
|
|
||||||
RunMode = rootSec.Key("RUN_MODE").MustString("prod")
|
|
||||||
}
|
|
||||||
|
|
||||||
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
|
if !allowRunAsRoot {
|
||||||
RunMode = strings.ToLower(RunMode)
|
// Special thanks to VLC which inspired the wording of this messaging.
|
||||||
if RunMode != "dev" {
|
log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.")
|
||||||
RunMode = "prod"
|
|
||||||
}
|
|
||||||
IsProd = RunMode != "dev"
|
|
||||||
|
|
||||||
// check if we run as root
|
|
||||||
if os.Getuid() == 0 {
|
|
||||||
if !unsafeAllowRunAsRoot {
|
|
||||||
// Special thanks to VLC which inspired the wording of this messaging.
|
|
||||||
log.Fatal("Gitea is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission")
|
|
||||||
}
|
|
||||||
log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
|
|
||||||
}
|
}
|
||||||
|
log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
|
// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
|
||||||
|
|||||||
@@ -165,9 +165,28 @@ type IssueStatusDef struct {
|
|||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ClosesIssue bool `json:"closes_issue"`
|
ClosesIssue bool `json:"closes_issue"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatusPresetEntry represents a single status in a preset template.
|
||||||
|
// swagger:model
|
||||||
|
type StatusPresetEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ClosesIssue bool `json:"closes_issue"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusPreset represents a named status preset template.
|
||||||
|
// swagger:model
|
||||||
|
type StatusPreset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Statuses []*StatusPresetEntry `json:"statuses"`
|
||||||
|
}
|
||||||
|
|
||||||
// IssuePriorityDef represents an org-level issue priority definition
|
// IssuePriorityDef represents an org-level issue priority definition
|
||||||
// swagger:model
|
// swagger:model
|
||||||
type IssuePriorityDef struct {
|
type IssuePriorityDef struct {
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ type BranchProtection struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
||||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"`
|
||||||
|
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
@@ -93,7 +99,13 @@ type CreateBranchProtectionOption struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
ForcePushAllowlistDeployKeys bool `json:"force_push_allowlist_deploy_keys"`
|
||||||
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
ForcePushAllowlistActionsUser bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistDeployKeys bool `json:"delete_allowlist_deploy_keys"`
|
||||||
|
DeleteAllowlistActionsUser bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
@@ -129,7 +141,13 @@ type EditBranchProtectionOption struct {
|
|||||||
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
ForcePushAllowlistUsernames []string `json:"force_push_allowlist_usernames"`
|
||||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||||
ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"`
|
ForcePushAllowlistDeployKeys *bool `json:"force_push_allowlist_deploy_keys"`
|
||||||
ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"`
|
ForcePushAllowlistActionsUser *bool `json:"force_push_allowlist_actions_user"`
|
||||||
|
EnableDelete *bool `json:"enable_delete"`
|
||||||
|
EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"`
|
||||||
|
DeleteAllowlistUsernames []string `json:"delete_allowlist_usernames"`
|
||||||
|
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||||
|
DeleteAllowlistDeployKeys *bool `json:"delete_allowlist_deploy_keys"`
|
||||||
|
DeleteAllowlistActionsUser *bool `json:"delete_allowlist_actions_user"`
|
||||||
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||||
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CascadeMergeRule represents a cascade merge rule
|
||||||
|
type CascadeMergeRule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SourceBranch string `json:"source_branch"`
|
||||||
|
TargetBranch string `json:"target_branch"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AutoMerge bool `json:"auto_merge"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCascadeMergeRuleOption options for creating a cascade merge rule
|
||||||
|
type CreateCascadeMergeRuleOption struct {
|
||||||
|
SourceBranch string `json:"source_branch" binding:"Required"`
|
||||||
|
TargetBranch string `json:"target_branch" binding:"Required"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
AutoMerge *bool `json:"auto_merge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditCascadeMergeRuleOption options for editing a cascade merge rule
|
||||||
|
type EditCascadeMergeRuleOption struct {
|
||||||
|
SourceBranch *string `json:"source_branch"`
|
||||||
|
TargetBranch *string `json:"target_branch"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
AutoMerge *bool `json:"auto_merge"`
|
||||||
|
}
|
||||||
@@ -40,6 +40,16 @@ type CreateAccessTokenOption struct {
|
|||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditAccessTokenOption options when editing access token scopes
|
||||||
|
// swagger:model EditAccessTokenOption
|
||||||
|
type EditAccessTokenOption struct {
|
||||||
|
// The new name for the token (optional)
|
||||||
|
Name string `json:"name"`
|
||||||
|
// The new scopes for the token
|
||||||
|
// example: ["read:repository", "write:issue"]
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
|
||||||
type CreateOAuth2ApplicationOptions struct {
|
type CreateOAuth2ApplicationOptions struct {
|
||||||
// The name of the OAuth2 application
|
// The name of the OAuth2 application
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
||||||
|
// a bare URL in the subject must not hijack the default link
|
||||||
|
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
|
||||||
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
||||||
|
// a URL embedded in larger subject text still becomes its own link
|
||||||
|
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
|
||||||
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("RenderIssueTitle", func(t *testing.T) {
|
t.Run("RenderIssueTitle", func(t *testing.T) {
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||||
expected := ` space @mention-user<SPACE><SPACE>
|
expected := ` space @mention-user<SPACE><SPACE>
|
||||||
|
|||||||
@@ -855,6 +855,8 @@
|
|||||||
"settings.access_token_deletion_confirm_action": "Delete",
|
"settings.access_token_deletion_confirm_action": "Delete",
|
||||||
"settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?",
|
"settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?",
|
||||||
"settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.",
|
"settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.",
|
||||||
|
"settings.edit_token_scopes": "Edit Token Scopes",
|
||||||
|
"settings.update_token_success": "Token scopes have been updated successfully.",
|
||||||
"settings.repo_and_org_access": "Repository and Organization Access",
|
"settings.repo_and_org_access": "Repository and Organization Access",
|
||||||
"settings.permissions_public_only": "Public only",
|
"settings.permissions_public_only": "Public only",
|
||||||
"settings.permissions_access_all": "All (public, private, and limited)",
|
"settings.permissions_access_all": "All (public, private, and limited)",
|
||||||
@@ -2437,6 +2439,17 @@
|
|||||||
"repo.settings.protect_force_push_allowlist_teams": "Allowlisted teams for force pushing:",
|
"repo.settings.protect_force_push_allowlist_teams": "Allowlisted teams for force pushing:",
|
||||||
"repo.settings.protect_force_push_allowlist_deploy_keys": "Allowlist deploy keys with push access to force push.",
|
"repo.settings.protect_force_push_allowlist_deploy_keys": "Allowlist deploy keys with push access to force push.",
|
||||||
"repo.settings.protect_force_push_allowlist_actions_user": "Allowlist actions bot user to force push.",
|
"repo.settings.protect_force_push_allowlist_actions_user": "Allowlist actions bot user to force push.",
|
||||||
|
"repo.settings.event_delete": "Branch Deletion",
|
||||||
|
"repo.settings.protect_disable_delete": "Disable Deletion",
|
||||||
|
"repo.settings.protect_disable_delete_desc": "This branch cannot be deleted.",
|
||||||
|
"repo.settings.protect_enable_delete_all": "Enable Deletion",
|
||||||
|
"repo.settings.protect_enable_delete_all_desc": "Anyone with admin access will be allowed to delete this branch.",
|
||||||
|
"repo.settings.protect_enable_delete_allowlist": "Allowlist Restricted Deletion",
|
||||||
|
"repo.settings.protect_enable_delete_allowlist_desc": "Only allowlisted users or teams will be allowed to delete this branch.",
|
||||||
|
"repo.settings.protect_delete_allowlist_users": "Allowlisted users for deletion:",
|
||||||
|
"repo.settings.protect_delete_allowlist_teams": "Allowlisted teams for deletion:",
|
||||||
|
"repo.settings.protect_delete_allowlist_deploy_keys": "Allowlist deploy keys with write access to delete.",
|
||||||
|
"repo.settings.protect_delete_allowlist_actions_user": "Allowlist actions bot user to delete.",
|
||||||
"repo.settings.protect_merge_whitelist_committers": "Enable Merge Allowlist",
|
"repo.settings.protect_merge_whitelist_committers": "Enable Merge Allowlist",
|
||||||
"repo.settings.protect_merge_whitelist_committers_desc": "Allow only allowlisted users or teams to merge pull requests into this branch.",
|
"repo.settings.protect_merge_whitelist_committers_desc": "Allow only allowlisted users or teams to merge pull requests into this branch.",
|
||||||
"repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:",
|
"repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:",
|
||||||
@@ -2996,6 +3009,11 @@
|
|||||||
"org.settings.issue_status_created": "Issue status created.",
|
"org.settings.issue_status_created": "Issue status created.",
|
||||||
"org.settings.issue_status_updated": "Issue status updated.",
|
"org.settings.issue_status_updated": "Issue status updated.",
|
||||||
"org.settings.issue_status_deleted": "Issue status deleted.",
|
"org.settings.issue_status_deleted": "Issue status deleted.",
|
||||||
|
"org.settings.issue_status_presets": "Status Presets",
|
||||||
|
"org.settings.issue_status_presets_desc": "Apply a preset template to replace your current statuses. Required statuses (Open/Closed) are preserved; others are deactivated and replaced.",
|
||||||
|
"org.settings.issue_status_preset_apply": "Apply Preset",
|
||||||
|
"org.settings.issue_status_preset_confirm": "This will deactivate your current custom statuses and replace them with the selected preset. Required statuses are preserved. Continue?",
|
||||||
|
"org.settings.issue_status_preset_applied": "Status preset applied successfully.",
|
||||||
"org.settings.issue_priorities": "Issue Priorities",
|
"org.settings.issue_priorities": "Issue Priorities",
|
||||||
"org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.",
|
"org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.",
|
||||||
"org.settings.issue_priorities_empty": "No custom issue priorities defined yet.",
|
"org.settings.issue_priorities_empty": "No custom issue priorities defined yet.",
|
||||||
|
|||||||
@@ -261,16 +261,32 @@ func (s *Service) UpdateLog(
|
|||||||
}
|
}
|
||||||
ack := task.LogLength
|
ack := task.LogLength
|
||||||
|
|
||||||
if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack {
|
// Trim rows the runner already had acked.
|
||||||
|
var rows []*runnerv1.LogRow
|
||||||
|
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack {
|
||||||
|
rows = req.Msg.Rows[ack-req.Msg.Index:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ack a re-sent finalize idempotently. Appending new rows past the seal errors.
|
||||||
|
if task.LogInStorage {
|
||||||
|
if len(rows) > 0 {
|
||||||
|
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
||||||
|
}
|
||||||
res.Msg.AckIndex = ack
|
res.Msg.AckIndex = ack
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.LogInStorage {
|
// Bail unless we have new rows or a NoMore to finalize. Even with
|
||||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
// NoMore, bail when the runner has outrun the server — archiving a
|
||||||
|
// log with a gap is worse than asking it to retry.
|
||||||
|
if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) {
|
||||||
|
res.Msg.AckIndex = ack
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := req.Msg.Rows[ack-req.Msg.Index:]
|
// WriteLogs is called even with no rows: with offset==0 it bootstraps
|
||||||
|
// an empty DBFS file so TransferLogs below has something to read when
|
||||||
|
// the runner finalizes a task that produced no log output.
|
||||||
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
|
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
|
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
|
||||||
|
|||||||
+138
-44
@@ -294,6 +294,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
|
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case auth_model.AccessTokenScopeCategoryLicensing:
|
||||||
|
ctx.APIError(http.StatusForbidden, "token scope is limited to public resources, licensing is not available")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,41 +508,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqTeamMembership user should be an team member, or a site admin
|
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||||
|
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||||
|
return func(ctx *context.APIContext) {
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||||
|
if ctx.IsUserSiteAdmin() {
|
||||||
|
return 0, true, true
|
||||||
|
}
|
||||||
|
if ctx.Org.Team == nil {
|
||||||
|
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID = ctx.Org.Team.OrgID
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
} else if isOwner {
|
||||||
|
return orgID, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return orgID, isTeamMember, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||||
|
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
} else if isOrgMember {
|
||||||
|
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||||
|
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||||
|
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||||
|
return func(ctx *context.APIContext) {
|
||||||
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
|
if !ok || privileged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqTeamMembership user should be a team member, or a site admin
|
||||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
if ctx.IsUserSiteAdmin() {
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
return
|
if !ok || privileged {
|
||||||
}
|
|
||||||
if ctx.Org.Team == nil {
|
|
||||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
|
||||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
orgID := ctx.Org.Team.OrgID
|
|
||||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
} else if isOwner {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
} else if !isTeamMember {
|
|
||||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
} else if isOrgMember {
|
|
||||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,7 +1045,9 @@ func Routes() *web.Router {
|
|||||||
m.Group("/tokens", func() {
|
m.Group("/tokens", func() {
|
||||||
m.Combo("").Get(user.ListAccessTokens).
|
m.Combo("").Get(user.ListAccessTokens).
|
||||||
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
|
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
|
||||||
m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken)
|
m.Combo("/{id}").
|
||||||
|
Patch(bind(api.EditAccessTokenOption{}), reqToken(), user.UpdateAccessToken).
|
||||||
|
Delete(reqToken(), user.DeleteAccessToken)
|
||||||
}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
|
}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
|
||||||
|
|
||||||
m.Get("/activities/feeds", user.ListUserActivityFeeds)
|
m.Get("/activities/feeds", user.ListUserActivityFeeds)
|
||||||
@@ -1257,6 +1300,23 @@ func Routes() *web.Router {
|
|||||||
})
|
})
|
||||||
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
||||||
}, reqToken(), reqAdmin())
|
}, reqToken(), reqAdmin())
|
||||||
|
m.Group("/cascade_rules", func() {
|
||||||
|
m.Get("", repo.ListCascadeRules)
|
||||||
|
m.Post("", mustNotBeArchived, repo.CreateCascadeRule)
|
||||||
|
m.Group("/{id}", func() {
|
||||||
|
m.Get("", repo.GetCascadeRule)
|
||||||
|
m.Patch("", mustNotBeArchived, repo.EditCascadeRule)
|
||||||
|
m.Delete("", mustNotBeArchived, repo.DeleteCascadeRule)
|
||||||
|
})
|
||||||
|
}, reqToken(), reqAdmin())
|
||||||
|
m.Group("/security", func() {
|
||||||
|
m.Get("/alerts", repo.ListSecurityAlerts)
|
||||||
|
m.Get("/alerts/{id}", repo.GetSecurityAlert)
|
||||||
|
m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert)
|
||||||
|
m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan)
|
||||||
|
m.Get("/config", repo.GetSecurityConfig)
|
||||||
|
m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig)
|
||||||
|
}, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true))
|
||||||
m.Group("/tags", func() {
|
m.Group("/tags", func() {
|
||||||
m.Get("", repo.ListTags)
|
m.Get("", repo.ListTags)
|
||||||
m.Get("/*", repo.GetTag)
|
m.Get("/*", repo.GetTag)
|
||||||
@@ -1314,6 +1374,7 @@ func Routes() *web.Router {
|
|||||||
m.Get("/revisions/*", repo.ListPageRevisions)
|
m.Get("/revisions/*", repo.ListPageRevisions)
|
||||||
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage)
|
||||||
m.Get("/pages", repo.ListWikiPages)
|
m.Get("/pages", repo.ListWikiPages)
|
||||||
|
m.Get("/search", repo.SearchWikiPages)
|
||||||
}, mustEnableWiki)
|
}, mustEnableWiki)
|
||||||
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
|
||||||
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
|
||||||
@@ -1480,12 +1541,10 @@ func Routes() *web.Router {
|
|||||||
Delete(reqToken(), repo.DeleteTopic)
|
Delete(reqToken(), repo.DeleteTopic)
|
||||||
}, reqAdmin())
|
}, reqAdmin())
|
||||||
}, reqAnyRepoReader())
|
}, reqAnyRepoReader())
|
||||||
m.Combo("/metadata", reqRepoReader(unit.TypeCode)).
|
m.Get("/metadata", repo.GetRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
m.Get("/manifest", repo.GetRepoMetadata) // backward compat
|
||||||
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat
|
m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
||||||
Get(repo.GetRepoMetadata).
|
|
||||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
|
|
||||||
// MokoGitea badge engine
|
// MokoGitea badge engine
|
||||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||||
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||||
@@ -1741,7 +1800,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||||
})
|
}, reqOrgVisible())
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Combo("").Get(org.ListHooks).
|
m.Combo("").Get(org.ListHooks).
|
||||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||||
@@ -1778,6 +1837,11 @@ func Routes() *web.Router {
|
|||||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||||
})
|
})
|
||||||
m.Get("/issue-statuses", org.ListIssueStatuses)
|
m.Get("/issue-statuses", org.ListIssueStatuses)
|
||||||
|
m.Group("/issue-statuses", func() {
|
||||||
|
m.Get("/presets", org.ListIssueStatusPresets)
|
||||||
|
m.Post("/presets/{preset}", reqToken(), reqOrgOwnership(), org.ApplyIssueStatusPreset)
|
||||||
|
m.Post("/copy/{source_org}", reqToken(), reqOrgOwnership(), org.CopyIssueStatusesFromOrg)
|
||||||
|
})
|
||||||
m.Get("/issue-priorities", org.ListIssuePriorities)
|
m.Get("/issue-priorities", org.ListIssuePriorities)
|
||||||
m.Get("/issue-types", org.ListIssueTypes)
|
m.Get("/issue-types", org.ListIssueTypes)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||||
@@ -1795,12 +1859,12 @@ func Routes() *web.Router {
|
|||||||
m.Group("/repos", func() {
|
m.Group("/repos", func() {
|
||||||
m.Get("", reqToken(), org.GetTeamRepos)
|
m.Get("", reqToken(), org.GetTeamRepos)
|
||||||
m.Combo("/{org}/{reponame}").
|
m.Combo("/{org}/{reponame}").
|
||||||
Put(reqToken(), org.AddTeamRepository).
|
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||||
Delete(reqToken(), org.RemoveTeamRepository).
|
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
|
||||||
Get(reqToken(), org.GetTeamRepo)
|
Get(reqToken(), org.GetTeamRepo)
|
||||||
})
|
})
|
||||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
|
||||||
|
|
||||||
m.Group("/admin", func() {
|
m.Group("/admin", func() {
|
||||||
m.Group("/cron", func() {
|
m.Group("/cron", func() {
|
||||||
@@ -1860,10 +1924,40 @@ func Routes() *web.Router {
|
|||||||
m.Get("/search", repo.TopicSearch)
|
m.Get("/search", repo.TopicSearch)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
||||||
|
|
||||||
// Licensing endpoints — DLID-gated, no token required
|
// Licensing endpoints
|
||||||
m.Group("/licensing", func() {
|
m.Group("/licensing", func() {
|
||||||
|
// Public (no auth)
|
||||||
m.Get("/updates/{product}", licensing.ServeUpdates)
|
m.Get("/updates/{product}", licensing.ServeUpdates)
|
||||||
})
|
m.Get("/validate", licensing.Validate)
|
||||||
|
m.Get("/download/{product}/{version}", licensing.ServeDownload)
|
||||||
|
|
||||||
|
// User self-service (authenticated)
|
||||||
|
m.Group("/my", func() {
|
||||||
|
m.Get("/licenses", licensing.MyLicenses)
|
||||||
|
m.Get("/licenses/{id}/domains", licensing.MyLicenseDomains)
|
||||||
|
m.Delete("/licenses/{id}/domains/{domain}", licensing.MyDeactivateDomain)
|
||||||
|
}, reqToken())
|
||||||
|
|
||||||
|
// Admin license management
|
||||||
|
m.Group("/licenses", func() {
|
||||||
|
m.Get("", licensing.ListLicenses)
|
||||||
|
m.Post("", licensing.CreateLicense)
|
||||||
|
m.Get("/{id}", licensing.GetLicense)
|
||||||
|
m.Patch("/{id}", licensing.UpdateLicense)
|
||||||
|
m.Delete("/{id}", licensing.DeleteLicense)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Admin tier management
|
||||||
|
m.Group("/tiers", func() {
|
||||||
|
m.Get("", licensing.ListTiers)
|
||||||
|
m.Post("", licensing.CreateTier)
|
||||||
|
m.Patch("/{id}", licensing.UpdateTier)
|
||||||
|
m.Delete("/{id}", licensing.DeleteTier)
|
||||||
|
}, reqToken(), reqSiteAdmin())
|
||||||
|
|
||||||
|
// Authenticated license detail
|
||||||
|
m.Get("/{dlid}/status", reqToken(), licensing.Status)
|
||||||
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryLicensing))
|
||||||
}, sudo())
|
}, sudo())
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeDownload handles GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ
|
||||||
|
func ServeDownload(ctx *context.APIContext) {
|
||||||
|
product := ctx.PathParam("product")
|
||||||
|
versionFile := ctx.PathParam("version")
|
||||||
|
token := ctx.FormString("token")
|
||||||
|
expiresStr := ctx.FormString("expires")
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
|
||||||
|
version, ok := licensing_service.ParseDownloadParams(versionFile)
|
||||||
|
if !ok {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid version format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expires, ok := licensing_service.ParseExpires(expiresStr)
|
||||||
|
if !ok || token == "" || dlid == "" {
|
||||||
|
ctx.APIError(http.StatusForbidden, "missing or invalid download parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signed token
|
||||||
|
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
|
||||||
|
ctx.APIError(http.StatusForbidden, "invalid or expired download token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DLID is still valid
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil || license == nil || !license.IsActive() {
|
||||||
|
ctx.APIError(http.StatusForbidden, "license invalid or expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entitlement
|
||||||
|
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if !has {
|
||||||
|
ctx.APIError(http.StatusForbidden, "no entitlement for product")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve repo from entitlement
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to get entitlements")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var repoOwner, repoName string
|
||||||
|
for _, ent := range ents {
|
||||||
|
if ent.ProductCode == product {
|
||||||
|
repoOwner = ent.RepoOwner
|
||||||
|
repoName = ent.RepoName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repoName == "" {
|
||||||
|
ctx.APIError(http.StatusNotFound, "product repo not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find repo
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
|
||||||
|
if err != nil || repo == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, "repository not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the release with matching version
|
||||||
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
ListOptions: db.ListOptionsAll,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
IncludeTags: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list releases")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRelease *repo_model.Release
|
||||||
|
for _, rel := range releases {
|
||||||
|
relVersion := extractVersion(rel.TagName)
|
||||||
|
if relVersion == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rel.Title != "" && extractVersion(rel.Title) == version {
|
||||||
|
targetRelease = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetRelease == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, fmt.Sprintf("release version %s not found", version))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ZIP attachment
|
||||||
|
var attachments []*repo_model.Attachment
|
||||||
|
err = db.GetEngine(ctx).Where("release_id = ?", targetRelease.ID).Find(&attachments)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to get attachments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipAttachment *repo_model.Attachment
|
||||||
|
for _, att := range attachments {
|
||||||
|
if att.Name != "" && len(att.Name) > 4 && att.Name[len(att.Name)-4:] == ".zip" {
|
||||||
|
zipAttachment = att
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zipAttachment == nil {
|
||||||
|
ctx.APIError(http.StatusNotFound, "no zip attachment found for release")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the download
|
||||||
|
licensing_model.LogLicenseAudit(ctx, license.ID, "download",
|
||||||
|
product, fmt.Sprintf("%s/%s", version, zipAttachment.Name))
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
fr, err := storage.Attachments.Open(zipAttachment.RelativePath())
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to open attachment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fr.Close()
|
||||||
|
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "application/zip")
|
||||||
|
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipAttachment.Name))
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if _, err := io.Copy(ctx.Resp, fr); err != nil {
|
||||||
|
log.Error("ServeDownload: io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
mojo_json "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Admin: License CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type createLicenseRequest struct {
|
||||||
|
UserID int64 `json:"user_id" binding:"Required"`
|
||||||
|
Tier string `json:"tier" binding:"Required"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
ExpiresMonths int `json:"expires_months"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLicense handles POST /api/v1/licensing/licenses
|
||||||
|
func CreateLicense(ctx *context.APIContext) {
|
||||||
|
var req createLicenseRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve max_domains from tier if not specified
|
||||||
|
maxDomains := req.MaxDomains
|
||||||
|
if maxDomains == 0 {
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, req.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
maxDomains = tier.MaxDomains
|
||||||
|
}
|
||||||
|
if maxDomains == 0 {
|
||||||
|
maxDomains = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt timeutil.TimeStamp
|
||||||
|
if req.ExpiresMonths > 0 {
|
||||||
|
expiresAt = timeutil.TimeStamp(time.Now().AddDate(0, req.ExpiresMonths, 0).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.CreateLicense(ctx, req.UserID, req.Tier, maxDomains, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to create license")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Notes != "" {
|
||||||
|
license.Notes = req.Notes
|
||||||
|
// TODO: update notes field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build entitlements from tier
|
||||||
|
if err := licensing_model.RebuildEntitlements(ctx, license.ID, req.Tier); err != nil {
|
||||||
|
log.Error("CreateLicense: RebuildEntitlements: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenses handles GET /api/v1/licensing/licenses
|
||||||
|
func ListLicenses(ctx *context.APIContext) {
|
||||||
|
page := ctx.FormInt("page")
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, get all licenses (pagination via offset)
|
||||||
|
// TODO: add proper pagination to the model layer
|
||||||
|
var licenses []*licensing_model.License
|
||||||
|
err := db.GetEngine(ctx).Limit(limit, (page-1)*limit).Find(&licenses)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicense handles GET /api/v1/licensing/licenses/{id}
|
||||||
|
func GetLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := licenseToJSON(ctx, license)
|
||||||
|
|
||||||
|
// Include entitlements
|
||||||
|
ents, _ := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
entList := make([]map[string]any, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
entList = append(entList, map[string]any{
|
||||||
|
"product_code": e.ProductCode,
|
||||||
|
"repo_owner": e.RepoOwner,
|
||||||
|
"repo_name": e.RepoName,
|
||||||
|
"is_custom": e.IsCustom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["entitlements"] = entList
|
||||||
|
|
||||||
|
// Include activations
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
actList := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
actList = append(actList, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"ip_address": a.IPAddress,
|
||||||
|
"joomla_ver": a.JoomlaVer,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result["activations"] = actList
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateLicenseRequest struct {
|
||||||
|
Tier *string `json:"tier"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLicense handles PATCH /api/v1/licensing/licenses/{id}
|
||||||
|
func UpdateLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateLicenseRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tier != nil && *req.Tier != license.Tier {
|
||||||
|
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to update tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Tier = *req.Tier
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil && *req.Status != license.Status {
|
||||||
|
if err := licensing_model.SetLicenseStatus(ctx, id, *req.Status); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to update status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
license.Status = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update simple fields directly
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
license.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.Notes != nil {
|
||||||
|
license.Notes = *req.Notes
|
||||||
|
cols = append(cols, "notes")
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||||
|
if err == nil {
|
||||||
|
license.ExpiresAt = timeutil.TimeStamp(t.Unix())
|
||||||
|
cols = append(cols, "expires_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cols) > 0 {
|
||||||
|
cols = append(cols, "updated_at")
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(license); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLicense handles DELETE /api/v1/licensing/licenses/{id}
|
||||||
|
func DeleteLicense(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to revoke license")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User: Self-service ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// MyLicenses handles GET /api/v1/licensing/my/licenses
|
||||||
|
func MyLicenses(ctx *context.APIContext) {
|
||||||
|
licenses, err := licensing_model.GetLicensesByUser(ctx, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(licenses))
|
||||||
|
for _, l := range licenses {
|
||||||
|
results = append(results, licenseToJSON(ctx, l))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyLicenseDomains handles GET /api/v1/licensing/my/licenses/{id}/domains
|
||||||
|
func MyLicenseDomains(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list domains")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(acts))
|
||||||
|
for _, a := range acts {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"domain": a.Domain,
|
||||||
|
"activated_at": formatTime(a.ActivatedAt),
|
||||||
|
"last_seen_at": formatTime(a.LastSeenAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyDeactivateDomain handles DELETE /api/v1/licensing/my/licenses/{id}/domains/{domain}
|
||||||
|
func MyDeactivateDomain(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid license ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByID(ctx, id)
|
||||||
|
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := ctx.PathParam("domain")
|
||||||
|
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to deactivate domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Product Tier CRUD ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListTiers handles GET /api/v1/licensing/tiers
|
||||||
|
func ListTiers(ctx *context.APIContext) {
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to list tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
results = append(results, tierToJSON(t))
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTierRequest struct {
|
||||||
|
TierKey string `json:"tier_key" binding:"Required"`
|
||||||
|
TierName string `json:"tier_name" binding:"Required"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains int `json:"max_domains"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTier handles POST /api/v1/licensing/tiers
|
||||||
|
func CreateTier(ctx *context.APIContext) {
|
||||||
|
var req createTierRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := mojo_json.Marshal(req.Repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: req.TierKey,
|
||||||
|
TierName: req.TierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: req.MaxDomains,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.GetEngine(ctx).Insert(tier)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusInternalServerError, "failed to create tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTierRequest struct {
|
||||||
|
TierName *string `json:"tier_name"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
MaxDomains *int `json:"max_domains"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTier handles PATCH /api/v1/licensing/tiers/{id}
|
||||||
|
func UpdateTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if err != nil || !has {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateTierRequest
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := make([]string, 0)
|
||||||
|
if req.TierName != nil {
|
||||||
|
tier.TierName = *req.TierName
|
||||||
|
cols = append(cols, "tier_name")
|
||||||
|
}
|
||||||
|
if req.Repos != nil {
|
||||||
|
reposJSON, _ := mojo_json.Marshal(req.Repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
cols = append(cols, "repos")
|
||||||
|
}
|
||||||
|
if req.MaxDomains != nil {
|
||||||
|
tier.MaxDomains = *req.MaxDomains
|
||||||
|
cols = append(cols, "max_domains")
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
tier.SortOrder = *req.SortOrder
|
||||||
|
cols = append(cols, "sort_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cols) > 0 {
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, tierToJSON(tier))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTier handles DELETE /api/v1/licensing/tiers/{id}
|
||||||
|
func DeleteTier(ctx *context.APIContext) {
|
||||||
|
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any licenses use this tier
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.APIError(http.StatusConflict, "cannot delete tier with active licenses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier)); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func licenseToJSON(ctx *context.APIContext, l *licensing_model.License) map[string]any {
|
||||||
|
tierName := l.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, l.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, l.ID)
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"id": l.ID,
|
||||||
|
"user_id": l.UserID,
|
||||||
|
"dlid": l.DLID,
|
||||||
|
"tier": l.Tier,
|
||||||
|
"tier_name": tierName,
|
||||||
|
"max_domains": l.MaxDomains,
|
||||||
|
"domains_used": domainCount,
|
||||||
|
"status": l.Status,
|
||||||
|
"notes": l.Notes,
|
||||||
|
"created_at": formatTime(l.CreatedAt),
|
||||||
|
"updated_at": formatTime(l.UpdatedAt),
|
||||||
|
}
|
||||||
|
if l.ExpiresAt > 0 {
|
||||||
|
result["expires_at"] = formatTime(l.ExpiresAt)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierToJSON(t *licensing_model.ProductTier) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"tier_key": t.TierKey,
|
||||||
|
"tier_name": t.TierName,
|
||||||
|
"repos": t.RepoList(),
|
||||||
|
"max_domains": t.MaxDomains,
|
||||||
|
"sort_order": t.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(ts timeutil.TimeStamp) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Joomla update XML structures.
|
// Joomla update XML structures.
|
||||||
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
|
|||||||
displayName = manifest.DerivedDisplayName()
|
displayName = manifest.DerivedDisplayName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build download URL
|
// Build signed download URL
|
||||||
baseURL := setting.AppURL
|
baseURL := setting.AppURL
|
||||||
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s",
|
token, expires := licensing_service.SignDownloadToken(productCode, version, dlid)
|
||||||
baseURL, productCode, version, dlid)
|
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s&token=%s&expires=%d",
|
||||||
|
baseURL, productCode, version, dlid, token, expires)
|
||||||
|
|
||||||
updates := xmlUpdates{
|
updates := xmlUpdates{
|
||||||
Updates: []xmlUpdate{
|
Updates: []xmlUpdate{
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateResponse is the public validation result.
|
||||||
|
type validateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
TierName string `json:"tier_name,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
DomainsUsed int `json:"domains_used,omitempty"`
|
||||||
|
DomainsMax int `json:"domains_max,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the full license detail for authenticated callers.
|
||||||
|
type statusResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
DLID string `json:"dlid"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
DomainsUsed int `json:"domains_used"`
|
||||||
|
DomainsMax int `json:"domains_max"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
|
||||||
|
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
|
||||||
|
func Validate(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.FormString("dlid")
|
||||||
|
product := ctx.FormString("product")
|
||||||
|
domain := ctx.FormString("domain")
|
||||||
|
|
||||||
|
if dlid == "" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.Status == "revoked" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.Status == "suspended" {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license.IsExpired() {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product entitlement if product is specified
|
||||||
|
if product != "" {
|
||||||
|
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Validate: HasEntitlement: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain limit if domain is specified
|
||||||
|
if domain != "" {
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
|
||||||
|
// Check if this domain is already activated
|
||||||
|
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
|
||||||
|
found := false
|
||||||
|
for _, a := range acts {
|
||||||
|
if a.Domain == domain {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := validateResponse{
|
||||||
|
Valid: true,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status handles GET /api/v1/licensing/{dlid}/status
|
||||||
|
// Authenticated endpoint — returns full license detail with entitlement list.
|
||||||
|
func Status(ctx *context.APIContext) {
|
||||||
|
dlid := ctx.PathParam("dlid")
|
||||||
|
|
||||||
|
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetLicenseByDLID: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if license == nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entitlements
|
||||||
|
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Status: GetEntitlementsByLicense: %v", err)
|
||||||
|
}
|
||||||
|
products := make([]string, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
products = append(products, e.ProductCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tier name
|
||||||
|
tierName := license.Tier
|
||||||
|
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
|
||||||
|
if tier != nil {
|
||||||
|
tierName = tier.TierName
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
|
||||||
|
|
||||||
|
resp := statusResponse{
|
||||||
|
Valid: license.IsActive(),
|
||||||
|
DLID: license.DLID,
|
||||||
|
Tier: license.Tier,
|
||||||
|
TierName: tierName,
|
||||||
|
Status: license.Status,
|
||||||
|
Products: products,
|
||||||
|
DomainsUsed: int(domainCount),
|
||||||
|
DomainsMax: license.MaxDomains,
|
||||||
|
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if license.ExpiresAt > 0 {
|
||||||
|
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -6,11 +6,26 @@ package org
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
|
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkOrgVisibility returns true if the current user can view org metadata.
|
||||||
|
// Public orgs are visible to everyone. Private/limited orgs require authentication.
|
||||||
|
func checkOrgVisibility(ctx *context.APIContext) bool {
|
||||||
|
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ctx.Doer == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssueStatuses returns active issue status definitions for an org.
|
// ListIssueStatuses returns active issue status definitions for an org.
|
||||||
func ListIssueStatuses(ctx *context.APIContext) {
|
func ListIssueStatuses(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
|
||||||
@@ -34,6 +49,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -47,6 +66,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
|
|||||||
Color: d.Color,
|
Color: d.Color,
|
||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
ClosesIssue: d.ClosesIssue,
|
ClosesIssue: d.ClosesIssue,
|
||||||
|
IsRequired: d.IsRequired,
|
||||||
SortOrder: d.SortOrder,
|
SortOrder: d.SortOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,6 +96,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -118,6 +142,10 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if !checkOrgVisibility(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
@@ -136,3 +164,124 @@ func ListIssueTypes(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, result)
|
ctx.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListIssueStatusPresets returns the available status preset templates.
|
||||||
|
func ListIssueStatusPresets(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/issue-statuses/presets organization orgListIssueStatusPresets
|
||||||
|
// ---
|
||||||
|
// summary: List available issue status presets
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: "StatusPresetList"
|
||||||
|
|
||||||
|
result := make([]*api.StatusPreset, 0, len(issues_model.StatusPresetNames()))
|
||||||
|
for _, name := range issues_model.StatusPresetNames() {
|
||||||
|
preset := issues_model.StatusPresets[name]
|
||||||
|
statuses := make([]*api.StatusPresetEntry, 0, len(preset.Statuses))
|
||||||
|
for _, s := range preset.Statuses {
|
||||||
|
statuses = append(statuses, &api.StatusPresetEntry{
|
||||||
|
Name: s.Name,
|
||||||
|
Color: s.Color,
|
||||||
|
Description: s.Description,
|
||||||
|
ClosesIssue: s.ClosesIssue,
|
||||||
|
IsRequired: s.IsRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result = append(result, &api.StatusPreset{
|
||||||
|
Name: preset.Name,
|
||||||
|
Description: preset.Description,
|
||||||
|
Statuses: statuses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyIssueStatusPreset applies a status preset to an organization.
|
||||||
|
func ApplyIssueStatusPreset(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /orgs/{org}/issue-statuses/presets/{preset} organization orgApplyIssueStatusPreset
|
||||||
|
// ---
|
||||||
|
// summary: Apply a status preset to an organization
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: preset
|
||||||
|
// in: path
|
||||||
|
// description: preset name
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// description: "StatusPresetApplied"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
presetName := ctx.PathParam("preset")
|
||||||
|
if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil {
|
||||||
|
if db.IsErrNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyIssueStatusesFromOrg copies status definitions from another organization.
|
||||||
|
func CopyIssueStatusesFromOrg(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /orgs/{org}/issue-statuses/copy/{source_org} organization orgCopyIssueStatuses
|
||||||
|
// ---
|
||||||
|
// summary: Copy issue statuses from another organization
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: target organization name
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: source_org
|
||||||
|
// in: path
|
||||||
|
// description: source organization name to copy from
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// description: "StatusesCopied"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
sourceOrgName := ctx.PathParam("source_org")
|
||||||
|
sourceOrg, err := org_model.GetOrgByName(ctx, sourceOrgName)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceOrg.Visibility != api.VisibleTypePublic && !ctx.Doer.IsAdmin {
|
||||||
|
isMember, err := org_model.IsOrganizationMember(ctx, sourceOrg.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1081,6 +1081,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
} else if errors.Is(err, util.ErrPermissionDenied) {
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.APIError(http.StatusForbidden, err)
|
ctx.APIError(http.StatusForbidden, err)
|
||||||
|
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
} else {
|
} else {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -693,6 +693,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteAllowlistUsers, err := user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if user_model.IsErrUserNotExist(err) {
|
if user_model.IsErrUserNotExist(err) {
|
||||||
@@ -711,7 +720,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -731,6 +740,15 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false)
|
||||||
|
if err != nil {
|
||||||
|
if organization.IsErrTeamNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if organization.IsErrTeamNotExist(err) {
|
if organization.IsErrTeamNotExist(err) {
|
||||||
@@ -763,6 +781,10 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
||||||
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
|
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
|
||||||
ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser,
|
ForcePushAllowlistActionsUser: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistActionsUser,
|
||||||
|
CanDelete: form.EnableDelete,
|
||||||
|
EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist,
|
||||||
|
DeleteAllowlistDeployKeys: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistDeployKeys,
|
||||||
|
DeleteAllowlistActionsUser: form.EnableDelete && form.EnableDeleteAllowlist && form.DeleteAllowlistActionsUser,
|
||||||
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
||||||
MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser,
|
MergeWhitelistActionsUser: form.EnableMergeWhitelist && form.MergeWhitelistActionsUser,
|
||||||
EnableStatusCheck: form.EnableStatusCheck,
|
EnableStatusCheck: form.EnableStatusCheck,
|
||||||
@@ -785,6 +807,8 @@ func CreateBranchProtection(ctx *context.APIContext) {
|
|||||||
TeamIDs: whitelistTeams,
|
TeamIDs: whitelistTeams,
|
||||||
ForcePushUserIDs: forcePushAllowlistUsers,
|
ForcePushUserIDs: forcePushAllowlistUsers,
|
||||||
ForcePushTeamIDs: forcePushAllowlistTeams,
|
ForcePushTeamIDs: forcePushAllowlistTeams,
|
||||||
|
DeleteUserIDs: deleteAllowlistUsers,
|
||||||
|
DeleteTeamIDs: deleteAllowlistTeams,
|
||||||
MergeUserIDs: mergeWhitelistUsers,
|
MergeUserIDs: mergeWhitelistUsers,
|
||||||
MergeTeamIDs: mergeWhitelistTeams,
|
MergeTeamIDs: mergeWhitelistTeams,
|
||||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||||
@@ -911,6 +935,32 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.EnableDelete != nil {
|
||||||
|
if !*form.EnableDelete {
|
||||||
|
protectBranch.CanDelete = false
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
} else {
|
||||||
|
protectBranch.CanDelete = true
|
||||||
|
if form.EnableDeleteAllowlist != nil {
|
||||||
|
if !*form.EnableDeleteAllowlist {
|
||||||
|
protectBranch.EnableDeleteAllowlist = false
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = false
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = false
|
||||||
|
} else {
|
||||||
|
protectBranch.EnableDeleteAllowlist = true
|
||||||
|
if form.DeleteAllowlistDeployKeys != nil {
|
||||||
|
protectBranch.DeleteAllowlistDeployKeys = *form.DeleteAllowlistDeployKeys
|
||||||
|
}
|
||||||
|
if form.DeleteAllowlistActionsUser != nil {
|
||||||
|
protectBranch.DeleteAllowlistActionsUser = *form.DeleteAllowlistActionsUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if form.Priority != nil {
|
if form.Priority != nil {
|
||||||
protectBranch.Priority = *form.Priority
|
protectBranch.Priority = *form.Priority
|
||||||
}
|
}
|
||||||
@@ -977,7 +1027,7 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
|
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
|
var whitelistUsers, forcePushAllowlistUsers, deleteAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64
|
||||||
if form.PushWhitelistUsernames != nil {
|
if form.PushWhitelistUsernames != nil {
|
||||||
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
|
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1004,6 +1054,19 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
} else {
|
} else {
|
||||||
forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs
|
forcePushAllowlistUsers = protectBranch.ForcePushAllowlistUserIDs
|
||||||
}
|
}
|
||||||
|
if form.DeleteAllowlistUsernames != nil {
|
||||||
|
deleteAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.DeleteAllowlistUsernames, false)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteAllowlistUsers = protectBranch.DeleteAllowlistUserIDs
|
||||||
|
}
|
||||||
if form.MergeWhitelistUsernames != nil {
|
if form.MergeWhitelistUsernames != nil {
|
||||||
mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1031,7 +1094,7 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
|
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
if form.PushWhitelistTeams != nil {
|
if form.PushWhitelistTeams != nil {
|
||||||
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
|
||||||
@@ -1059,6 +1122,19 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
} else {
|
} else {
|
||||||
forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs
|
forcePushAllowlistTeams = protectBranch.ForcePushAllowlistTeamIDs
|
||||||
}
|
}
|
||||||
|
if form.DeleteAllowlistTeams != nil {
|
||||||
|
deleteAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.DeleteAllowlistTeams, false)
|
||||||
|
if err != nil {
|
||||||
|
if organization.IsErrTeamNotExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteAllowlistTeams = protectBranch.DeleteAllowlistTeamIDs
|
||||||
|
}
|
||||||
if form.MergeWhitelistTeams != nil {
|
if form.MergeWhitelistTeams != nil {
|
||||||
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1092,6 +1168,8 @@ func EditBranchProtection(ctx *context.APIContext) {
|
|||||||
TeamIDs: whitelistTeams,
|
TeamIDs: whitelistTeams,
|
||||||
ForcePushUserIDs: forcePushAllowlistUsers,
|
ForcePushUserIDs: forcePushAllowlistUsers,
|
||||||
ForcePushTeamIDs: forcePushAllowlistTeams,
|
ForcePushTeamIDs: forcePushAllowlistTeams,
|
||||||
|
DeleteUserIDs: deleteAllowlistUsers,
|
||||||
|
DeleteTeamIDs: deleteAllowlistTeams,
|
||||||
MergeUserIDs: mergeWhitelistUsers,
|
MergeUserIDs: mergeWhitelistUsers,
|
||||||
MergeTeamIDs: mergeWhitelistTeams,
|
MergeTeamIDs: mergeWhitelistTeams,
|
||||||
ApprovalsUserIDs: approvalsWhitelistUsers,
|
ApprovalsUserIDs: approvalsWhitelistUsers,
|
||||||
@@ -1287,6 +1365,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
|||||||
} else if errors.Is(err, util.ErrNotExist) {
|
} else if errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
return
|
return
|
||||||
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
|
ctx.APIError(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toCascadeRuleAPI(rule *repo_model.CascadeMergeRule) *api.CascadeMergeRule {
|
||||||
|
return &api.CascadeMergeRule{
|
||||||
|
ID: rule.ID,
|
||||||
|
SourceBranch: rule.SourceBranch,
|
||||||
|
TargetBranch: rule.TargetBranch,
|
||||||
|
Enabled: rule.Enabled,
|
||||||
|
AutoMerge: rule.AutoMerge,
|
||||||
|
CreatedAt: rule.CreatedUnix.AsTime(),
|
||||||
|
UpdatedAt: rule.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListCascadeRules(ctx *context.APIContext) {
|
||||||
|
rules, err := repo_model.GetCascadeRulesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiRules := make([]*api.CascadeMergeRule, len(rules))
|
||||||
|
for i, rule := range rules {
|
||||||
|
apiRules[i] = toCascadeRuleAPI(rule)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, apiRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCascadeRule(ctx *context.APIContext) {
|
||||||
|
var req api.CreateCascadeMergeRuleOption
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch == "" || req.TargetBranch == "" {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch == req.TargetBranch {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := &repo_model.CascadeMergeRule{
|
||||||
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
SourceBranch: req.SourceBranch,
|
||||||
|
TargetBranch: req.TargetBranch,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
rule.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.AutoMerge != nil {
|
||||||
|
rule.AutoMerge = *req.AutoMerge
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.CreateCascadeRule(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req api.EditCascadeMergeRuleOption
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SourceBranch != nil {
|
||||||
|
rule.SourceBranch = *req.SourceBranch
|
||||||
|
}
|
||||||
|
if req.TargetBranch != nil {
|
||||||
|
rule.TargetBranch = *req.TargetBranch
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
rule.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.AutoMerge != nil {
|
||||||
|
rule.AutoMerge = *req.AutoMerge
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.SourceBranch == rule.TargetBranch {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.UpdateCascadeRule(ctx, rule); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCascadeRule(ctx *context.APIContext) {
|
||||||
|
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.DeleteCascadeRule(ctx, ctx.Repo.Repository.ID, rule.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -32,6 +32,16 @@ type apiMetadata struct {
|
|||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
ExtensionType string `json:"extension_type"`
|
ExtensionType string `json:"extension_type"`
|
||||||
EntryPoint string `json:"entry_point"`
|
EntryPoint string `json:"entry_point"`
|
||||||
|
|
||||||
|
// deploy
|
||||||
|
DeployHost string `json:"deploy_host,omitempty"`
|
||||||
|
DeployPort string `json:"deploy_port,omitempty"`
|
||||||
|
DeployUser string `json:"deploy_user,omitempty"`
|
||||||
|
DeployPath string `json:"deploy_path,omitempty"`
|
||||||
|
DockerImage string `json:"docker_image,omitempty"`
|
||||||
|
DockerRegistry string `json:"docker_registry,omitempty"`
|
||||||
|
ContainerName string `json:"container_name,omitempty"`
|
||||||
|
HealthURL string `json:"health_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoMetadata returns the manifest settings for a repository.
|
// GetRepoMetadata returns the manifest settings for a repository.
|
||||||
@@ -81,6 +91,14 @@ func GetRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,35 +114,59 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/Manifest"
|
// "$ref": "#/responses/Manifest"
|
||||||
var req apiMetadata
|
// Decode into a map to detect which fields were actually sent.
|
||||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
var raw map[string]any
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
|
||||||
ctx.APIError(http.StatusBadRequest, err)
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &repo_model.RepoMetadata{
|
// Load existing metadata (or create defaults).
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
m, _ := repo_model.GetRepoMetadata(ctx, ctx.Repo.Repository.ID)
|
||||||
Name: req.Name,
|
if m == nil {
|
||||||
Org: req.Org,
|
m = &repo_model.RepoMetadata{
|
||||||
Description: req.Description,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
Name: ctx.Repo.Repository.Name,
|
||||||
LicenseSPDX: req.LicenseSPDX,
|
Org: ctx.Repo.Repository.OwnerName,
|
||||||
LicenseName: req.LicenseName,
|
Description: ctx.Repo.Repository.Description,
|
||||||
VersionPrefix: req.VersionPrefix,
|
}
|
||||||
ElementName: req.ElementName,
|
|
||||||
Platform: req.Platform,
|
|
||||||
StandardsVersion: req.StandardsVersion,
|
|
||||||
StandardsSource: req.StandardsSource,
|
|
||||||
Maintainer: req.Maintainer,
|
|
||||||
MaintainerURL: req.MaintainerURL,
|
|
||||||
InfoURL: req.InfoURL,
|
|
||||||
TargetVersion: req.TargetVersion,
|
|
||||||
PHPMinimum: req.PHPMinimum,
|
|
||||||
Language: req.Language,
|
|
||||||
ExtensionType: req.ExtensionType,
|
|
||||||
EntryPoint: req.EntryPoint,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply only the fields present in the request.
|
||||||
|
setStr := func(key string, target *string) {
|
||||||
|
if v, ok := raw[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
*target = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStr("name", &m.Name)
|
||||||
|
setStr("org", &m.Org)
|
||||||
|
setStr("description", &m.Description)
|
||||||
|
setStr("license_spdx", &m.LicenseSPDX)
|
||||||
|
setStr("license_name", &m.LicenseName)
|
||||||
|
setStr("version_prefix", &m.VersionPrefix)
|
||||||
|
setStr("element_name", &m.ElementName)
|
||||||
|
setStr("platform", &m.Platform)
|
||||||
|
setStr("standards_version", &m.StandardsVersion)
|
||||||
|
setStr("standards_source", &m.StandardsSource)
|
||||||
|
setStr("maintainer", &m.Maintainer)
|
||||||
|
setStr("maintainer_url", &m.MaintainerURL)
|
||||||
|
setStr("info_url", &m.InfoURL)
|
||||||
|
setStr("target_version", &m.TargetVersion)
|
||||||
|
setStr("php_minimum", &m.PHPMinimum)
|
||||||
|
setStr("language", &m.Language)
|
||||||
|
setStr("extension_type", &m.ExtensionType)
|
||||||
|
setStr("entry_point", &m.EntryPoint)
|
||||||
|
setStr("deploy_host", &m.DeployHost)
|
||||||
|
setStr("deploy_port", &m.DeployPort)
|
||||||
|
setStr("deploy_user", &m.DeployUser)
|
||||||
|
setStr("deploy_path", &m.DeployPath)
|
||||||
|
setStr("docker_image", &m.DockerImage)
|
||||||
|
setStr("docker_registry", &m.DockerRegistry)
|
||||||
|
setStr("container_name", &m.ContainerName)
|
||||||
|
setStr("health_url", &m.HealthURL)
|
||||||
|
|
||||||
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, m); err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -151,5 +193,13 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
|
|||||||
Language: m.Language,
|
Language: m.Language,
|
||||||
ExtensionType: m.ExtensionType,
|
ExtensionType: m.ExtensionType,
|
||||||
EntryPoint: m.EntryPoint,
|
EntryPoint: m.EntryPoint,
|
||||||
|
DeployHost: m.DeployHost,
|
||||||
|
DeployPort: m.DeployPort,
|
||||||
|
DeployUser: m.DeployUser,
|
||||||
|
DeployPath: m.DeployPath,
|
||||||
|
DockerImage: m.DockerImage,
|
||||||
|
DockerRegistry: m.DockerRegistry,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
HealthURL: m.HealthURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiSecurityAlert struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Scanner string `json:"scanner"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RuleID string `json:"rule_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
LineNumber int `json:"line_number,omitempty"`
|
||||||
|
CommitSHA string `json:"commit_sha,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiSecurityConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BlockOnPush bool `json:"block_on_push"`
|
||||||
|
SecretScanner bool `json:"secret_scanner"`
|
||||||
|
DependScanner bool `json:"depend_scanner"`
|
||||||
|
CodeScanner bool `json:"code_scanner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSecurityAlerts returns all security alerts for a repo.
|
||||||
|
func ListSecurityAlerts(ctx *context.APIContext) {
|
||||||
|
status := ctx.FormString("status")
|
||||||
|
repoID := ctx.Repo.Repository.ID
|
||||||
|
|
||||||
|
var alerts []*security_model.SecurityAlert
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "", "active":
|
||||||
|
alerts, err = security_model.GetActiveAlerts(ctx, repoID)
|
||||||
|
default:
|
||||||
|
alerts, err = security_model.GetAllAlerts(ctx, repoID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*apiSecurityAlert, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
result[i] = toAPISecurityAlert(a)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecurityAlert returns a single security alert.
|
||||||
|
func GetSecurityAlert(ctx *context.APIContext) {
|
||||||
|
id := ctx.PathParamInt64("id")
|
||||||
|
alert, err := security_model.GetAlertByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityAlert changes the status of a security alert.
|
||||||
|
func UpdateSecurityAlert(ctx *context.APIContext) {
|
||||||
|
id := ctx.PathParamInt64("id")
|
||||||
|
alert, err := security_model.GetAlertByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := security_model.AlertStatus(req.Status)
|
||||||
|
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "status must be 'resolved' or 'dismissed'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alert, _ = security_model.GetAlertByID(ctx, id)
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSecurityScan runs all enabled scanners against HEAD.
|
||||||
|
func TriggerSecurityScan(ctx *context.APIContext) {
|
||||||
|
commit := ctx.Repo.Commit
|
||||||
|
if commit == nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "no commits in repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
|
||||||
|
|
||||||
|
alerts, err := security_model.GetActiveAlerts(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*apiSecurityAlert, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
result[i] = toAPISecurityAlert(a)
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecurityConfig returns the scanner config for a repo.
|
||||||
|
func GetSecurityConfig(ctx *context.APIContext) {
|
||||||
|
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityConfig updates the scanner config for a repo.
|
||||||
|
func UpdateSecurityConfig(ctx *context.APIContext) {
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
BlockOnPush *bool `json:"block_on_push"`
|
||||||
|
SecretScanner *bool `json:"secret_scanner"`
|
||||||
|
DependScanner *bool `json:"depend_scanner"`
|
||||||
|
CodeScanner *bool `json:"code_scanner"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
cfg.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.BlockOnPush != nil {
|
||||||
|
cfg.BlockOnPush = *req.BlockOnPush
|
||||||
|
}
|
||||||
|
if req.SecretScanner != nil {
|
||||||
|
cfg.SecretScanner = *req.SecretScanner
|
||||||
|
}
|
||||||
|
if req.DependScanner != nil {
|
||||||
|
cfg.DependScanner = *req.DependScanner
|
||||||
|
}
|
||||||
|
if req.CodeScanner != nil {
|
||||||
|
cfg.CodeScanner = *req.CodeScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPISecurityAlert(a *security_model.SecurityAlert) *apiSecurityAlert {
|
||||||
|
return &apiSecurityAlert{
|
||||||
|
ID: a.ID,
|
||||||
|
Scanner: string(a.Scanner),
|
||||||
|
Severity: string(a.Severity),
|
||||||
|
Status: string(a.Status),
|
||||||
|
RuleID: a.RuleID,
|
||||||
|
Title: a.Title,
|
||||||
|
Description: a.Description,
|
||||||
|
FilePath: a.FilePath,
|
||||||
|
LineNumber: a.LineNumber,
|
||||||
|
CommitSHA: a.CommitSHA,
|
||||||
|
Fingerprint: a.Fingerprint,
|
||||||
|
CreatedAt: a.CreatedUnix.AsTime(),
|
||||||
|
UpdatedAt: a.UpdatedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurityConfig {
|
||||||
|
return &apiSecurityConfig{
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
BlockOnPush: cfg.BlockOnPush,
|
||||||
|
SecretScanner: cfg.SecretScanner,
|
||||||
|
DependScanner: cfg.DependScanner,
|
||||||
|
CodeScanner: cfg.CodeScanner,
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
@@ -461,10 +462,148 @@ func ListPageRevisions(ctx *context.APIContext) {
|
|||||||
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchWikiPages searches wiki page titles and content.
|
||||||
|
func SearchWikiPages(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/wiki/search repository repoSearchWikiPages
|
||||||
|
// ---
|
||||||
|
// summary: Search wiki pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: q
|
||||||
|
// in: query
|
||||||
|
// description: search query
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: "SearchResults"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
query := strings.TrimSpace(ctx.FormString("q"))
|
||||||
|
if query == "" {
|
||||||
|
ctx.JSON(http.StatusOK, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiRepo, commit := findWikiRepoCommit(ctx)
|
||||||
|
if wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
}
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
type WikiSearchResult struct {
|
||||||
|
PageName string `json:"page_name"`
|
||||||
|
PageURL string `json:"page_url"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := commit.ListEntriesRecursiveFast()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []WikiSearchResult
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsRegular() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseName := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
// Extract just the filename without path for special file check
|
||||||
|
parts := strings.Split(baseName, "/")
|
||||||
|
shortName := parts[len(parts)-1]
|
||||||
|
if shortName == "_Sidebar" || shortName == "_Footer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
titleMatch := strings.Contains(strings.ToLower(baseName), queryLower)
|
||||||
|
contentMatch := strings.Contains(strings.ToLower(content), queryLower)
|
||||||
|
|
||||||
|
if !titleMatch && !contentMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contextLine := ""
|
||||||
|
if contentMatch {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.Contains(strings.ToLower(line), queryLower) {
|
||||||
|
contextLine = strings.TrimSpace(line)
|
||||||
|
if len(contextLine) > 200 {
|
||||||
|
contextLine = contextLine[:200] + "..."
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
|
||||||
|
|
||||||
|
results = append(results, WikiSearchResult{
|
||||||
|
PageName: displayName,
|
||||||
|
PageURL: string(wikiName),
|
||||||
|
Context: contextLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = setting.API.DefaultPagingNum
|
||||||
|
}
|
||||||
|
total := len(results)
|
||||||
|
start := (page - 1) * limit
|
||||||
|
end := start + limit
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetLinkHeader(int64(total), limit)
|
||||||
|
ctx.SetTotalCountHeader(int64(total))
|
||||||
|
ctx.JSON(http.StatusOK, results[start:end])
|
||||||
|
}
|
||||||
|
|
||||||
// findEntryForFile finds the tree entry for a target filepath.
|
// findEntryForFile finds the tree entry for a target filepath.
|
||||||
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
||||||
entry, err := commit.GetTreeEntryByPath(target)
|
entry, err := commit.GetTreeEntryByPath(target)
|
||||||
if err != nil {
|
if err != nil && !git.IsErrNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
|
|||||||
@@ -209,6 +209,106 @@ func DeleteAccessToken(ctx *context.APIContext) {
|
|||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccessToken update access token scopes
|
||||||
|
func UpdateAccessToken(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /users/{username}/tokens/{id} user userUpdateAccessToken
|
||||||
|
// ---
|
||||||
|
// summary: Update an access token's scopes
|
||||||
|
// consumes:
|
||||||
|
// - application/json
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user whose token is to be updated
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the token to update
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/EditAccessTokenOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/AccessToken"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
tokenID, _ := strconv.ParseInt(ctx.PathParam("id"), 0, 64)
|
||||||
|
if tokenID == 0 {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
|
||||||
|
UserID: ctx.ContextUser.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token *auth_model.AccessToken
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t.ID == tokenID {
|
||||||
|
token = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == nil {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.EditAccessTokenOption)
|
||||||
|
|
||||||
|
if form.Name == "" && len(form.Scopes) == 0 {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "must provide name or scopes to update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Name != "" {
|
||||||
|
token.Name = form.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(form.Scopes) > 0 {
|
||||||
|
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIError(http.StatusBadRequest, fmt.Errorf("invalid access token scope: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scope == "" {
|
||||||
|
ctx.APIError(http.StatusBadRequest, "access token must have a scope")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.Scope = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth_model.UpdateAccessToken(ctx, token); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.AccessToken{
|
||||||
|
ID: token.ID,
|
||||||
|
Name: token.Name,
|
||||||
|
TokenLastEight: token.TokenLastEight,
|
||||||
|
Scopes: token.Scope.StringSlice(),
|
||||||
|
Created: token.CreatedUnix.AsTime(),
|
||||||
|
Updated: token.UpdatedUnix.AsTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
|
||||||
func CreateOauth2Application(ctx *context.APIContext) {
|
func CreateOauth2Application(ctx *context.APIContext) {
|
||||||
// swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application
|
// swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cascade"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron"
|
||||||
feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed"
|
feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed"
|
||||||
indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer"
|
indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer"
|
||||||
@@ -153,6 +154,7 @@ func InitWebInstalled(ctx context.Context) {
|
|||||||
mustInit(webhook.Init)
|
mustInit(webhook.Init)
|
||||||
mustInit(pull_service.Init)
|
mustInit(pull_service.Init)
|
||||||
mustInit(automerge.Init)
|
mustInit(automerge.Init)
|
||||||
|
cascade.Init()
|
||||||
mustInit(task.Init)
|
mustInit(task.Init)
|
||||||
mustInit(repo_migrations.Init)
|
mustInit(repo_migrations.Init)
|
||||||
eventsource.GetManager().Init()
|
eventsource.GetManager().Init()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit"
|
||||||
gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||||
|
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
type preReceiveContext struct {
|
type preReceiveContext struct {
|
||||||
@@ -40,9 +41,6 @@ type preReceiveContext struct {
|
|||||||
canCreatePullRequest bool
|
canCreatePullRequest bool
|
||||||
checkedCanCreatePullRequest bool
|
checkedCanCreatePullRequest bool
|
||||||
|
|
||||||
canWriteCode bool
|
|
||||||
checkedCanWriteCode bool
|
|
||||||
|
|
||||||
protectedTags []*git_model.ProtectedTag
|
protectedTags []*git_model.ProtectedTag
|
||||||
gotProtectedTags bool
|
gotProtectedTags bool
|
||||||
|
|
||||||
@@ -50,24 +48,36 @@ type preReceiveContext struct {
|
|||||||
|
|
||||||
opts *private.HookOptions
|
opts *private.HookOptions
|
||||||
|
|
||||||
branchName string
|
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||||
|
canWriteCodeUnitCached *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWriteCode returns true if pusher can write code
|
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
if ctx.canWriteCodeUnitCached == nil {
|
||||||
if !ctx.checkedCanWriteCode {
|
var canWrite bool
|
||||||
if !ctx.loadPusherAndPermission() {
|
if ctx.loadPusherAndPermission() {
|
||||||
return false
|
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||||
}
|
}
|
||||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
ctx.canWriteCodeUnitCached = &canWrite
|
||||||
ctx.checkedCanWriteCode = true
|
|
||||||
}
|
}
|
||||||
return ctx.canWriteCode
|
return *ctx.canWriteCodeUnitCached
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssertCanWriteCode returns true if pusher can write code
|
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
|
||||||
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
|
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||||
if !ctx.CanWriteCode() {
|
if ctx.canWriteCodeUnit() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
|
||||||
|
if !refFullName.IsBranch() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||||
|
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||||
|
if !ctx.canWriteCodeRef(refFullName) {
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,7 +139,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
||||||
preReceiveFor(ourCtx, refFullName)
|
preReceiveFor(ourCtx, refFullName)
|
||||||
default:
|
default:
|
||||||
ourCtx.AssertCanWriteCode()
|
ourCtx.assertCanWriteRef(refFullName)
|
||||||
}
|
}
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
@@ -141,9 +151,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
|
|
||||||
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
||||||
branchName := refFullName.BranchName()
|
branchName := refFullName.BranchName()
|
||||||
ctx.branchName = branchName
|
|
||||||
|
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +160,25 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
gitRepo := ctx.Repo.GitRepo
|
gitRepo := ctx.Repo.GitRepo
|
||||||
objectFormat := ctx.Repo.GetObjectFormat()
|
objectFormat := ctx.Repo.GetObjectFormat()
|
||||||
|
|
||||||
|
if newCommitID != objectFormat.EmptyObjectID().String() {
|
||||||
|
newCommit, err := gitRepo.GetCommit(newCommitID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Secret scan: failed to get commit %s in %-v: %v", newCommitID[:12], repo, err)
|
||||||
|
} else {
|
||||||
|
if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 {
|
||||||
|
msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12])
|
||||||
|
for _, f := range findings {
|
||||||
|
msg += fmt.Sprintf("\n - %s in %s:%d", f.Title, f.FilePath, f.LineNumber)
|
||||||
|
}
|
||||||
|
log.Warn("Secret scan blocked push to %s in %-v: %d findings", branchName, repo, len(findings))
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: msg,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultBranch := repo.DefaultBranch
|
defaultBranch := repo.DefaultBranch
|
||||||
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
|
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
|
||||||
defaultBranch = repo.DefaultWikiBranch
|
defaultBranch = repo.DefaultWikiBranch
|
||||||
@@ -182,12 +210,29 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
//
|
//
|
||||||
// First of all we need to enforce absolutely:
|
// First of all we need to enforce absolutely:
|
||||||
//
|
//
|
||||||
// 1. Detect and prevent deletion of the branch
|
// 1. Detect and prevent deletion of the branch (unless user is in delete allowlist)
|
||||||
if newCommitID == objectFormat.EmptyObjectID().String() {
|
if newCommitID == objectFormat.EmptyObjectID().String() {
|
||||||
log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
|
canDelete := false
|
||||||
ctx.JSON(http.StatusForbidden, private.Response{
|
if ctx.opts.DeployKeyID != 0 {
|
||||||
UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
|
canDelete = protectBranch.CanDelete && (!protectBranch.EnableDeleteAllowlist || protectBranch.DeleteAllowlistDeployKeys)
|
||||||
})
|
} else {
|
||||||
|
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to GetUserByID for delete check in %-v: %v", repo, err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||||
|
Err: fmt.Sprintf("Unable to GetUserByID: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canDelete = protectBranch.CanUserDelete(ctx, user)
|
||||||
|
}
|
||||||
|
if !canDelete {
|
||||||
|
log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
|
||||||
|
ctx.JSON(http.StatusForbidden, private.Response{
|
||||||
|
UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +449,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package private
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||||
|
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/contexttest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||||
|
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||||
|
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||||
|
// together with a protected branch or a tag to escalate into full repository write.
|
||||||
|
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||||
|
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||||
|
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||||
|
|
||||||
|
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||||
|
// repo owner write access to exactly this head branch and nothing else.
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: baseRepo.ID,
|
||||||
|
PosterID: headRepo.OwnerID,
|
||||||
|
},
|
||||||
|
HeadRepoID: headRepo.ID,
|
||||||
|
BaseRepoID: baseRepo.ID,
|
||||||
|
HeadBranch: "granted-branch",
|
||||||
|
BaseBranch: "master",
|
||||||
|
AllowMaintainerEdit: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||||
|
|
||||||
|
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||||
|
maintainer := baseRepo.Owner
|
||||||
|
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||||
|
ctx := &preReceiveContext{
|
||||||
|
PrivateContext: mockCtx,
|
||||||
|
loadedPusher: true,
|
||||||
|
user: maintainer,
|
||||||
|
userPerm: headPerm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The granted branch must be writable...
|
||||||
|
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||||
|
|
||||||
|
// ...but another branch in the same push must NOT inherit that grant.
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
|
||||||
|
|
||||||
|
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
|
||||||
|
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
|
||||||
|
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||||
|
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||||
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplLicenseTiers templates.TplName = "admin/license_tiers"
|
||||||
|
|
||||||
|
// LicenseTiers shows the product tier management page.
|
||||||
|
func LicenseTiers(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = "Product Tiers"
|
||||||
|
ctx.Data["PageIsAdminLicenseTiers"] = true
|
||||||
|
|
||||||
|
tiers, err := licensing_model.GetAllProductTiers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetAllProductTiers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierView struct {
|
||||||
|
*licensing_model.ProductTier
|
||||||
|
Repos []string
|
||||||
|
LicenseCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]tierView, 0, len(tiers))
|
||||||
|
for _, t := range tiers {
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", t.TierKey).Count(new(licensing_model.License))
|
||||||
|
views = append(views, tierView{
|
||||||
|
ProductTier: t,
|
||||||
|
Repos: t.RepoList(),
|
||||||
|
LicenseCount: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["Tiers"] = views
|
||||||
|
ctx.HTML(http.StatusOK, tplLicenseTiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierCreate handles POST to create a new tier.
|
||||||
|
func LicenseTierCreate(ctx *context.Context) {
|
||||||
|
tierKey := ctx.FormString("tier_key")
|
||||||
|
tierName := ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
maxDomains, _ := strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if tierKey == "" || tierName == "" {
|
||||||
|
ctx.Flash.Error("Tier key and name are required")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier := &licensing_model.ProductTier{
|
||||||
|
TierKey: tierKey,
|
||||||
|
TierName: tierName,
|
||||||
|
Repos: string(reposJSON),
|
||||||
|
MaxDomains: maxDomains,
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).Insert(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to create tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tierName + "' created")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierUpdate handles POST to update a tier.
|
||||||
|
func LicenseTierUpdate(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier.TierName = ctx.FormString("tier_name")
|
||||||
|
repos := ctx.FormStrings("repos")
|
||||||
|
reposJSON, _ := json.Marshal(repos)
|
||||||
|
tier.Repos = string(reposJSON)
|
||||||
|
tier.MaxDomains, _ = strconv.Atoi(ctx.FormString("max_domains"))
|
||||||
|
tier.SortOrder, _ = strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
|
|
||||||
|
if _, err := db.GetEngine(ctx).ID(id).Cols("tier_name", "repos", "max_domains", "sort_order").Update(tier); err != nil {
|
||||||
|
ctx.Flash.Error("Failed to update tier: " + err.Error())
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' updated")
|
||||||
|
}
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTierDelete handles POST to delete a tier.
|
||||||
|
func LicenseTierDelete(ctx *context.Context) {
|
||||||
|
id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64)
|
||||||
|
|
||||||
|
tier := new(licensing_model.ProductTier)
|
||||||
|
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
|
||||||
|
if !has {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
|
||||||
|
if count > 0 {
|
||||||
|
ctx.Flash.Error("Cannot delete tier with active licenses. Reassign licenses first.")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
|
||||||
|
ctx.Flash.Success("Tier '" + tier.TierName + "' deleted")
|
||||||
|
ctx.Redirect("/admin/license-tiers")
|
||||||
|
}
|
||||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the passcode with the stored TOTP secret.
|
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
if ok {
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
u, err := user_model.GetUserByID(ctx, id)
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = form.Passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -368,9 +368,21 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
|
|
||||||
opts := &user_service.UpdateOptions{}
|
opts := &user_service.UpdateOptions{}
|
||||||
|
|
||||||
// Reactivate user if they are deactivated
|
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
|
||||||
|
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||||
|
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||||
|
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||||
|
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||||
if !u.IsActive {
|
if !u.IsActive {
|
||||||
opts.IsActive = optional.Some(true)
|
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetExternalLogin", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
|
||||||
|
if isDisabledByAutoSync {
|
||||||
|
opts.IsActive = optional.Some(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update GroupClaims
|
// Update GroupClaims
|
||||||
@@ -514,17 +526,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// search in external linked users
|
// search in external linked users
|
||||||
externalLoginUser := &user_model.ExternalLoginUser{
|
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
ExternalID: gothUser.UserID,
|
|
||||||
LoginSourceID: authSource.ID,
|
|
||||||
}
|
|
||||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return nil, goth.User{}, err
|
||||||
}
|
}
|
||||||
if hasUser {
|
if hasUser {
|
||||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
||||||
return user, gothUser, err
|
if err != nil && !user_model.IsErrUserNotExist(err) {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
|
if err == nil && user.IsIndividual() {
|
||||||
|
return user, gothUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The external login record is stale: the linked user no longer exists, or it exists but is
|
||||||
|
// not an individual user (only individual users can sign in, so a link pointing at an
|
||||||
|
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
|
||||||
|
// relink the external account to the correct user. Nothing is lost, because the link is
|
||||||
|
// recreated automatically on the next sign-in.
|
||||||
|
reason := "linked user does not exist"
|
||||||
|
if err == nil {
|
||||||
|
reason = fmt.Sprintf("linked user type is %d", user.Type)
|
||||||
|
}
|
||||||
|
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
|
||||||
|
|
||||||
|
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found to login
|
// no user found to login
|
||||||
|
|||||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
regenerateScratchToken = true
|
regenerateScratchToken = true
|
||||||
} else {
|
} else {
|
||||||
passcode := ctx.FormString("passcode")
|
passcode := ctx.FormString("passcode")
|
||||||
ok, err := twofa.ValidateTOTP(passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || twofa.LastUsedPasscode == passcode {
|
if !ok {
|
||||||
ctx.Data["IsResetForm"] = true
|
ctx.Data["IsResetForm"] = true
|
||||||
ctx.Data["Err_Passcode"] = true
|
ctx.Data["Err_Passcode"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var commits []*git.Commit
|
var commits []*git.Commit
|
||||||
var err error
|
var err error
|
||||||
if ctx.Repo.Commit != nil {
|
if ctx.Repo.Commit != nil {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
fileName := ctx.Repo.TreePath
|
fileName := ctx.Repo.TreePath
|
||||||
if len(fileName) == 0 {
|
if len(fileName) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
// shows tags and/or releases on the repo as RSS / Atom feed
|
// shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
IncludeTags: !isReleasesOnly,
|
IncludeTags: !isReleasesOnly,
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
|||||||
@@ -4,9 +4,18 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
|
||||||
|
// feed serves private repository content, mirroring checkDownloadTokenScope for
|
||||||
|
// downloads. Returns false (and writes the response) when the token is denied.
|
||||||
|
func checkRepoFeedTokenScope(ctx *context.Context) bool {
|
||||||
|
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
|
||||||
|
return !ctx.Written()
|
||||||
|
}
|
||||||
|
|
||||||
// RenderBranchFeed render format for branch or file
|
// RenderBranchFeed render format for branch or file
|
||||||
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
||||||
if ctx.Repo.TreePath == "" {
|
if ctx.Repo.TreePath == "" {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
||||||
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||||
RequestedRepo: repo,
|
RequestedRepo: repo,
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
|
|||||||
@@ -27,9 +27,35 @@ func SettingsIssueStatuses(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["IssueStatuses"] = defs
|
ctx.Data["IssueStatuses"] = defs
|
||||||
|
|
||||||
|
// Load preset names for the preset selector
|
||||||
|
presetNames := issues_model.StatusPresetNames()
|
||||||
|
type presetInfo struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
presets := make([]presetInfo, 0, len(presetNames))
|
||||||
|
for _, name := range presetNames {
|
||||||
|
p := issues_model.StatusPresets[name]
|
||||||
|
presets = append(presets, presetInfo{Name: p.Name, Description: p.Description})
|
||||||
|
}
|
||||||
|
ctx.Data["StatusPresets"] = presets
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, tplOrgIssueStatuses)
|
ctx.HTML(http.StatusOK, tplOrgIssueStatuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingsIssueStatusesApplyPresetPost applies a status preset to the org.
|
||||||
|
func SettingsIssueStatusesApplyPresetPost(ctx *context.Context) {
|
||||||
|
presetName := ctx.FormString("preset")
|
||||||
|
if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil {
|
||||||
|
ctx.Flash.Error("Unknown preset: " + presetName)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("org.settings.issue_status_preset_applied"))
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
}
|
||||||
|
|
||||||
// SettingsIssueStatusesCreatePost creates a new org-level issue status.
|
// SettingsIssueStatusesCreatePost creates a new org-level issue status.
|
||||||
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
||||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||||
@@ -103,6 +129,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
||||||
|
if issues_model.IsErrStatusRequired(err) {
|
||||||
|
ctx.Flash.Error("Cannot delete required status: " + def.Name)
|
||||||
|
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("DeleteIssueStatusDef", err)
|
ctx.ServerError("DeleteIssueStatusDef", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
|
|||||||
SubURL string
|
SubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
|
||||||
|
type OrgWikiTreeNode struct {
|
||||||
|
Name string
|
||||||
|
SubURL string
|
||||||
|
IsDir bool
|
||||||
|
Children []*OrgWikiTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
// Wiki renders the org wiki tab.
|
// Wiki renders the org wiki tab.
|
||||||
func Wiki(ctx *context.Context) {
|
func Wiki(ctx *context.Context) {
|
||||||
org := ctx.Org.Organization
|
org := ctx.Org.Organization
|
||||||
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
|
||||||
|
|
||||||
// Build page list from repo root.
|
// Build folder tree for sidebar navigation.
|
||||||
entries, err := commit.ListEntries()
|
wikiTree := buildOrgWikiTree(commit)
|
||||||
if err != nil {
|
ctx.Data["WikiTree"] = wikiTree
|
||||||
ctx.ServerError("ListEntries", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pages := make([]OrgWikiPage, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if !isMarkdownFile(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
displayName := strings.TrimSuffix(name, path.Ext(name))
|
|
||||||
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages = append(pages, OrgWikiPage{
|
|
||||||
Name: displayName,
|
|
||||||
SubURL: displayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx.Data["Pages"] = pages
|
|
||||||
|
|
||||||
// Determine which page to render.
|
// Determine which page to render.
|
||||||
pageName := ctx.PathParamRaw("*")
|
pageName := ctx.PathParamRaw("*")
|
||||||
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
|
|||||||
ctx.HTML(http.StatusOK, tplOrgWiki)
|
ctx.HTML(http.StatusOK, tplOrgWiki)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
|
||||||
|
// Shows up to 2 levels deep (folders and their immediate children).
|
||||||
|
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevel []*OrgWikiTreeNode
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
node := &OrgWikiTreeNode{
|
||||||
|
Name: name,
|
||||||
|
SubURL: name,
|
||||||
|
IsDir: true,
|
||||||
|
}
|
||||||
|
// List children of this directory (1 level deep).
|
||||||
|
subTree := entry.Tree()
|
||||||
|
if subTree != nil {
|
||||||
|
children, _ := subTree.ListEntries()
|
||||||
|
for _, child := range children {
|
||||||
|
childName := child.Name()
|
||||||
|
if child.IsDir() {
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: childName,
|
||||||
|
SubURL: name + "/" + childName,
|
||||||
|
IsDir: true,
|
||||||
|
})
|
||||||
|
} else if isMarkdownFile(childName) {
|
||||||
|
displayName := strings.TrimSuffix(childName, path.Ext(childName))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.Children = append(node.Children, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: name + "/" + displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, node)
|
||||||
|
} else if isMarkdownFile(name) {
|
||||||
|
displayName := strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
topLevel = append(topLevel, &OrgWikiTreeNode{
|
||||||
|
Name: displayName,
|
||||||
|
SubURL: displayName,
|
||||||
|
IsDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topLevel
|
||||||
|
}
|
||||||
|
|
||||||
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
|
||||||
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
|
||||||
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func MergeUpstream(ctx *context.Context) {
|
|||||||
branchName := ctx.FormString("branch")
|
branchName := ctx.FormString("branch")
|
||||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.JSONErrorNotFound()
|
ctx.JSONErrorNotFound()
|
||||||
return
|
return
|
||||||
} else if pull_service.IsErrMergeConflicts(err) {
|
} else if pull_service.IsErrMergeConflicts(err) {
|
||||||
|
|||||||
+19
-28
@@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
|
|||||||
// httpBase does the common work for git http services,
|
// httpBase does the common work for git http services,
|
||||||
// including early response, authentication, repository lookup and permission check.
|
// including early response, authentication, repository lookup and permission check.
|
||||||
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||||
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
|
||||||
|
|
||||||
if ctx.FormString("go-get") == "1" {
|
if ctx.FormString("go-get") == "1" {
|
||||||
context.EarlyResponseForGoGetMeta(ctx)
|
context.EarlyResponseForGoGetMeta(ctx)
|
||||||
return nil
|
return nil
|
||||||
@@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
|
|
||||||
isWiki := false
|
isWiki := false
|
||||||
unitType := unit.TypeCode
|
unitType := unit.TypeCode
|
||||||
|
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||||
if strings.HasSuffix(reponame, ".wiki") {
|
if strings.HasSuffix(repoName, ".wiki") {
|
||||||
isWiki = true
|
isWiki = true
|
||||||
unitType = unit.TypeWiki
|
unitType = unit.TypeWiki
|
||||||
reponame = reponame[:len(reponame)-5]
|
repoName = repoName[:len(repoName)-5]
|
||||||
}
|
}
|
||||||
|
|
||||||
owner := ctx.ContextUser
|
owner := ctx.ContextUser
|
||||||
@@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repoExist := true
|
repoExist := true
|
||||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
|
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !repo_model.IsErrRepoNotExist(err) {
|
if !repo_model.IsErrRepoNotExist(err) {
|
||||||
ctx.ServerError("GetRepositoryByName", err)
|
ctx.ServerError("GetRepositoryByName", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil {
|
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil {
|
||||||
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -127,31 +125,24 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only public pull don't need auth.
|
// Only public pulls don't need auth: repo must exist, not require-sign-in
|
||||||
// For private repos, also allow anonymous pull if the specific unit
|
canAnonymousPull := false
|
||||||
// (code or wiki) has AnonymousAccessMode >= Read.
|
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
|
||||||
isPublicPull := repoExist && isPull && !repo.IsPrivate
|
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
|
||||||
if repoExist && isPull && repo.IsPrivate {
|
canAnonymousPull = true
|
||||||
repoUnit := repo.MustGetUnit(ctx, unitType)
|
|
||||||
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
|
|
||||||
isPublicPull = true
|
|
||||||
}
|
}
|
||||||
}
|
if !canAnonymousPull && ctx.Doer == nil {
|
||||||
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
|
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
|
||||||
|
if err != nil {
|
||||||
// don't allow anonymous pulls if organization is not public
|
ctx.ServerError("GetDoerRepoPermission", err)
|
||||||
if isPublicPull {
|
return nil
|
||||||
if err := repo.LoadOwner(ctx); err != nil {
|
}
|
||||||
ctx.ServerError("LoadOwner", err)
|
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check access
|
// check access
|
||||||
if askAuth {
|
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
|
||||||
// rely on the results of Contexter
|
|
||||||
if !ctx.IsSigned {
|
if !ctx.IsSigned {
|
||||||
// TODO: support digit auth - which would be Authorization header with digit
|
// TODO: support digit auth - which would be Authorization header with digit
|
||||||
if setting.OAuth2.Enabled {
|
if setting.OAuth2.Enabled {
|
||||||
@@ -237,7 +228,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame)
|
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("pushCreateRepo: %v", err)
|
log.Error("pushCreateRepo: %v", err)
|
||||||
ctx.Status(http.StatusNotFound)
|
ctx.Status(http.StatusNotFound)
|
||||||
|
|||||||
+29
-24
@@ -681,6 +681,8 @@ func indexCommit(commits []*git.Commit, commitID string) *git.Commit {
|
|||||||
|
|
||||||
// ViewPullFiles render pull request changed files list page
|
// ViewPullFiles render pull request changed files list page
|
||||||
func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
||||||
|
var err error
|
||||||
|
|
||||||
ctx.Data["PageIsPullList"] = true
|
ctx.Data["PageIsPullList"] = true
|
||||||
ctx.Data["PageIsPullFiles"] = true
|
ctx.Data["PageIsPullFiles"] = true
|
||||||
|
|
||||||
@@ -705,44 +707,47 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
|
|||||||
|
|
||||||
headCommitID := prCompareInfo.HeadCommitID
|
headCommitID := prCompareInfo.HeadCommitID
|
||||||
isSingleCommit := beforeCommitID == "" && afterCommitID != ""
|
isSingleCommit := beforeCommitID == "" && afterCommitID != ""
|
||||||
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
|
|
||||||
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
|
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
|
||||||
|
|
||||||
|
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
|
||||||
ctx.Data["IsShowingAllCommits"] = isShowAllCommits
|
ctx.Data["IsShowingAllCommits"] = isShowAllCommits
|
||||||
|
|
||||||
if afterCommitID == "" || afterCommitID == headCommitID {
|
afterCommitID = util.IfZero(afterCommitID, headCommitID)
|
||||||
afterCommitID = headCommitID
|
|
||||||
}
|
|
||||||
afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID)
|
afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID)
|
||||||
|
if afterCommit == nil && afterCommitID == headCommitID {
|
||||||
|
afterCommit, err = gitRepo.GetCommit(afterCommitID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetCommit(afterCommitID)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if afterCommit == nil {
|
if afterCommit == nil {
|
||||||
ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits")
|
ctx.NotFound(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var beforeCommit *git.Commit
|
var beforeCommit *git.Commit
|
||||||
var err error
|
if isSingleCommit {
|
||||||
if !isSingleCommit {
|
|
||||||
if beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase {
|
|
||||||
beforeCommitID = prCompareInfo.CompareBase
|
|
||||||
// merge base commit is not in the list of the pull request commits
|
|
||||||
beforeCommit, err = gitRepo.GetCommit(beforeCommitID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetCommit", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID)
|
|
||||||
if beforeCommit == nil {
|
|
||||||
ctx.HTTPError(http.StatusBadRequest, "before commit not found in PR commits")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
beforeCommit, err = afterCommit.Parent(0)
|
beforeCommit, err = afterCommit.Parent(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("Parent", err)
|
ctx.ServerError("afterCommit.Parent", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
beforeCommitID = beforeCommit.ID.String()
|
beforeCommitID = beforeCommit.ID.String()
|
||||||
|
} else {
|
||||||
|
beforeCommitID = util.IfZero(beforeCommitID, prCompareInfo.CompareBase)
|
||||||
|
beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID)
|
||||||
|
if beforeCommit == nil && beforeCommitID == prCompareInfo.CompareBase {
|
||||||
|
beforeCommit, err = gitRepo.GetCommit(beforeCommitID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetCommit(beforeCommitID)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if beforeCommit == nil {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["CompareInfo"] = prCompareInfo
|
ctx.Data["CompareInfo"] = prCompareInfo
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user