Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Miller add7c0da4d feat: make metadata/manifest GET endpoint publicly accessible (#676)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
PR RC Release / Build RC Release (pull_request) Failing after 54s
Universal: PR Check / Secret Scan (pull_request) Successful in 54s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 42s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Publish to Composer / Publish Package (release) Failing after 5s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Remove reqRepoReader auth requirement from GET /repos/{owner}/{repo}/metadata
and /manifest endpoints. PUT (update) still requires token + admin.
2026-06-21 10:18:07 -05:00
143 changed files with 2350 additions and 7614 deletions
+5 -5
View File
@@ -57,7 +57,7 @@ jobs:
- name: Determine target repos - name: Determine target repos
id: repos id: repos
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_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 ${MOKOGITEA_TOKEN}" \ -H "Authorization: token ${GA_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:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_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 ${MOKOGITEA_TOKEN}" \ -H "Authorization: token ${GA_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 ${MOKOGITEA_TOKEN}" \ -H "Authorization: token ${GA_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 -1
View File
@@ -22,7 +22,7 @@ on:
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions: permissions:
contents: write contents: write
+15 -61
View File
@@ -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,24 +21,15 @@
# | 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, synchronize, closed] types: [opened, closed]
branches: branches:
- main - main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
action: action:
@@ -52,7 +43,7 @@ on:
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_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 }}
@@ -60,13 +51,12 @@ 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:
@@ -102,7 +92,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 "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ --api-base "${GITEA_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
@@ -121,7 +111,7 @@ jobs:
- name: Update RC release notes from CHANGELOG.md - name: Update RC release notes from CHANGELOG.md
run: | run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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
@@ -159,7 +149,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
@@ -251,50 +241,14 @@ jobs:
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT" [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT" echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_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 "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}" 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="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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)
@@ -363,7 +317,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="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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" \
@@ -392,7 +346,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="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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)
@@ -416,7 +370,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="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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}"
@@ -437,7 +391,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="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_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
@@ -463,5 +417,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](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/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
fi fi
-6
View File
@@ -13,12 +13,6 @@
name: "Generic: Project CI" name: "Generic: Project CI"
on: on:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -1,68 +0,0 @@
# 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 }}"
+10 -10
View File
@@ -21,7 +21,7 @@ permissions:
contents: write contents: write
env: env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_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.MOKOGITEA_TOKEN }} token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches - name: Delete merged branches
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Merged Branch Cleanup ===" echo "=== Merged Branch Cleanup ==="
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API # List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \ BRANCHES=$(curl -sS -H "Authorization: token ${GA_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 ${MOKOGITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_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:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Workflow Run Cleanup ===" echo "=== Workflow Run Cleanup ==="
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_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 ${MOKOGITEA_TOKEN}" \ RUNS=$(curl -sS -H "Authorization: token ${GA_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 ${MOKOGITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_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
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
@@ -1,10 +0,0 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
-109
View File
@@ -1,109 +0,0 @@
# 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"
+4 -4
View File
@@ -42,10 +42,10 @@ jobs:
- name: Setup MokoStandards tools - name: Setup MokoStandards tools
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}' COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_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" \
@@ -39,17 +39,6 @@ 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:
+4 -4
View File
@@ -19,7 +19,7 @@ permissions:
issues: write issues: write
env: env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_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.MOKOGITEA_TOKEN }}" TOKEN="${{ secrets.GA_TOKEN }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_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="${MOKOGITEA_URL}/${{ github.repository }}" REPO_URL="${GITEA_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 \
+51
View File
@@ -0,0 +1,51 @@
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 }}
+31 -28
View File
@@ -47,15 +47,15 @@ jobs:
fi fi
;; ;;
fix/*|bugfix/*) fix/*|bugfix/*)
if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ]; then if [ "$BASE" != "dev" ]; then
ALLOWED=false ALLOWED=false
REASON="Fix branches must target 'main' or 'dev', not '${BASE}'" REASON="Fix branches must target 'dev', not '${BASE}'"
fi fi
;; ;;
patch/*) patch/*)
if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false ALLOWED=false
REASON="Patch branches must target 'main', 'dev', or 'rc', not '${BASE}'" REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi fi
;; ;;
hotfix/*) hotfix/*)
@@ -86,11 +86,10 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`main\` or \`dev\`" >> $GITHUB_STEP_SUMMARY echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`main\`, \`dev\`, or \`rc\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1 exit 1
fi fi
@@ -147,21 +146,12 @@ jobs:
- name: Detect platform - name: Detect platform
id: platform id: platform
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
REPO: ${{ github.repository }}
run: | run: |
# Query metadata API for platform (manifest.xml is deprecated) # Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM="" PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
if [ -n "$MOKOGITEA_TOKEN" ]; then [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
PLATFORM=$(curl -sf -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${MOKOGITEA_URL}/api/v1/repos/${REPO}/metadata" 2>/dev/null \
| sed -n 's/.*"platform"\s*:\s*"\([^"]*\)".*/\1/p' | head -1) || true
fi
[ -z "$PLATFORM" ] && PLATFORM="generic" [ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP - name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
@@ -506,26 +496,39 @@ jobs:
steps: steps:
- name: Trigger RC pre-release - name: Trigger RC pre-release
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }} BRANCH: ${{ github.head_ref }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: | run: |
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ────────────────────────────────────────────────────── # ── Issue Reporter ──────────────────────────────────────────────────────
report-issues: report-issues:
name: Report Issues name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate] needs: [branch-policy, validate]
if: >- if: >-
always() && always() &&
needs.validate.result == 'failure' needs.validate.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
steps:
- name: Checkout
uses: actions/checkout@v4
with: with:
gate: "PR Validation" sparse-checkout: automation/ci-issue-reporter.sh
workflow: "PR Check" sparse-checkout-cone-mode: false
severity: error
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." - name: "File issue for PR validation failure"
secrets: inherit env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+1 -22
View File
@@ -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.02.00 # VERSION: 05.01.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"
@@ -59,11 +59,6 @@ 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:
@@ -93,20 +88,8 @@ 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
@@ -183,7 +166,6 @@ 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 }}"
@@ -194,7 +176,6 @@ 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 }}"
@@ -231,7 +212,6 @@ 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 }}"
@@ -245,7 +225,6 @@ 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}"
+13 -18
View File
@@ -29,20 +29,12 @@ 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: |
set -euo pipefail BRANCH="${{ github.event.pull_request.head.ref }}"
# 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="${GITEA_URL}/api/v1/repos/${REPO}/branches" API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/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 \
@@ -50,22 +42,25 @@ 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})"; exit 1 echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi fi
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection) # Delete rc/ branch
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));') ENCODED=$(php -r "echo rawurlencode('${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
+36 -24
View File
@@ -77,7 +77,7 @@ jobs:
- name: Check actor permission (admin only) - name: Check actor permission (admin only)
id: perm id: perm
env: env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }} ACTOR: ${{ github.actor }}
run: | run: |
@@ -671,30 +671,42 @@ jobs:
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates # Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
report-scripts: report-issues:
name: "Report: Scripts Governance" name: "Report Issues"
needs: [access_check, scripts_governance] runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
if: >- if: >-
always() && always() &&
needs.scripts_governance.result == 'failure' (needs.scripts_governance.result == 'failure' ||
uses: ./.mokogitea/workflows/ci-issue-reporter.yml needs.repo_health.result == 'failure')
with:
gate: "Scripts Governance"
workflow: "Repo Health"
severity: error
details: "Scripts directory policy violations detected. Review required and allowed directories."
secrets: inherit
report-health: steps:
name: "Report: Repository Health" - name: Checkout
needs: [access_check, repo_health] uses: actions/checkout@v4
if: >-
always() &&
needs.repo_health.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with: with:
gate: "Repository Health" sparse-checkout: automation/ci-issue-reporter.sh
workflow: "Repo Health" sparse-checkout-cone-mode: false
severity: error
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." - name: "File issues for failed gates"
secrets: inherit env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+82
View File
@@ -0,0 +1,82 @@
# 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
-130
View File
@@ -1,130 +0,0 @@
# 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
+4 -12
View File
@@ -13,7 +13,6 @@
name: "Universal: Workflow Sync Trigger" name: "Universal: Workflow Sync Trigger"
on: on:
workflow_dispatch:
pull_request: pull_request:
types: [closed] types: [closed]
branches: branches:
@@ -27,9 +26,8 @@ jobs:
name: Sync workflows to live repos name: Sync workflows to live repos
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true &&
(github.event.pull_request.merged == true && !contains(github.event.pull_request.title, '[skip sync]')
!contains(github.event.pull_request.title, '[skip sync]'))
steps: steps:
- name: Determine platform from repo name - name: Determine platform from repo name
@@ -51,14 +49,8 @@ jobs:
env: env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: | run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}" GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli git clone --depth 1 "${GITEA_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 - name: Install dependencies
run: | run: |
-77
View File
@@ -3,28 +3,6 @@
## [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
@@ -32,61 +10,6 @@
- 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
+23 -21
View File
@@ -1,36 +1,38 @@
# MokoGitea # MokoGitea
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API. Moko fork of Gitea — adding project board REST API endpoints and custom enhancements
![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) ![Wiki](https://img.shields.io/badge/wiki-MokoGitea-blue?style=flat-square)
Custom Gitea fork with Project Board API
--- ---
## Key Features ## Pages
- **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)) - [Branding](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Branding)
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads - [Deployment](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/Deployment)
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation - [Project API](Project API)
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration - [roadmap](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki/roadmap)
- **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
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning **Category:** Infrastructure | **Platform:** [MokoPlatform wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki)
- **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
- [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs Full documentation is available on the [Wiki](https://code.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki).
- [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 [org wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) for development guidelines, coding standards, and contribution instructions. See the wiki for development guidelines and contribution instructions.
## License ## License
@@ -38,4 +40,4 @@ This project is licensed under the GNU General Public License v3.0 or later -- s
--- ---
*[Moko Consulting](https://mokoconsulting.tech)* *[Moko Consulting](https://mokoconsulting.tech) -- [MokoStandards](https://code.mokoconsulting.tech/MokoConsulting/MokoPlatform/wiki/Home)*
+237
View File
@@ -0,0 +1,237 @@
#!/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
+9 -14
View File
@@ -113,25 +113,23 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
return nil return nil
} }
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with func getAccessMode(verb, lfsVerb string) perm.AccessMode {
// 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, true return perm.AccessModeRead
case git.CmdVerbReceivePack: case git.CmdVerbReceivePack:
return perm.AccessModeWrite, true return perm.AccessModeWrite
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer: case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
switch lfsVerb { switch lfsVerb {
case git.CmdSubVerbLfsUpload: case git.CmdSubVerbLfsUpload:
return perm.AccessModeWrite, true return perm.AccessModeWrite
case git.CmdSubVerbLfsDownload: case git.CmdSubVerbLfsDownload:
return perm.AccessModeRead, true return perm.AccessModeRead
} }
} }
return perm.AccessModeNone, false // should be unreachable
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 {
@@ -249,10 +247,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
} }
} }
requestedMode, ok := getAccessMode(verb, lfsVerb) requestedMode := 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() {
-56
View File
@@ -1,56 +0,0 @@
// 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)
})
}
}
+2
View File
@@ -51,6 +51,8 @@ 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
+2
View File
@@ -48,6 +48,8 @@ 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
+4
View File
@@ -64,6 +64,7 @@ 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
@@ -80,6 +81,9 @@ 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))
} }
+2 -16
View File
@@ -24,7 +24,6 @@ const (
AccessTokenScopeCategoryIssue AccessTokenScopeCategoryIssue
AccessTokenScopeCategoryRepository AccessTokenScopeCategoryRepository
AccessTokenScopeCategoryUser AccessTokenScopeCategoryUser
AccessTokenScopeCategoryLicensing
) )
// AllAccessTokenScopeCategories contains all access token scope categories // AllAccessTokenScopeCategories contains all access token scope categories
@@ -38,7 +37,6 @@ 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
@@ -84,9 +82,6 @@ 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.
@@ -98,8 +93,7 @@ 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
@@ -130,9 +124,6 @@ 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.
@@ -151,7 +142,6 @@ 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.
@@ -176,8 +166,6 @@ 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
@@ -192,7 +180,6 @@ 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,
@@ -204,7 +191,6 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A
AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue,
AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository,
AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser,
AccessTokenScopeCategoryLicensing: AccessTokenScopeWriteLicensing,
}, },
} }
@@ -384,7 +370,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:licensing", "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user",
"all", "all",
)) ))
return scope return scope
+3 -3
View File
@@ -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", "licensing", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories()) assert.Equal(t, []string{"activitypub", "admin", "issue", "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,write:licensing", "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,public-only", "public-only,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},
} }
for _, scope := range GetAccessTokenCategories() { for _, scope := range GetAccessTokenCategories() {
+4 -28
View File
@@ -21,7 +21,6 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
) )
// //
@@ -105,43 +104,20 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil return nil
} }
// validateTOTP validates the provided passcode. It does not consume the passcode; all login // ValidateTOTP validates the provided passcode.
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice. func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
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)
-47
View File
@@ -1,47 +0,0 @@
// 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)
}
+2 -5
View File
@@ -196,10 +196,7 @@ 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
} }
// LFS objects are repository code content, so authorization must require cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
// 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
} }
@@ -223,7 +220,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.TypeCode)), builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
) )
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
+4 -71
View File
@@ -51,12 +51,6 @@ 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"`
@@ -200,46 +194,6 @@ 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.
@@ -411,9 +365,6 @@ 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.
@@ -479,18 +430,6 @@ 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.
@@ -635,15 +574,14 @@ 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, lenDeleteIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs), len(p.DeleteAllowlistUserIDs) lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs, lenDeleteTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs), len(p.DeleteAllowlistTeamIDs) lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
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 {
@@ -651,19 +589,16 @@ 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)
} }
@@ -678,7 +613,6 @@ 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)
} }
@@ -690,7 +624,6 @@ 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)
} }
+6 -237
View File
@@ -22,7 +22,6 @@ 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'"`
@@ -33,211 +32,6 @@ 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
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
@@ -262,15 +56,14 @@ 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: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true}, {OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
{OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true}, {OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
{OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true}, {OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
{OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true}, {OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, 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 {
@@ -318,37 +111,13 @@ 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
} }
-8
View File
@@ -10,7 +10,6 @@ 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"
@@ -861,11 +860,6 @@ 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
@@ -894,8 +888,6 @@ 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, "@")
-19
View File
@@ -4,9 +4,7 @@
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"
@@ -41,7 +39,6 @@ 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)
@@ -373,22 +370,6 @@ 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
-3
View File
@@ -436,9 +436,6 @@ 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
} }
-23
View File
@@ -1,23 +0,0 @@
// 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))
}
-20
View File
@@ -1,20 +0,0 @@
// 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))
}
-24
View File
@@ -1,24 +0,0 @@
// 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))
}
+1 -86
View File
@@ -323,60 +323,6 @@ 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{}
@@ -402,7 +348,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 + len(DefaultOrgTeams) org.NumTeams = 1
org.NumMembers = 1 org.NumMembers = 1
org.Type = user_model.UserTypeOrganization org.Type = user_model.UserTypeOrganization
@@ -467,37 +413,6 @@ 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
}) })
} }
-63
View File
@@ -1,63 +0,0 @@
// 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
}
-10
View File
@@ -50,16 +50,6 @@ 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'"`
} }
+1 -1
View File
@@ -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. FIXME: some tables do not have "id" column orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
for _, condition := range conditions { for _, condition := range conditions {
switch cond := condition.(type) { switch cond := condition.(type) {
case *testCond: case *testCond:
+2 -11
View File
@@ -80,11 +80,8 @@ 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, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) { func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
return db.Get[ExternalLoginUser](ctx, builder.Eq{ return db.GetEngine(ctx).Get(externalLoginUser)
"external_id": externalID,
"login_source_id": loginSourceID,
})
} }
// LinkExternalToUser link the external user to the user // LinkExternalToUser link the external user to the user
@@ -121,12 +118,6 @@ 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
-12
View File
@@ -298,9 +298,6 @@ 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:
@@ -309,9 +306,6 @@ 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
@@ -325,9 +319,6 @@ 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)
@@ -341,9 +332,6 @@ 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:
-47
View File
@@ -254,53 +254,6 @@ 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) {
-12
View File
@@ -78,18 +78,6 @@ 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"},
+1 -1
View File
@@ -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
} }
-54
View File
@@ -1,54 +0,0 @@
// 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)
}
+1 -1
View File
@@ -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,8 +1,6 @@
// 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 (
@@ -20,8 +18,10 @@ 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 parser // LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser { func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
// 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,64 +54,79 @@ 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) && !errors.Is(err, context.DeadlineExceeded) { if err != nil && !errors.Is(err, context.Canceled) {
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{
treepath: treepath, return bufReader, func() {
paths: paths,
rd: bufReader,
close: func() {
ctxCancel() ctxCancel()
stdoutReaderClose() 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
close func() cancel func()
} }
// logNameStatusCommitData represents a commit artifact from git log raw // NewLogNameStatusRepoParser returns a new parser for a git log raw output
type logNameStatusCommitData struct { func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
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
} }
// walkNext returns the next LogStatusCommitData // Next returns the next LogStatusCommitData
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) { func (g *LogNameStatusRepoParser) Next(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')
switch { if err != nil {
case errors.Is(err, bufio.ErrBufferFull): switch err {
case bufio.ErrBufferFull:
g.buffull = true g.buffull = true
case err != nil: case io.EOF:
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err 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')
switch { if err != nil {
case errors.Is(err, bufio.ErrBufferFull): switch err {
case bufio.ErrBufferFull:
g.buffull = true g.buffull = true
case err != nil: case io.EOF:
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err return nil, err
} }
} }
}
// Our "line" must look like: <commitid> SP (<parent> SP) * NUL // Our "line" must look like: <commitid> SP (<parent> SP) * NUL
commitIDs := string(g.next) commitIDs := string(g.next)
@@ -258,10 +273,13 @@ diffloop:
} }
} }
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases // Close closes the parser
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)
@@ -304,9 +322,11 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
} }
} }
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...) g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
// don't use defer g.cancel() here as g may change its value - instead wrap in a func // don't use defer g.Close() here as g may change its value - instead wrap in a func
defer func() { g.close() }() defer func() {
g.Close()
}()
results := make([]string, len(paths)) results := make([]string, len(paths))
remaining := len(paths) remaining := len(paths)
@@ -320,16 +340,25 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
heaploop: heaploop:
for { for {
if walkGitLogDebugBeforeNext != nil { select {
walkGitLogDebugBeforeNext() case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
break heaploop
} }
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen) g.Close()
if ctx.Err() != nil { return nil, ctx.Err()
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far default:
} else if errors.Is(err, io.EOF) { }
break heaploop // reached to the end of log output current, err := g.Next(treepath, path2idx, changed, maxpathlen)
} else if err != nil { if err != nil {
return nil, err // other unknown errors if errors.Is(err, context.DeadlineExceeded) {
break heaploop
}
g.Close()
return nil, err
}
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 {
@@ -366,14 +395,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.close() g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
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
@@ -381,6 +410,7 @@ 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 {
-3
View File
@@ -121,9 +121,6 @@ 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")
} }
-23
View File
@@ -4,10 +4,7 @@
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"
@@ -22,23 +19,3 @@ 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")
}
+7 -63
View File
@@ -8,7 +8,6 @@ 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.
@@ -24,61 +23,10 @@ type HostMatchList struct {
ipNets []*net.IPNet ipNets []*net.IPNet
} }
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate) // MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
const MatchBuiltinExternal = "external" const MatchBuiltinExternal = "external"
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be // 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.
// 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.
@@ -145,22 +93,18 @@ func (hl *HostMatchList) checkPattern(host string) bool {
return false return false
} }
// matchesIP determines if the given IP matches any of the configured rules func (hl *HostMatchList) checkIP(ip net.IP) bool {
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:
// External address must be a global unicast, must not be in reserved range and must not be in private range if ip.IsGlobalUnicast() && !ip.IsPrivate() {
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
return true return true
} }
case MatchBuiltinPrivate: case MatchBuiltinPrivate:
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons if ip.IsPrivate() {
// and must be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
return true return true
} }
case MatchBuiltinLoopback: case MatchBuiltinLoopback:
@@ -191,7 +135,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.matchesIP(ip) return hl.checkIP(ip)
} }
return false return false
} }
@@ -202,7 +146,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.matchesIP(ip) return hl.checkPattern(host) || hl.checkIP(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
-57
View File
@@ -159,60 +159,3 @@ 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)
}
}
+3 -21
View File
@@ -175,25 +175,16 @@ 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, and wraps the whole subject in defaultLink. // emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
// 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,
@@ -201,15 +192,6 @@ 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
+1 -12
View File
@@ -146,25 +146,14 @@ func ParseControlFile(r io.Reader) (*Package, error) {
var depends strings.Builder var depends strings.Builder
var control strings.Builder var control strings.Builder
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files s := bufio.NewScanner(io.TeeReader(r, &control))
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 == "" {
// 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 continue
} }
break
}
control.WriteString(line)
control.WriteByte('\n')
if line[0] == ' ' || line[0] == '\t' { if line[0] == ' ' || line[0] == '\t' {
switch key { switch key {
-15
View File
@@ -184,19 +184,4 @@ 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")
})
} }
+21 -28
View File
@@ -14,7 +14,6 @@ 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
@@ -198,38 +197,32 @@ 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.
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value()
RunMode = os.Getenv("GITEA_RUN_MODE")
if !allowRunAsRoot { if RunMode == "" {
// Special thanks to VLC which inspired the wording of this messaging. RunMode = rootSec.Key("RUN_MODE").MustString("prod")
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.") }
// 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.
RunMode = strings.ToLower(RunMode)
if RunMode != "dev" {
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.
-19
View File
@@ -165,28 +165,9 @@ 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 {
-18
View File
@@ -49,12 +49,6 @@ type BranchProtection struct {
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"`
@@ -100,12 +94,6 @@ type CreateBranchProtectionOption struct {
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"`
@@ -142,12 +130,6 @@ type EditBranchProtectionOption struct {
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"`
-33
View File
@@ -1,33 +0,0 @@
// 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"`
}
-10
View File
@@ -40,16 +40,6 @@ 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
-12
View File
@@ -140,18 +140,6 @@ 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>
-18
View File
@@ -855,8 +855,6 @@
"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)",
@@ -2439,17 +2437,6 @@
"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:",
@@ -3009,11 +2996,6 @@
"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.",
+4 -20
View File
@@ -261,32 +261,16 @@ func (s *Service) UpdateLog(
} }
ack := task.LogLength ack := task.LogLength
// Trim rows the runner already had acked. if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack {
var rows []*runnerv1.LogRow res.Msg.AckIndex = ack
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack { return res, nil
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 task.LogInStorage {
if len(rows) > 0 {
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
} }
res.Msg.AckIndex = ack
return res, nil
}
// Bail unless we have new rows or a NoMore to finalize. Even with rows := req.Msg.Rows[ack-req.Msg.Index:]
// 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
}
// 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)
+18 -84
View File
@@ -294,9 +294,6 @@ 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
} }
} }
} }
@@ -508,49 +505,31 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
} }
} }
// reqOrgVisible requires the organization to be visible to the doer, or a site admin // reqTeamMembership user should be an team member, or a site admin
func reqOrgVisible() func(ctx *context.APIContext) { func reqTeamMembership() func(ctx *context.APIContext) {
return 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() { if ctx.IsUserSiteAdmin() {
return 0, true, true return
} }
if ctx.Org.Team == nil { if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context") setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context")) ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
return 0, false, false return
} }
orgID = ctx.Org.Team.OrgID orgID := ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID) isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return 0, false, false return
} else if isOwner { } else if isOwner {
return orgID, true, true return
} }
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID) if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return 0, false, false return
} } else if !isTeamMember {
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -559,28 +538,8 @@ func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
} else { } else {
ctx.APIErrorNotFound() 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 return
} }
denyNonTeamMember(ctx, orgID)
}
}
// reqTeamMembership user should be a team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
denyNonTeamMember(ctx, orgID)
} }
} }
@@ -1045,9 +1004,7 @@ 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}"). m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken)
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)
@@ -1300,23 +1257,6 @@ 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)
@@ -1374,7 +1314,6 @@ 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)
@@ -1800,7 +1739,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)
@@ -1837,11 +1776,6 @@ 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())
@@ -1859,12 +1793,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(), reqTeamMembership(), org.AddTeamRepository). Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository). Delete(reqToken(), 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(), reqTeamReadAccess(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
m.Group("/admin", func() { m.Group("/admin", func() {
m.Group("/cron", func() { m.Group("/cron", func() {
@@ -1957,7 +1891,7 @@ func Routes() *web.Router {
// Authenticated license detail // Authenticated license detail
m.Get("/{dlid}/status", reqToken(), licensing.Status) m.Get("/{dlid}/status", reqToken(), licensing.Status)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryLicensing)) })
}, sudo()) }, sudo())
return m return m
+3 -12
View File
@@ -207,10 +207,7 @@ func UpdateLicense(ctx *context.APIContext) {
} }
if len(cols) > 0 { if len(cols) > 0 {
cols = append(cols, "updated_at") cols = append(cols, "updated_at")
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(license); err != nil { db.GetEngine(ctx).ID(id).Cols(cols...).Update(license)
ctx.APIErrorInternal(err)
return
}
} }
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license)) ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
@@ -402,10 +399,7 @@ func UpdateTier(ctx *context.APIContext) {
} }
if len(cols) > 0 { if len(cols) > 0 {
if _, err := db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier); err != nil { db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier)
ctx.APIErrorInternal(err)
return
}
} }
ctx.JSON(http.StatusOK, tierToJSON(tier)) ctx.JSON(http.StatusOK, tierToJSON(tier))
@@ -433,10 +427,7 @@ func DeleteTier(ctx *context.APIContext) {
return return
} }
if _, err := db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier)); err != nil { db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }
-149
View File
@@ -6,26 +6,11 @@ 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
@@ -49,10 +34,6 @@ 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)
@@ -66,7 +47,6 @@ 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,
}) })
} }
@@ -96,10 +76,6 @@ 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)
@@ -142,10 +118,6 @@ 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)
@@ -164,124 +136,3 @@ 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)
}
-2
View File
@@ -1081,8 +1081,6 @@ 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)
} }
+3 -84
View File
@@ -693,15 +693,6 @@ 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) {
@@ -720,7 +711,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 var whitelistTeams, forcePushAllowlistTeams, 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 {
@@ -740,15 +731,6 @@ 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) {
@@ -781,10 +763,6 @@ 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,
@@ -807,8 +785,6 @@ 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,
@@ -935,32 +911,6 @@ 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
} }
@@ -1027,7 +977,7 @@ func EditBranchProtection(ctx *context.APIContext) {
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
} }
var whitelistUsers, forcePushAllowlistUsers, deleteAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64 var whitelistUsers, forcePushAllowlistUsers, 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 {
@@ -1054,19 +1004,6 @@ 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 {
@@ -1094,7 +1031,7 @@ func EditBranchProtection(ctx *context.APIContext) {
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
} }
var whitelistTeams, forcePushAllowlistTeams, deleteAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 var whitelistTeams, forcePushAllowlistTeams, 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)
@@ -1122,19 +1059,6 @@ 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 {
@@ -1168,8 +1092,6 @@ 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,
@@ -1365,9 +1287,6 @@ 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
-151
View File
@@ -1,151 +0,0 @@
// 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)
}
+21 -71
View File
@@ -32,16 +32,6 @@ 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.
@@ -91,14 +81,6 @@ 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,
}) })
} }
@@ -114,58 +96,34 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/Manifest" // "$ref": "#/responses/Manifest"
// Decode into a map to detect which fields were actually sent. var req apiMetadata
var raw map[string]any if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
ctx.APIError(http.StatusBadRequest, err) ctx.APIError(http.StatusBadRequest, err)
return return
} }
// Load existing metadata (or create defaults). m := &repo_model.RepoMetadata{
m, _ := repo_model.GetRepoMetadata(ctx, ctx.Repo.Repository.ID)
if m == nil {
m = &repo_model.RepoMetadata{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Name: ctx.Repo.Repository.Name, Name: req.Name,
Org: ctx.Repo.Repository.OwnerName, Org: req.Org,
Description: ctx.Repo.Repository.Description, Description: req.Description,
}
}
// Apply only the fields present in the request. LicenseSPDX: req.LicenseSPDX,
setStr := func(key string, target *string) { LicenseName: req.LicenseName,
if v, ok := raw[key]; ok { VersionPrefix: req.VersionPrefix,
if s, ok := v.(string); ok { ElementName: req.ElementName,
*target = s 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,
} }
}
}
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)
@@ -193,13 +151,5 @@ 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,
}) })
} }
-220
View File
@@ -1,220 +0,0 @@
// 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,
}
}
+1 -140
View File
@@ -8,7 +8,6 @@ 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"
@@ -462,148 +461,10 @@ 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 && !git.IsErrNotExist(err) { if err != nil {
return nil, err return nil, err
} }
if entry != nil { if entry != nil {
-100
View File
@@ -209,106 +209,6 @@ 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
-2
View File
@@ -38,7 +38,6 @@ 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"
@@ -154,7 +153,6 @@ 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()
+20 -65
View File
@@ -26,7 +26,6 @@ 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 {
@@ -41,6 +40,9 @@ 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
@@ -48,36 +50,24 @@ type preReceiveContext struct {
opts *private.HookOptions opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here branchName string
canWriteCodeUnitCached *bool
} }
func (ctx *preReceiveContext) canWriteCodeUnit() bool { // CanWriteCode returns true if pusher can write code
if ctx.canWriteCodeUnitCached == nil { func (ctx *preReceiveContext) CanWriteCode() bool {
var canWrite bool if !ctx.checkedCanWriteCode {
if ctx.loadPusherAndPermission() { if !ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
ctx.canWriteCodeUnitCached = &canWrite
}
return *ctx.canWriteCodeUnitCached
}
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
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 false
} }
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user) ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.checkedCanWriteCode = true
}
return ctx.canWriteCode
} }
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false // AssertCanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool { func (ctx *preReceiveContext) AssertCanWriteCode() bool {
if !ctx.canWriteCodeRef(refFullName) { if !ctx.CanWriteCode() {
if ctx.Written() { if ctx.Written() {
return false return false
} }
@@ -139,7 +129,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.assertCanWriteRef(refFullName) ourCtx.AssertCanWriteCode()
} }
if ctx.Written() { if ctx.Written() {
return return
@@ -151,8 +141,9 @@ 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.assertCanWriteRef(refFullName) { if !ctx.AssertCanWriteCode() {
return return
} }
@@ -160,25 +151,6 @@ 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
@@ -210,31 +182,14 @@ 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 (unless user is in delete allowlist) // 1. Detect and prevent deletion of the branch
if newCommitID == objectFormat.EmptyObjectID().String() { if newCommitID == objectFormat.EmptyObjectID().String() {
canDelete := false
if ctx.opts.DeployKeyID != 0 {
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) log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
ctx.JSON(http.StatusForbidden, private.Response{ ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName), UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
}) })
return return
} }
return
}
isForcePush := false isForcePush := false
@@ -449,7 +404,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.assertCanWriteRef(refFullName) { if !ctx.AssertCanWriteCode() {
return return
} }
-70
View File
@@ -1,70 +0,0 @@
// 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")))
}
+9 -3
View File
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return return
} }
// Validate the passcode and atomically consume it to prevent reuse/replay. // Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode) ok, err := twofa.ValidateTOTP(form.Passcode)
if err != nil { if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return
} }
if ok { if ok && twofa.LastUsedPasscode != form.Passcode {
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,6 +81,12 @@ 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
+8 -36
View File
@@ -368,22 +368,10 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
opts := &user_service.UpdateOptions{} opts := &user_service.UpdateOptions{}
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go // Reactivate user if they are deactivated
// 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 {
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) opts.IsActive = optional.Some(true)
} }
}
// Update GroupClaims // Update GroupClaims
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
@@ -526,33 +514,17 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
} }
// search in external linked users // search in external linked users
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID) externalLoginUser := &user_model.ExternalLoginUser{
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(ctx, externalLoginUser.UserID) user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
if err != nil && !user_model.IsErrUserNotExist(err) { return user, gothUser, 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
+9 -3
View File
@@ -177,17 +177,23 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true regenerateScratchToken = true
} else { } else {
passcode := ctx.FormString("passcode") passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode) ok, err := twofa.ValidateTOTP(passcode)
if err != nil { if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error()) ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
return return
} }
if !ok { if !ok || twofa.LastUsedPasscode == passcode {
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
}
} }
} }
-3
View File
@@ -15,9 +15,6 @@ 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 {
-3
View File
@@ -16,9 +16,6 @@ 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
-3
View File
@@ -15,9 +15,6 @@ 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,
-9
View File
@@ -4,18 +4,9 @@
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 == "" {
-3
View File
@@ -16,9 +16,6 @@ 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,
-31
View File
@@ -27,35 +27,9 @@ 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"))
@@ -129,11 +103,6 @@ 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
} }
+25 -73
View File
@@ -29,14 +29,6 @@ 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
@@ -79,9 +71,31 @@ func Wiki(ctx *context.Context) {
} }
ctx.Data["WikiRepoLink"] = wikiRepo.Link() ctx.Data["WikiRepoLink"] = wikiRepo.Link()
// Build folder tree for sidebar navigation. // Build page list from repo root.
wikiTree := buildOrgWikiTree(commit) entries, err := commit.ListEntries()
ctx.Data["WikiTree"] = wikiTree if err != nil {
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("*")
@@ -143,68 +157,6 @@ 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.
+1 -1
View File
@@ -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) || errors.Is(err, util.ErrPermissionDenied) { if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound() ctx.JSONErrorNotFound()
return return
} else if pull_service.IsErrMergeConflicts(err) { } else if pull_service.IsErrMergeConflicts(err) {
+27 -18
View File
@@ -58,6 +58,8 @@ 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
@@ -91,11 +93,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
@@ -105,14 +107,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
} }
@@ -125,24 +127,31 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil return nil
} }
// Only public pulls don't need auth: repo must exist, not require-sign-in // Only public pull don't need auth.
canAnonymousPull := false // For private repos, also allow anonymous pull if the specific unit
if isPull && repoExist && !setting.Service.RequireSignInViewStrict { // (code or wiki) has AnonymousAccessMode >= Read.
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate { isPublicPull := repoExist && isPull && !repo.IsPrivate
canAnonymousPull = true if repoExist && isPull && repo.IsPrivate {
repoUnit := repo.MustGetUnit(ctx, unitType)
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
isPublicPull = true
} }
if !canAnonymousPull && ctx.Doer == nil { }
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil) askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err) // don't allow anonymous pulls if organization is not public
if isPublicPull {
if err := repo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return nil return nil
} }
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
} askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
} }
// check access // check access
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth if askAuth {
// 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 {
@@ -228,7 +237,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)
+24 -29
View File
@@ -681,8 +681,6 @@ 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
@@ -707,47 +705,44 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
headCommitID := prCompareInfo.HeadCommitID headCommitID := prCompareInfo.HeadCommitID
isSingleCommit := beforeCommitID == "" && afterCommitID != "" isSingleCommit := beforeCommitID == "" && afterCommitID != ""
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
ctx.Data["IsShowingAllCommits"] = isShowAllCommits ctx.Data["IsShowingAllCommits"] = isShowAllCommits
afterCommitID = util.IfZero(afterCommitID, headCommitID) if afterCommitID == "" || 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.NotFound(nil) ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits")
return return
} }
var beforeCommit *git.Commit var beforeCommit *git.Commit
if isSingleCommit { var err error
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("afterCommit.Parent", err) ctx.ServerError("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
-8
View File
@@ -123,14 +123,6 @@ func saveMetadata(ctx *context.Context) {
manifest.Maintainer = existing.Maintainer manifest.Maintainer = existing.Maintainer
manifest.MaintainerURL = existing.MaintainerURL manifest.MaintainerURL = existing.MaintainerURL
manifest.Language = existing.Language manifest.Language = existing.Language
manifest.DeployHost = existing.DeployHost
manifest.DeployPort = existing.DeployPort
manifest.DeployUser = existing.DeployUser
manifest.DeployPath = existing.DeployPath
manifest.DockerImage = existing.DockerImage
manifest.DockerRegistry = existing.DockerRegistry
manifest.ContainerName = existing.ContainerName
manifest.HealthURL = existing.HealthURL
} }
if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil { if err := repo_model.CreateOrUpdateRepoMetadata(ctx, manifest); err != nil {
+1 -29
View File
@@ -82,7 +82,6 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["Users"] = users c.Data["Users"] = users
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",") c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",")
c.Data["delete_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistUserIDs), ",")
c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",") c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
@@ -98,7 +97,6 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["Teams"] = teams c.Data["Teams"] = teams
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",") c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",")
c.Data["delete_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.DeleteAllowlistTeamIDs), ",")
c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",") c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
} }
@@ -157,7 +155,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
} }
} }
var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, deleteAllowlistUsers, deleteAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64
protectBranch.RuleName = f.RuleName protectBranch.RuleName = f.RuleName
if f.RequiredApprovals < 0 { if f.RequiredApprovals < 0 {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
@@ -213,30 +211,6 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
protectBranch.ForcePushAllowlistActionsUser = false protectBranch.ForcePushAllowlistActionsUser = false
} }
switch f.EnableDelete {
case "all":
protectBranch.CanDelete = true
protectBranch.EnableDeleteAllowlist = false
protectBranch.DeleteAllowlistDeployKeys = false
protectBranch.DeleteAllowlistActionsUser = false
case "whitelist":
protectBranch.CanDelete = true
protectBranch.EnableDeleteAllowlist = true
protectBranch.DeleteAllowlistDeployKeys = f.DeleteAllowlistDeployKeys
protectBranch.DeleteAllowlistActionsUser = f.DeleteAllowlistActionsUser
if strings.TrimSpace(f.DeleteAllowlistUsers) != "" {
deleteAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistUsers, ","))
}
if strings.TrimSpace(f.DeleteAllowlistTeams) != "" {
deleteAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.DeleteAllowlistTeams, ","))
}
default:
protectBranch.CanDelete = false
protectBranch.EnableDeleteAllowlist = false
protectBranch.DeleteAllowlistDeployKeys = false
protectBranch.DeleteAllowlistActionsUser = false
}
protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist
protectBranch.MergeWhitelistActionsUser = f.EnableMergeWhitelist && f.MergeWhitelistActionsUser protectBranch.MergeWhitelistActionsUser = f.EnableMergeWhitelist && f.MergeWhitelistActionsUser
if f.EnableMergeWhitelist { if f.EnableMergeWhitelist {
@@ -300,8 +274,6 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
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,
+51 -1096
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More