Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd7b0ac53 | |||
| 0ca8441d1e | |||
| d07cfd412b | |||
| bd821e2d44 | |||
| aeed197ea5 | |||
| 45fc346d52 | |||
| 02071a23d6 | |||
| 3a5c6a37cf | |||
| 37fb3703c7 | |||
| 6a3db171c1 | |||
| d3134b1c53 | |||
| 3aac1b456c | |||
| b31336d1fe | |||
| 4b68853f08 | |||
| 86bd8a2cad | |||
| 24b3516c1d | |||
| 056eb7d3c4 | |||
| 0492448ab5 | |||
| 377dd019be | |||
| 5ffed39449 | |||
| 0cc569aef6 | |||
| 4ed45a5916 | |||
| ea87b3db97 | |||
| d77b82a8af | |||
| 22b0b14ea8 | |||
| 1256f57d40 | |||
| 05763eb661 | |||
| 0c45a2fda3 | |||
| 8680fd1cab | |||
| adf0e923df | |||
| 296df7792a | |||
| 51332a587d | |||
| 8fc2700c16 | |||
| 844b4b6e81 | |||
| cb9516b79b | |||
| aea4370845 | |||
| fc234bc911 | |||
| b252e9569f | |||
| efb0433412 | |||
| 982e45a56e | |||
| 343cba690e | |||
| 78328146e5 | |||
| 84c6a94333 | |||
| 11b2195bdf | |||
| f8a91ed34e | |||
| 87f92fe1ab | |||
| 66aea89b40 | |||
| 7c75133ef1 | |||
| 108b19dcc8 | |||
| 37a62b5ab7 | |||
| 35ebbef489 | |||
| 193c705c05 | |||
| fa845164bb | |||
| fefdf1a1ec | |||
| 23bb025700 | |||
| 7913a05285 | |||
| 98301bc92b | |||
| c618ec9f87 | |||
| 5a25068d81 | |||
| 57894e25fd | |||
| 2857a1f6a1 | |||
| b9c04e51b4 | |||
| 0b44e92369 | |||
| cf25eef480 | |||
| 5da6a40f10 | |||
| 4e5aa5f3ce | |||
| 9a4aa0fafb | |||
| e947600ea7 | |||
| b0bbaab621 | |||
| 84df5d7932 | |||
| 7b334f94c0 | |||
| 805c566615 | |||
| f53bc895ba | |||
| 2ff0e4aa21 | |||
| c0f89a373d | |||
| 0dc858c15c | |||
| 94590bc834 | |||
| cbf34fb987 | |||
| 26ad4fd03f | |||
| bc578b7eba | |||
| 186ac68f03 | |||
| fedce235d5 | |||
| d3c6998d3e | |||
| e9648f367e | |||
| 725acbe112 | |||
| 884e568ea2 | |||
| e99658ddc0 | |||
| f627219ca8 | |||
| df9305758f | |||
| ebdf59d64f | |||
| 8ffd2ffe18 |
@@ -120,3 +120,6 @@ prime/
|
||||
|
||||
# A Makefile for custom make targets
|
||||
Makefile.local
|
||||
|
||||
# Local clone of the MCP server (separate repo, not a submodule of this project)
|
||||
/mcp-mokogitea-api/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: '[FEATURE] '
|
||||
title: '(feat) '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +=======================================================================+
|
||||
@@ -64,10 +64,14 @@ jobs:
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
(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')
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
(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')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -75,6 +79,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
@@ -90,7 +95,7 @@ jobs:
|
||||
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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
@@ -99,11 +104,39 @@ jobs:
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
|
||||
# Resolve the source branch HEAD commit.
|
||||
SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
|
||||
|| { echo "::error::Source branch ${FROM} not found"; exit 1; }
|
||||
SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
|
||||
[ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
|
||||
|
||||
# Point rc at the source commit. If rc already exists (a protected branch that
|
||||
# cannot be deleted), force-update its ref in place instead of delete+recreate:
|
||||
# deleting a protected branch fails, which then makes the recreate return HTTP 409.
|
||||
if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then
|
||||
echo "rc exists - force-updating to ${FROM} (${SRC_SHA})"
|
||||
curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \
|
||||
|| { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; }
|
||||
else
|
||||
echo "Creating rc from ${FROM}"
|
||||
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \
|
||||
|| { echo "::error::Failed to create rc from ${FROM}"; exit 1; }
|
||||
fi
|
||||
|
||||
# Repoint the PR at rc, then delete the old source branch (non-fatal).
|
||||
if [ -n "$PR" ]; then
|
||||
curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
|
||||
fi
|
||||
curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true
|
||||
echo "Renamed ${FROM} -> rc"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
@@ -163,9 +196,13 @@ jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -173,6 +210,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
@@ -208,7 +246,7 @@ jobs:
|
||||
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
|
||||
rm -rf /tmp/mokocli
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
|
||||
cd /tmp/mokocli
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
@@ -33,7 +33,8 @@ jobs:
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
# URL-encode the branch name's slashes (no PHP dependency on the runner)
|
||||
ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
||||
# PATH: /.gitea/workflows/ci-generic.yml
|
||||
# PATH: /.mokogitea/workflows/ci-generic.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
|
||||
|
||||
@@ -32,6 +32,8 @@ jobs:
|
||||
lint:
|
||||
name: Lint & Validate
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
|
||||
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -130,6 +132,9 @@ jobs:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
# Run only when lint succeeded; always() forces evaluation so a skipped
|
||||
# lint (e.g. template repos) skips this job cleanly instead of hanging.
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# PATH: /.mokogitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
for BRANCH in $BRANCHES; do
|
||||
# Skip protected branches
|
||||
case "$BRANCH" in
|
||||
main|master|develop|release/*|hotfix/*) continue ;;
|
||||
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
|
||||
esac
|
||||
|
||||
# Check if branch is merged into main
|
||||
|
||||
@@ -47,6 +47,15 @@ jobs:
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# This RC flow drives a Joomla-style update stream (updates.xml). Repos that don't ship
|
||||
# one (e.g. generic Go/TS) have nothing to package here, so no-op cleanly instead of
|
||||
# aborting under `set -e` when the file is absent.
|
||||
if [ ! -f updates.xml ]; then
|
||||
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No updates.xml in this repo — skipping RC update-stream packaging"
|
||||
exit 0
|
||||
fi
|
||||
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||
BASE_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' updates.xml | head -1)
|
||||
[ -z "$BASE_VERSION" ] && BASE_VERSION="04.00.00"
|
||||
RC_VERSION="${BASE_VERSION}-rc.${PR_NUMBER}"
|
||||
@@ -56,7 +65,7 @@ jobs:
|
||||
echo "RC version: $RC_VERSION (tag: $RC_TAG)"
|
||||
|
||||
- name: Update updates.xml RC channel
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
|
||||
env:
|
||||
RC_VERSION: ${{ steps.version.outputs.version }}
|
||||
RC_TAG: ${{ steps.version.outputs.tag }}
|
||||
@@ -106,7 +115,7 @@ jobs:
|
||||
PYEOF
|
||||
|
||||
- name: Create RC release
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
RC_TAG: ${{ steps.version.outputs.tag }}
|
||||
@@ -153,7 +162,7 @@ jobs:
|
||||
PYEOF
|
||||
|
||||
- name: Commit updates.xml
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -6,7 +6,7 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# PATH: /.mokogitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Universal: Build & Release"
|
||||
- "Joomla: Extension CI"
|
||||
- "Generic: Project CI"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: mokocli.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
@@ -47,15 +47,15 @@ jobs:
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
@@ -86,7 +86,8 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -126,6 +127,8 @@ jobs:
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
|
||||
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -147,11 +150,12 @@ jobs:
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
|
||||
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
|
||||
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected platform: $PLATFORM"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
@@ -492,6 +496,9 @@ jobs:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
# Run only when both gates succeeded; always() forces evaluation so a skipped
|
||||
# validate (e.g. template repos) skips this job cleanly instead of hanging.
|
||||
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
|
||||
@@ -48,9 +48,13 @@ jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
runs-on: release
|
||||
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
!startsWith(github.event.repository.name, 'Template-') &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Sync Workflows to Repos
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.mokogitea/workflows/**'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout mokocli
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: MokoConsulting/mokocli
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1
|
||||
with:
|
||||
php-version: '8.1'
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction
|
||||
|
||||
- name: Sync workflows to generic repos
|
||||
run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
@@ -3,6 +3,17 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Org branch protection: repositories now show the inherited organization rules read-only in their Branch Protection settings, with an expandable detail (direct push, force-push, branch deletion, merge restrictions, required approvals, status checks, protected files, and whitelisted teams) — like GitHub surfaces org rulesets in a repo (#727)
|
||||
- Org branch protection: org-level rules can now also protect against branch deletion (`enable_delete` + delete allowlist teams), mirroring the per-repo delete allowlist (#727)
|
||||
- Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727)
|
||||
- Org-level push policy: one policy per org, enforced in the pre-receive hook across all its repositories — branch/tag name conventions (glob), a mandatory secret-scanning block-on-push that repos cannot disable, a max pushed-file size, and blocked file-path patterns. API at `/orgs/{org}/push_policy`. Naming is fail-closed; the content checks (blocked paths, max size) fail open on error so a policy bug can never block every push (#727)
|
||||
- Org-level repository defaults: an org can force new/transferred repositories private and set default pull-request settings (allowed merge styles, default merge style, auto-delete branch after merge), applied via a notifier when a repo is created in or transferred into the org (best-effort — never blocks repo creation). API at `/orgs/{org}/repo_defaults` (#727)
|
||||
- Org-level email domain policy: restrict which email domains an organization's members may have — a user can only be added to the org (via any team) if their primary email matches one of the allowed domain globs. Enforced at the single membership-add choke point (`AddTeamMember`); API at `/orgs/{org}/email_domain_policy` (#727)
|
||||
- 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)
|
||||
@@ -10,6 +21,8 @@
|
||||
- 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)
|
||||
@@ -34,7 +47,31 @@
|
||||
- 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
|
||||
- Org-level branch protection now **layers** with per-repo rules instead of being ignored whenever a repo rule exists. When both an org rule and a repo rule match a branch, the effective rule is the most-restrictive (fail-closed) combination — the org rule is a mandatory floor a repo cannot weaken: allow flags AND'd, gate/require/block flags OR'd, required approvals max'd, status checks and protected-file patterns unioned, whitelists intersected. Previously a repo rule shadowed the org rule entirely at the enforcement choke point (`GetFirstMatchProtectedBranchRule`), letting a repo opt out of org protection (#727)
|
||||
- Org Teams page: list now renders — the handler wrote `ctx.Data["OrgListTeams"]` but the template reads `.Teams`, so the page showed header/nav but no teams (#720)
|
||||
- Issue type: now editable after creation for users with issue write permission — the sidebar gated editing on a `FieldEditFlags` data key that was never populated (always read-only); now uses `HasIssuesOrPullsWritePermission` like the priority field (#721)
|
||||
- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page
|
||||
- PR check branch policy: allow `fix/*` → `main` and `patch/*` → `main` to match documented policy (was rejecting fix/patch PRs to main)
|
||||
- PR check platform detection: guard for missing `.mokogitea/manifest.xml` so the Validate PR job no longer aborts under `set -e` (manifest replaced by metadata API)
|
||||
- Remove dangling `mcp-mokogitea-api` submodule gitlink (no `.gitmodules` entry) that broke `submodule update --init` at checkout, failing all PR build/release jobs; ignore the local clone path
|
||||
- PR RC Release workflow: no-op cleanly when `updates.xml` is absent (generic repos) instead of aborting the "Determine RC version" step under `set -e`
|
||||
- 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoGitea
|
||||
|
||||
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, CI standardization, and project board API.
|
||||
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API.
|
||||
|
||||
 
|
||||
|
||||
@@ -11,8 +11,12 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org
|
||||
- **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))
|
||||
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
|
||||
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
|
||||
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection
|
||||
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration
|
||||
- **Cascade Merge** -- auto-create PRs to downstream branches after merge with configurable rules per repo
|
||||
- **Security Scanning** -- secret detection (pre-receive blocking) + code analysis (SQL injection, XSS, command injection, path traversal, and more) with REST API for alerts, config, and on-demand scans
|
||||
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
|
||||
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
|
||||
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
|
||||
- **Project Board API** -- REST endpoints for project columns and cards
|
||||
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
|
||||
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
|
||||
|
||||
+14
-9
@@ -113,23 +113,25 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
|
||||
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
|
||||
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
|
||||
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
|
||||
// check in routers/private/serv.go and grant access.
|
||||
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
|
||||
switch verb {
|
||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
case git.CmdVerbReceivePack:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||
switch lfsVerb {
|
||||
case git.CmdSubVerbLfsUpload:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdSubVerbLfsDownload:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
}
|
||||
}
|
||||
// should be unreachable
|
||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
||||
return perm.AccessModeNone
|
||||
return perm.AccessModeNone, false
|
||||
}
|
||||
|
||||
func runServ(ctx context.Context, c *cli.Command) error {
|
||||
@@ -247,7 +249,10 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
}
|
||||
|
||||
requestedMode := getAccessMode(verb, lfsVerb)
|
||||
requestedMode, ok := getAccessMode(verb, lfsVerb)
|
||||
if !ok {
|
||||
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
|
||||
}
|
||||
|
||||
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||
if extra.HasError() {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetAccessMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
verb, lfsVerb string
|
||||
expected perm.AccessMode
|
||||
}{
|
||||
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
|
||||
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
|
||||
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.expected, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
|
||||
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
|
||||
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
|
||||
// it would pass the `userMode < mode` permission check in routers/private/serv.go
|
||||
// and hand out valid LFS JWTs for any private repository.
|
||||
func TestGetAccessModeUnknownVerb(t *testing.T) {
|
||||
cases := []struct{ verb, lfsVerb string }{
|
||||
{git.CmdVerbLfsAuthenticate, ""},
|
||||
{git.CmdVerbLfsAuthenticate, "badverb"},
|
||||
{git.CmdVerbLfsTransfer, "badverb"},
|
||||
{"git-unknown-verb", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, perm.AccessModeNone, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
Submodule mcp-mokogitea-api deleted from dbaf91546e
@@ -64,7 +64,6 @@ type FindRunOptions struct {
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
TriggerUserID int64
|
||||
TriggerEvent webhook_module.HookEventType
|
||||
Approved bool // not util.OptionalBool, it works only when it's true
|
||||
Status []Status
|
||||
ConcurrencyGroup string
|
||||
CommitSHA string
|
||||
@@ -81,9 +80,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.TriggerUserID > 0 {
|
||||
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 {
|
||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
//
|
||||
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTOTP validates the provided passcode.
|
||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
||||
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
|
||||
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||
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)
|
||||
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)
|
||||
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.
|
||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||
_, err := db.GetEngine(ctx).Insert(t)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||
require.NoError(t, err)
|
||||
|
||||
tfa := &auth_model.TwoFactor{UID: 1}
|
||||
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||
|
||||
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// first use of a valid passcode succeeds
|
||||
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||
require.NoError(t, err)
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
|
||||
// an invalid passcode is rejected without consuming anything
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
||||
// LFS objects are repository code content, so authorization must require
|
||||
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||
// reuse of an existing LFS object across repositories.
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||
cond := builder.In(
|
||||
"`lfs_meta_object`.repository_id",
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||
)
|
||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgEmailDomainPolicy restricts which email domains an organization's members may
|
||||
// have. When configured, a user can only be added to the org if their primary email
|
||||
// matches one of the allowed domain globs. At most one row per org. See issue #727.
|
||||
type OrgEmailDomainPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
AllowedDomains string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgEmailDomainPolicy))
|
||||
}
|
||||
|
||||
// ErrEmailDomainNotAllowed is returned when a user's email domain is not permitted
|
||||
// by the organization's email domain policy.
|
||||
type ErrEmailDomainNotAllowed struct {
|
||||
Email string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
func (e ErrEmailDomainNotAllowed) Error() string {
|
||||
return fmt.Sprintf("email %q is not in an allowed domain for organization %d", e.Email, e.OrgID)
|
||||
}
|
||||
|
||||
// IsErrEmailDomainNotAllowed reports whether err is an ErrEmailDomainNotAllowed.
|
||||
func IsErrEmailDomainNotAllowed(err error) bool {
|
||||
_, ok := err.(ErrEmailDomainNotAllowed)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *OrgEmailDomainPolicy) domainGlobs() []glob.Glob {
|
||||
var out []glob.Glob
|
||||
for _, d := range strings.Split(p.AllowedDomains, ";") {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if g, err := glob.Compile(d); err == nil {
|
||||
out = append(out, g)
|
||||
} else {
|
||||
log.Warn("Invalid org email domain glob %q: %v", d, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EmailAllowed reports whether email's domain satisfies the policy. An empty policy
|
||||
// (no configured domains) allows any email.
|
||||
func (p *OrgEmailDomainPolicy) EmailAllowed(email string) bool {
|
||||
globs := p.domainGlobs()
|
||||
if len(globs) == 0 {
|
||||
return true
|
||||
}
|
||||
at := strings.LastIndexByte(email, '@')
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(email[at+1:])
|
||||
for _, g := range globs {
|
||||
if g.Match(domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetOrgEmailDomainPolicy returns the org's email domain policy, or nil if none.
|
||||
func GetOrgEmailDomainPolicy(ctx context.Context, orgID int64) (*OrgEmailDomainPolicy, error) {
|
||||
policy, exist, err := db.Get[OrgEmailDomainPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// OrgEmailDomainAllowed reports whether email is permitted for the org. It returns
|
||||
// true when the org has no policy configured.
|
||||
func OrgEmailDomainAllowed(ctx context.Context, orgID int64, email string) (bool, error) {
|
||||
policy, err := GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if policy == nil {
|
||||
return true, nil
|
||||
}
|
||||
return policy.EmailAllowed(email), nil
|
||||
}
|
||||
|
||||
// UpsertOrgEmailDomainPolicy creates or updates the single policy row for an org.
|
||||
func UpsertOrgEmailDomainPolicy(ctx context.Context, policy *OrgEmailDomainPolicy) error {
|
||||
existing, err := GetOrgEmailDomainPolicy(ctx, policy.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||
return fmt.Errorf("Insert OrgEmailDomainPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
policy.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||
return fmt.Errorf("Update OrgEmailDomainPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgEmailDomainPolicy removes an org's email domain policy.
|
||||
func DeleteOrgEmailDomainPolicy(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgEmailDomainPolicy))
|
||||
return err
|
||||
}
|
||||
@@ -33,6 +33,9 @@ type OrgProtectedBranch struct {
|
||||
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
||||
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
||||
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
@@ -96,6 +99,9 @@ func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
|
||||
CanForcePush: o.CanForcePush,
|
||||
EnableForcePushAllowlist: o.EnableForcePushAllowlist,
|
||||
ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs,
|
||||
CanDelete: o.CanDelete,
|
||||
EnableDeleteAllowlist: o.EnableDeleteAllowlist,
|
||||
DeleteAllowlistTeamIDs: o.DeleteAllowlistTeamIDs,
|
||||
EnableStatusCheck: o.EnableStatusCheck,
|
||||
StatusCheckContexts: o.StatusCheckContexts,
|
||||
RequiredApprovals: o.RequiredApprovals,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgProtectedTag represents an org-level tag protection rule. It cascades to all
|
||||
// repositories in the organization and layers on top of each repo's own protected
|
||||
// tags (a tag is controllable only if allowed at both levels). Org rules reference
|
||||
// teams only (like OrgProtectedBranch). See issue #727.
|
||||
type OrgProtectedTag struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||
NamePattern string `xorm:"UNIQUE(s)"`
|
||||
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgProtectedTag))
|
||||
}
|
||||
|
||||
// ToProtectedTag converts an org-level tag rule into a repo-scoped ProtectedTag so
|
||||
// the standard name-matching and allowlist logic can be reused. Org rules are
|
||||
// team-only, so the user allowlist is left empty.
|
||||
func (o *OrgProtectedTag) ToProtectedTag() *ProtectedTag {
|
||||
return &ProtectedTag{
|
||||
NamePattern: o.NamePattern,
|
||||
AllowlistTeamIDs: o.AllowlistTeamIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgProtectedTagByID retrieves a single org tag rule by org ID and rule ID.
|
||||
func GetOrgProtectedTagByID(ctx context.Context, orgID, id int64) (*OrgProtectedTag, error) {
|
||||
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "id": id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// GetOrgProtectedTagByNamePattern retrieves a single org tag rule by its pattern.
|
||||
func GetOrgProtectedTagByNamePattern(ctx context.Context, orgID int64, pattern string) (*OrgProtectedTag, error) {
|
||||
rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "name_pattern": pattern})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// FindOrgProtectedTags loads all org-level tag protection rules for an organization.
|
||||
func FindOrgProtectedTags(ctx context.Context, orgID int64) ([]*OrgProtectedTag, error) {
|
||||
var rules []*OrgProtectedTag
|
||||
err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("created_unix").Find(&rules)
|
||||
return rules, err
|
||||
}
|
||||
|
||||
// CreateOrgProtectedTag creates a new org-level tag protection rule.
|
||||
func CreateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||
if _, err := db.GetEngine(ctx).Insert(rule); err != nil {
|
||||
return fmt.Errorf("Insert OrgProtectedTag: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOrgProtectedTag updates an existing org-level tag protection rule.
|
||||
func UpdateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error {
|
||||
if _, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule); err != nil {
|
||||
return fmt.Errorf("Update OrgProtectedTag: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgProtectedTag deletes an org-level tag protection rule.
|
||||
func DeleteOrgProtectedTag(ctx context.Context, orgID, id int64) error {
|
||||
affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedTag))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("org tag protection rule ID(%d) not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserAllowedToControlTagInRepo layers org-level tag rules on top of a repo's own
|
||||
// protected tags: the user must be allowed by BOTH levels (most-restrictive). The
|
||||
// repo decision is evaluated first (using the already-loaded repoTags); if it
|
||||
// allows and the owner is an organization, the org-level rules must also allow.
|
||||
func IsUserAllowedToControlTagInRepo(ctx context.Context, repoTags []*ProtectedTag, repo *repo_model.Repository, tagName string, userID int64) (bool, error) {
|
||||
allowed, err := IsUserAllowedToControlTag(ctx, repoTags, tagName, userID)
|
||||
if err != nil || !allowed {
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
orgRules, err := FindOrgProtectedTags(ctx, owner.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(orgRules) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
orgTags := make([]*ProtectedTag, len(orgRules))
|
||||
for i, r := range orgRules {
|
||||
orgTags[i] = r.ToProtectedTag()
|
||||
}
|
||||
return IsUserAllowedToControlTag(ctx, orgTags, tagName, userID)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgPushPolicy is a single org-wide policy enforced in the pre-receive hook on
|
||||
// every repository of the organization. Unlike the branch/tag rulesets there is at
|
||||
// most one policy per org. Empty pattern / zero fields mean "no constraint". See #727.
|
||||
type OrgPushPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
BranchNamePattern string `xorm:"TEXT"`
|
||||
TagNamePattern string `xorm:"TEXT"`
|
||||
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
BlockedFilePatterns string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgPushPolicy))
|
||||
}
|
||||
|
||||
// nameMatchesPattern reports whether name satisfies a glob pattern. An empty pattern
|
||||
// imposes no constraint; an invalid pattern fails open (no constraint) so a
|
||||
// misconfigured policy never blocks all pushes.
|
||||
func nameMatchesPattern(pattern, name string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
g, err := glob.Compile(pattern, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid org push policy name pattern %q: %v", pattern, err)
|
||||
return true
|
||||
}
|
||||
return g.Match(name)
|
||||
}
|
||||
|
||||
// BranchNameAllowed reports whether a branch name satisfies the naming policy.
|
||||
func (p *OrgPushPolicy) BranchNameAllowed(name string) bool {
|
||||
return nameMatchesPattern(p.BranchNamePattern, name)
|
||||
}
|
||||
|
||||
// TagNameAllowed reports whether a tag name satisfies the naming policy.
|
||||
func (p *OrgPushPolicy) TagNameAllowed(name string) bool {
|
||||
return nameMatchesPattern(p.TagNamePattern, name)
|
||||
}
|
||||
|
||||
// BlockedFileGlobs parses the ';'-separated blocked file pattern list.
|
||||
func (p *OrgPushPolicy) BlockedFileGlobs() []glob.Glob {
|
||||
var out []glob.Glob
|
||||
for _, expr := range strings.Split(p.BlockedFilePatterns, ";") {
|
||||
expr = strings.TrimSpace(strings.ToLower(expr))
|
||||
if expr == "" {
|
||||
continue
|
||||
}
|
||||
if g, err := glob.Compile(expr, '.', '/'); err == nil {
|
||||
out = append(out, g)
|
||||
} else {
|
||||
log.Warn("Invalid org push policy blocked file pattern %q: %v", expr, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetOrgPushPolicy returns the org's push policy, or nil if none is configured.
|
||||
func GetOrgPushPolicy(ctx context.Context, orgID int64) (*OrgPushPolicy, error) {
|
||||
policy, exist, err := db.Get[OrgPushPolicy](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// GetOrgPushPolicyForRepo returns the push policy of the repo's owning organization,
|
||||
// or nil if the owner is not an organization or has no policy.
|
||||
func GetOrgPushPolicyForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgPushPolicy, error) {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return GetOrgPushPolicy(ctx, owner.ID)
|
||||
}
|
||||
|
||||
// UpsertOrgPushPolicy creates or updates the single push policy for an org.
|
||||
func UpsertOrgPushPolicy(ctx context.Context, policy *OrgPushPolicy) error {
|
||||
existing, err := GetOrgPushPolicy(ctx, policy.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(policy); err != nil {
|
||||
return fmt.Errorf("Insert OrgPushPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
policy.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil {
|
||||
return fmt.Errorf("Update OrgPushPolicy: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgPushPolicy removes an org's push policy.
|
||||
func DeleteOrgPushPolicy(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgPushPolicy))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgRepoDefaults holds an organization's default repository settings, applied to a
|
||||
// repository when it is created in or transferred into the org (via a notifier).
|
||||
// There is at most one row per org. See issue #727.
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgRepoDefaults))
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaults returns the org's repo defaults, or nil if none are configured.
|
||||
func GetOrgRepoDefaults(ctx context.Context, orgID int64) (*OrgRepoDefaults, error) {
|
||||
defaults, exist, err := db.Get[OrgRepoDefaults](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaultsForRepo returns the repo-defaults of the repo's owning org, or
|
||||
// nil if the owner is not an organization or has none configured.
|
||||
func GetOrgRepoDefaultsForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgRepoDefaults, error) {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return GetOrgRepoDefaults(ctx, owner.ID)
|
||||
}
|
||||
|
||||
// UpsertOrgRepoDefaults creates or updates the single repo-defaults row for an org.
|
||||
func UpsertOrgRepoDefaults(ctx context.Context, defaults *OrgRepoDefaults) error {
|
||||
existing, err := GetOrgRepoDefaults(ctx, defaults.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(defaults); err != nil {
|
||||
return fmt.Errorf("Insert OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defaults.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(defaults); err != nil {
|
||||
return fmt.Errorf("Update OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgRepoDefaults removes an org's repo defaults.
|
||||
func DeleteOrgRepoDefaults(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgRepoDefaults))
|
||||
return err
|
||||
}
|
||||
@@ -85,19 +85,40 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetFirstMatchProtectedBranchRule returns the first matched rule.
|
||||
// It checks repo-level rules first; if none match, it falls back to org-level rules
|
||||
// (if the repo belongs to an organization).
|
||||
// GetFirstMatchProtectedBranchRule returns the effective protected-branch rule for a
|
||||
// branch. It combines the matching repo-level rule with the matching org-level rule
|
||||
// (when the repo belongs to an organization): if both match they are layered with
|
||||
// mergeMostRestrictive so the org rule acts as a floor the repo cannot weaken; if
|
||||
// only one matches that one is returned; if neither matches, nil.
|
||||
func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
||||
rules, err := FindRepoProtectedBranchRules(ctx, repoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matched := rules.GetFirstMatched(branchName); matched != nil {
|
||||
return matched, nil
|
||||
repoRule := rules.GetFirstMatched(branchName)
|
||||
|
||||
orgRule, err := getFirstMatchOrgProtectedBranchRule(ctx, repoID, branchName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fall back to org-level rules
|
||||
switch {
|
||||
case repoRule == nil && orgRule == nil:
|
||||
return nil, nil
|
||||
case orgRule == nil:
|
||||
return repoRule, nil
|
||||
case repoRule == nil:
|
||||
return orgRule, nil
|
||||
default:
|
||||
return mergeMostRestrictive(repoRule, orgRule), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getFirstMatchOrgProtectedBranchRule returns the matching org-level rule for a
|
||||
// branch expressed as a repo-scoped ProtectedBranch (RepoID set so downstream
|
||||
// permission checks work), or nil if the repo's owner is not an organization or no
|
||||
// org rule matches.
|
||||
func getFirstMatchOrgProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -119,7 +140,7 @@ func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchN
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Convert org rule to a ProtectedBranch with RepoID set so callers work correctly
|
||||
// Convert org rule to a ProtectedBranch with RepoID set so callers work correctly.
|
||||
pb := orgRule.ToProtectedBranch()
|
||||
pb.RepoID = repoID
|
||||
return pb, nil
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import "strings"
|
||||
|
||||
// mergeMostRestrictive combines a repo-level and an org-level protected-branch rule
|
||||
// that both match the same branch into a single effective rule, always applying the
|
||||
// STRICTER constraint from each side (fail-closed). This makes an org-level rule a
|
||||
// mandatory floor that a repo rule can only tighten, never weaken. See issue #727.
|
||||
//
|
||||
// Combination directions:
|
||||
// - "Can*" / allow booleans -> AND (an action is allowed only if both allow it)
|
||||
// - "Enable*/Block*/Require*" -> OR (a gate is on if either side turns it on)
|
||||
// - RequiredApprovals -> max
|
||||
// - required-set lists -> union (status contexts, protected files)
|
||||
// - allow-set lists -> intersection (whitelists, unprotected files)
|
||||
//
|
||||
// Identity (ID, RepoID, RuleName, Priority) is taken from the repo rule so that
|
||||
// downstream permission checks (which LoadRepo via RepoID) keep working.
|
||||
func mergeMostRestrictive(repoRule, orgRule *ProtectedBranch) *ProtectedBranch {
|
||||
eff := *repoRule
|
||||
|
||||
// Direct push.
|
||||
eff.CanPush = repoRule.CanPush && orgRule.CanPush
|
||||
eff.EnableWhitelist, eff.WhitelistUserIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistUserIDs, orgRule.EnableWhitelist, orgRule.WhitelistUserIDs)
|
||||
_, eff.WhitelistTeamIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistTeamIDs, orgRule.EnableWhitelist, orgRule.WhitelistTeamIDs)
|
||||
eff.WhitelistDeployKeys = repoRule.WhitelistDeployKeys && orgRule.WhitelistDeployKeys
|
||||
eff.WhitelistActionsUser = repoRule.WhitelistActionsUser && orgRule.WhitelistActionsUser
|
||||
|
||||
// Force push.
|
||||
eff.CanForcePush = repoRule.CanForcePush && orgRule.CanForcePush
|
||||
eff.EnableForcePushAllowlist, eff.ForcePushAllowlistUserIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistUserIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistUserIDs)
|
||||
_, eff.ForcePushAllowlistTeamIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistTeamIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistTeamIDs)
|
||||
eff.ForcePushAllowlistDeployKeys = repoRule.ForcePushAllowlistDeployKeys && orgRule.ForcePushAllowlistDeployKeys
|
||||
eff.ForcePushAllowlistActionsUser = repoRule.ForcePushAllowlistActionsUser && orgRule.ForcePushAllowlistActionsUser
|
||||
|
||||
// Delete.
|
||||
eff.CanDelete = repoRule.CanDelete && orgRule.CanDelete
|
||||
eff.EnableDeleteAllowlist, eff.DeleteAllowlistUserIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistUserIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistUserIDs)
|
||||
_, eff.DeleteAllowlistTeamIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistTeamIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistTeamIDs)
|
||||
eff.DeleteAllowlistDeployKeys = repoRule.DeleteAllowlistDeployKeys && orgRule.DeleteAllowlistDeployKeys
|
||||
eff.DeleteAllowlistActionsUser = repoRule.DeleteAllowlistActionsUser && orgRule.DeleteAllowlistActionsUser
|
||||
|
||||
// Merge whitelist.
|
||||
eff.EnableMergeWhitelist, eff.MergeWhitelistUserIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistUserIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistUserIDs)
|
||||
_, eff.MergeWhitelistTeamIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistTeamIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistTeamIDs)
|
||||
eff.MergeWhitelistActionsUser = repoRule.MergeWhitelistActionsUser && orgRule.MergeWhitelistActionsUser
|
||||
|
||||
// Status checks.
|
||||
eff.EnableStatusCheck = repoRule.EnableStatusCheck || orgRule.EnableStatusCheck
|
||||
eff.StatusCheckContexts = unionStrings(repoRule.StatusCheckContexts, orgRule.StatusCheckContexts)
|
||||
|
||||
// Approvals and reviews.
|
||||
eff.RequiredApprovals = maxInt64(repoRule.RequiredApprovals, orgRule.RequiredApprovals)
|
||||
eff.EnableApprovalsWhitelist, eff.ApprovalsWhitelistUserIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistUserIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistUserIDs)
|
||||
_, eff.ApprovalsWhitelistTeamIDs = mergeAllowlist(repoRule.EnableApprovalsWhitelist, repoRule.ApprovalsWhitelistTeamIDs, orgRule.EnableApprovalsWhitelist, orgRule.ApprovalsWhitelistTeamIDs)
|
||||
eff.BlockOnRejectedReviews = repoRule.BlockOnRejectedReviews || orgRule.BlockOnRejectedReviews
|
||||
eff.BlockOnOfficialReviewRequests = repoRule.BlockOnOfficialReviewRequests || orgRule.BlockOnOfficialReviewRequests
|
||||
eff.BlockOnOutdatedBranch = repoRule.BlockOnOutdatedBranch || orgRule.BlockOnOutdatedBranch
|
||||
eff.DismissStaleApprovals = repoRule.DismissStaleApprovals || orgRule.DismissStaleApprovals
|
||||
eff.IgnoreStaleApprovals = repoRule.IgnoreStaleApprovals || orgRule.IgnoreStaleApprovals
|
||||
|
||||
// Commits, files, admin override.
|
||||
eff.RequireSignedCommits = repoRule.RequireSignedCommits || orgRule.RequireSignedCommits
|
||||
eff.ProtectedFilePatterns = unionPatterns(repoRule.ProtectedFilePatterns, orgRule.ProtectedFilePatterns)
|
||||
eff.UnprotectedFilePatterns = intersectPatterns(repoRule.UnprotectedFilePatterns, orgRule.UnprotectedFilePatterns)
|
||||
eff.BlockAdminMergeOverride = repoRule.BlockAdminMergeOverride || orgRule.BlockAdminMergeOverride
|
||||
|
||||
return &eff
|
||||
}
|
||||
|
||||
// mergeAllowlist combines two allow-lists under most-restrictive semantics. An
|
||||
// allow-list only narrows access when its Enable flag is set; a disabled list means
|
||||
// "everyone", so it imposes no constraint. Therefore: if both are enabled the result
|
||||
// is the intersection (a principal must be allowed by both); if only one is enabled
|
||||
// its list is used as-is; if neither is enabled the list is irrelevant.
|
||||
func mergeAllowlist(aEnabled bool, aIDs []int64, bEnabled bool, bIDs []int64) (bool, []int64) {
|
||||
switch {
|
||||
case aEnabled && bEnabled:
|
||||
return true, intersectInt64(aIDs, bIDs)
|
||||
case aEnabled:
|
||||
return true, aIDs
|
||||
case bEnabled:
|
||||
return true, bIDs
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func intersectInt64(a, b []int64) []int64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, x := range a {
|
||||
set[x] = struct{}{}
|
||||
}
|
||||
var out []int64
|
||||
for _, x := range b {
|
||||
if _, ok := set[x]; ok {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func unionStrings(a, b []string) []string {
|
||||
if len(a) == 0 {
|
||||
return b
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return a
|
||||
}
|
||||
seen := make(map[string]struct{}, len(a)+len(b))
|
||||
out := make([]string, 0, len(a)+len(b))
|
||||
for _, s := range a {
|
||||
if _, ok := seen[s]; !ok {
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
for _, s := range b {
|
||||
if _, ok := seen[s]; !ok {
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unionPatterns unions two ';'-separated file-pattern lists (more patterns protected
|
||||
// = more restrictive).
|
||||
func unionPatterns(a, b string) string {
|
||||
return strings.Join(unionStrings(splitPatterns(a), splitPatterns(b)), ";")
|
||||
}
|
||||
|
||||
// intersectPatterns intersects two ';'-separated file-pattern lists. Unprotected
|
||||
// patterns are carve-outs that REDUCE protection, so the restrictive combination
|
||||
// keeps only the exemptions present in both.
|
||||
func intersectPatterns(a, b string) string {
|
||||
as, bs := splitPatterns(a), splitPatterns(b)
|
||||
set := make(map[string]struct{}, len(as))
|
||||
for _, s := range as {
|
||||
set[s] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(bs))
|
||||
var out []string
|
||||
for _, s := range bs {
|
||||
if _, ok := set[s]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[s]; dup {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return strings.Join(out, ";")
|
||||
}
|
||||
|
||||
func splitPatterns(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ";") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maxInt64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -33,6 +33,211 @@ func (IssueStatusDef) TableName() string {
|
||||
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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
||||
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 {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
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:] {
|
||||
user = strings.TrimPrefix(user, "@")
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -370,6 +373,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||
// each compiled rule must enforce a bounded match time.
|
||||
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||
start := time.Now()
|
||||
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||
}
|
||||
|
||||
func testGetApprovers(t *testing.T) {
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||
|
||||
@@ -438,6 +438,12 @@ func prepareMigrationTasks() []*migration {
|
||||
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),
|
||||
newMigration(362, "Add delete allowlist to org protected branch", v1_27.AddDeleteAllowlistToOrgProtectedBranch),
|
||||
newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable),
|
||||
newMigration(364, "Add org push policy table", v1_27.AddOrgPushPolicyTable),
|
||||
newMigration(365, "Add org repo defaults table", v1_27.AddOrgRepoDefaultsTable),
|
||||
newMigration(366, "Add org email domain policy table", v1_27.AddOrgEmailDomainPolicyTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddCascadeMergeRuleTable(x *xorm.Engine) error {
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(CascadeMergeRule))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
// AddDeleteAllowlistToOrgProtectedBranch adds branch-deletion protection columns to
|
||||
// org-level branch protection rules, mirroring the per-repo delete allowlist so an
|
||||
// org rule can also protect branches from deletion. See issue #727.
|
||||
func AddDeleteAllowlistToOrgProtectedBranch(x *xorm.Engine) error {
|
||||
type OrgProtectedBranch struct {
|
||||
CanDelete bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableDeleteAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DeleteAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
}
|
||||
return x.Sync(new(OrgProtectedBranch))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgProtectedTagTable creates the org-level tag protection table. Org tag rules
|
||||
// cascade to all repositories in the organization and layer on top of each repo's
|
||||
// own protected tags. See issue #727.
|
||||
func AddOrgProtectedTagTable(x *xorm.Engine) error {
|
||||
type OrgProtectedTag struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE(s) index"`
|
||||
NamePattern string `xorm:"UNIQUE(s)"`
|
||||
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgProtectedTag))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgPushPolicyTable creates the org-level push policy table (one row per org),
|
||||
// enforced in the pre-receive hook across all repositories of the org. See #727.
|
||||
func AddOrgPushPolicyTable(x *xorm.Engine) error {
|
||||
type OrgPushPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
BranchNamePattern string `xorm:"TEXT"`
|
||||
TagNamePattern string `xorm:"TEXT"`
|
||||
RequireSecretBlock bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MaxFileSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
BlockedFilePatterns string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgPushPolicy))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgRepoDefaultsTable creates the org repository-defaults table (one row per
|
||||
// org), applied to repositories created in or transferred into the org. See #727.
|
||||
func AddOrgRepoDefaultsTable(x *xorm.Engine) error {
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgRepoDefaults))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AddOrgEmailDomainPolicyTable creates the org email-domain policy table (one row
|
||||
// per org) restricting the email domains of members added to the org. See #727.
|
||||
func AddOrgEmailDomainPolicyTable(x *xorm.Engine) error {
|
||||
type OrgEmailDomainPolicy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
AllowedDomains string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgEmailDomainPolicy))
|
||||
}
|
||||
@@ -323,6 +323,60 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if !owner.CanCreateOrganization() {
|
||||
return ErrUserNotAllowedCreateOrg{}
|
||||
@@ -348,7 +402,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
||||
}
|
||||
org.UseCustomAvatar = true
|
||||
org.MaxRepoCreation = -1
|
||||
org.NumTeams = 1
|
||||
org.NumTeams = 1 + len(DefaultOrgTeams)
|
||||
org.NumMembers = 1
|
||||
org.Type = user_model.UserTypeOrganization
|
||||
|
||||
@@ -413,6 +467,37 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
||||
}); err != nil {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AutoMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CascadeMergeRule))
|
||||
}
|
||||
|
||||
func GetCascadeRulesByRepoID(ctx context.Context, repoID int64) ([]*CascadeMergeRule, error) {
|
||||
rules := make([]*CascadeMergeRule, 0)
|
||||
return rules, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&rules)
|
||||
}
|
||||
|
||||
func GetCascadeRulesForBranch(ctx context.Context, repoID int64, sourceBranch string) ([]*CascadeMergeRule, error) {
|
||||
rules := make([]*CascadeMergeRule, 0)
|
||||
return rules, db.GetEngine(ctx).Where("repo_id = ? AND source_branch = ? AND enabled = ?", repoID, sourceBranch, true).Find(&rules)
|
||||
}
|
||||
|
||||
func GetCascadeRuleByID(ctx context.Context, id int64) (*CascadeMergeRule, error) {
|
||||
rule := &CascadeMergeRule{ID: id}
|
||||
has, err := db.GetEngine(ctx).Get(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func CreateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||
_, err := db.GetEngine(ctx).Insert(rule)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error {
|
||||
_, err := db.GetEngine(ctx).ID(rule.ID).Cols("source_branch", "target_branch", "enabled", "auto_merge").Update(rule)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteCascadeRule(ctx context.Context, repoID, id int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", id, repoID).Delete(&CascadeMergeRule{})
|
||||
return err
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
||||
}
|
||||
|
||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
|
||||
for _, condition := range conditions {
|
||||
switch cond := condition.(type) {
|
||||
case *testCond:
|
||||
|
||||
@@ -80,8 +80,11 @@ func init() {
|
||||
}
|
||||
|
||||
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
||||
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(externalLoginUser)
|
||||
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
|
||||
return db.Get[ExternalLoginUser](ctx, builder.Eq{
|
||||
"external_id": externalID,
|
||||
"login_source_id": loginSourceID,
|
||||
})
|
||||
}
|
||||
|
||||
// LinkExternalToUser link the external user to the user
|
||||
@@ -118,6 +121,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
|
||||
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
|
||||
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
||||
var id int64
|
||||
|
||||
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
|
||||
// GetLastCommitForPaths returns last commit information
|
||||
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
|
||||
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||
revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
|
||||
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
|
||||
require.NoError(t, err)
|
||||
entries, err := commit.Tree.ListEntries()
|
||||
require.NoError(t, err)
|
||||
|
||||
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
|
||||
for _, info := range infos {
|
||||
nilCommits += util.Iif(info.Commit == nil, 1, 0)
|
||||
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
|
||||
}
|
||||
return nilCommits, nonNilCommits
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
|
||||
|
||||
walkGitLogDebugBeforeNext = cancel
|
||||
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
|
||||
assert.NoError(t, err)
|
||||
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
|
||||
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
|
||||
assert.Equal(t, 3, nilCommits)
|
||||
|
||||
walkGitLogDebugBeforeNext = nil
|
||||
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
|
||||
assert.NoError(t, err)
|
||||
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
|
||||
assert.Equal(t, 3, nonNilCommits)
|
||||
assert.Equal(t, 0, nilCommits)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
|
||||
entryPaths[i] = entry.Name()
|
||||
}
|
||||
|
||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !gogit
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
@@ -18,10 +20,8 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
|
||||
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
|
||||
|
||||
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
|
||||
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
|
||||
cmd := gitcmd.NewCommand()
|
||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
||||
|
||||
@@ -54,77 +54,62 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
|
||||
ctx, ctxCancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||
|
||||
return bufReader, func() {
|
||||
ctxCancel()
|
||||
stdoutReaderClose()
|
||||
return &logNameStatusRepoParser{
|
||||
treepath: treepath,
|
||||
paths: paths,
|
||||
rd: bufReader,
|
||||
close: func() {
|
||||
ctxCancel()
|
||||
stdoutReaderClose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||
type LogNameStatusRepoParser struct {
|
||||
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||
type logNameStatusRepoParser struct {
|
||||
treepath string
|
||||
paths []string
|
||||
next []byte
|
||||
buffull bool
|
||||
rd *bufio.Reader
|
||||
cancel func()
|
||||
close func()
|
||||
}
|
||||
|
||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
||||
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 {
|
||||
// logNameStatusCommitData represents a commit artifact from git log raw
|
||||
type logNameStatusCommitData struct {
|
||||
CommitID string
|
||||
ParentIDs []string
|
||||
Paths []bool
|
||||
}
|
||||
|
||||
// Next returns the next LogStatusCommitData
|
||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
||||
// walkNext returns the next LogStatusCommitData
|
||||
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
|
||||
var err error
|
||||
if len(g.next) == 0 {
|
||||
g.buffull = false
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, bufio.ErrBufferFull):
|
||||
g.buffull = true
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ret := LogNameStatusCommitData{}
|
||||
ret := logNameStatusCommitData{}
|
||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||
g.next, err = g.rd.ReadSlice('\x00')
|
||||
if err != nil {
|
||||
switch err {
|
||||
case bufio.ErrBufferFull:
|
||||
g.buffull = true
|
||||
case io.EOF:
|
||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, bufio.ErrBufferFull):
|
||||
g.buffull = true
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,13 +258,10 @@ diffloop:
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the parser
|
||||
func (g *LogNameStatusRepoParser) Close() {
|
||||
g.cancel()
|
||||
}
|
||||
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
headRef := head.ID.String()
|
||||
|
||||
tree, err := head.SubTree(treepath)
|
||||
@@ -322,11 +304,9 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
||||
}
|
||||
}
|
||||
|
||||
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||
// don't use defer g.Close() here as g may change its value - instead wrap in a func
|
||||
defer func() {
|
||||
g.Close()
|
||||
}()
|
||||
g := logNameStatusRepo(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
|
||||
defer func() { g.close() }()
|
||||
|
||||
results := make([]string, len(paths))
|
||||
remaining := len(paths)
|
||||
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
||||
|
||||
heaploop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
if walkGitLogDebugBeforeNext != nil {
|
||||
walkGitLogDebugBeforeNext()
|
||||
}
|
||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
break heaploop
|
||||
}
|
||||
g.Close()
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
break heaploop
|
||||
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
|
||||
if ctx.Err() != nil {
|
||||
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
|
||||
} else if errors.Is(err, io.EOF) {
|
||||
break heaploop // reached to the end of log output
|
||||
} else if err != nil {
|
||||
return nil, err // other unknown errors
|
||||
}
|
||||
parentRemaining.Remove(current.CommitID)
|
||||
for i, found := range current.Paths {
|
||||
@@ -395,14 +366,14 @@ heaploop:
|
||||
if remaining <= nextRestart {
|
||||
commitSinceNextRestart++
|
||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||
g.Close()
|
||||
remainingPaths := make([]string, 0, len(paths))
|
||||
for i, pth := range paths {
|
||||
if results[i] == "" {
|
||||
remainingPaths = append(remainingPaths, pth)
|
||||
}
|
||||
}
|
||||
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||
g.close()
|
||||
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||
parentRemaining = make(container.Set[string])
|
||||
nextRestart = (remaining * 3) / 4
|
||||
continue heaploop
|
||||
@@ -410,7 +381,6 @@ heaploop:
|
||||
}
|
||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||
}
|
||||
g.Close()
|
||||
|
||||
resultsMap := map[string]string{}
|
||||
for i, pth := range paths {
|
||||
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
// 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 {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, isEmpty)
|
||||
}
|
||||
|
||||
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||
func TestCloneRefusesRedirects(t *testing.T) {
|
||||
var targetHit atomic.Bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHit.Store(true)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
|
||||
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HostMatchList is used to check if a host or IP is in a list.
|
||||
@@ -23,10 +24,61 @@ type HostMatchList struct {
|
||||
ipNets []*net.IPNet
|
||||
}
|
||||
|
||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
||||
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||
const MatchBuiltinExternal = "external"
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
||||
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range []string{
|
||||
// IPv4
|
||||
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||
// IPv6
|
||||
"100::/64", // RFC 6666 discard-only
|
||||
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||
"2001:db8::/32", // RFC 3849 documentation
|
||||
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||
} {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
})
|
||||
|
||||
// isReservedIP reports whether ip falls in reserved special-purpose
|
||||
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||
func isReservedIP(ip net.IP) bool {
|
||||
for _, ipNet := range reservedIPNets() {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||
// Also called LAN/Intranet.
|
||||
const MatchBuiltinPrivate = "private"
|
||||
|
||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||
@@ -93,18 +145,22 @@ func (hl *HostMatchList) checkPattern(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
||||
// matchesIP determines if the given IP matches any of the configured rules
|
||||
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
|
||||
if slices.Contains(hl.patterns, "*") {
|
||||
return true
|
||||
}
|
||||
for _, builtin := range hl.builtins {
|
||||
switch builtin {
|
||||
case MatchBuiltinExternal:
|
||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
||||
// External address must be a global unicast, must not be in reserved range and must not be in private range
|
||||
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinPrivate:
|
||||
if ip.IsPrivate() {
|
||||
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
|
||||
// and must be in private range
|
||||
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinLoopback:
|
||||
@@ -135,7 +191,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
return hl.checkIP(ip)
|
||||
return hl.matchesIP(ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -146,7 +202,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
||||
return hl.checkPattern(host) || hl.checkIP(ip)
|
||||
return hl.checkPattern(host) || hl.matchesIP(ip)
|
||||
}
|
||||
|
||||
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
||||
|
||||
@@ -159,3 +159,60 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
|
||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
// A binary package control file holds exactly one stanza. Stop at the
|
||||
// blank line that terminates it, otherwise a crafted control file could
|
||||
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||
// fields) into the generated repository "Packages" index.
|
||||
if control.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
|
||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +169,24 @@ type IssueStatusDef struct {
|
||||
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
|
||||
// swagger:model
|
||||
type IssuePriorityDef struct {
|
||||
|
||||
@@ -17,6 +17,9 @@ type OrgBranchProtection struct {
|
||||
EnableForcePush bool `json:"enable_force_push"`
|
||||
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
EnableDelete bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
EnableStatusCheck bool `json:"enable_status_check"`
|
||||
@@ -49,6 +52,9 @@ type CreateOrgBranchProtectionOption struct {
|
||||
EnableForcePush bool `json:"enable_force_push"`
|
||||
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
EnableDelete bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
EnableStatusCheck bool `json:"enable_status_check"`
|
||||
@@ -76,6 +82,9 @@ type EditOrgBranchProtectionOption struct {
|
||||
EnableForcePush *bool `json:"enable_force_push"`
|
||||
EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"`
|
||||
ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"`
|
||||
EnableDelete *bool `json:"enable_delete"`
|
||||
EnableDeleteAllowlist *bool `json:"enable_delete_allowlist"`
|
||||
DeleteAllowlistTeams []string `json:"delete_allowlist_teams"`
|
||||
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||
EnableStatusCheck *bool `json:"enable_status_check"`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
// OrgEmailDomainPolicy represents an organization's email domain policy
|
||||
type OrgEmailDomainPolicy struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
AllowedDomains string `json:"allowed_domains"`
|
||||
}
|
||||
|
||||
// EditOrgEmailDomainPolicyOption options for editing an org's email domain policy
|
||||
type EditOrgEmailDomainPolicyOption struct {
|
||||
AllowedDomains *string `json:"allowed_domains"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// OrgPushPolicy represents an organization's push policy (one per org)
|
||||
type OrgPushPolicy struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
BranchNamePattern string `json:"branch_name_pattern"`
|
||||
TagNamePattern string `json:"tag_name_pattern"`
|
||||
RequireSecretBlock bool `json:"require_secret_block"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
BlockedFilePatterns string `json:"blocked_file_patterns"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EditOrgPushPolicyOption options for editing an organization's push policy. Only
|
||||
// fields that are set will be changed.
|
||||
type EditOrgPushPolicyOption struct {
|
||||
BranchNamePattern *string `json:"branch_name_pattern"`
|
||||
TagNamePattern *string `json:"tag_name_pattern"`
|
||||
RequireSecretBlock *bool `json:"require_secret_block"`
|
||||
MaxFileSize *int64 `json:"max_file_size"`
|
||||
BlockedFilePatterns *string `json:"blocked_file_patterns"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
// OrgRepoDefaults represents an organization's default repository settings
|
||||
type OrgRepoDefaults struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
ForcePrivate bool `json:"force_private"`
|
||||
ApplyPRDefaults bool `json:"apply_pr_defaults"`
|
||||
AllowMerge bool `json:"allow_merge"`
|
||||
AllowRebase bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge bool `json:"allow_rebase_merge"`
|
||||
AllowSquash bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
|
||||
// EditOrgRepoDefaultsOption options for editing an org's repo defaults. Only fields
|
||||
// that are set will be changed.
|
||||
type EditOrgRepoDefaultsOption struct {
|
||||
ForcePrivate *bool `json:"force_private"`
|
||||
ApplyPRDefaults *bool `json:"apply_pr_defaults"`
|
||||
AllowMerge *bool `json:"allow_merge"`
|
||||
AllowRebase *bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
||||
AllowSquash *bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly *bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle *string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// OrgTagProtection represents an org-level tag protection rule
|
||||
type OrgTagProtection struct {
|
||||
ID int64 `json:"id"`
|
||||
OrgID int64 `json:"org_id"`
|
||||
NamePattern string `json:"name_pattern"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateOrgTagProtectionOption options for creating an org-level tag protection
|
||||
type CreateOrgTagProtectionOption struct {
|
||||
NamePattern string `json:"name_pattern" binding:"Required"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
}
|
||||
|
||||
// EditOrgTagProtectionOption options for editing an org-level tag protection
|
||||
type EditOrgTagProtectionOption struct {
|
||||
NamePattern *string `json:"name_pattern"`
|
||||
WhitelistTeams []string `json:"whitelist_teams"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// CascadeMergeRule represents a cascade merge rule
|
||||
type CascadeMergeRule struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AutoMerge bool `json:"auto_merge"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateCascadeMergeRuleOption options for creating a cascade merge rule
|
||||
type CreateCascadeMergeRuleOption struct {
|
||||
SourceBranch string `json:"source_branch" binding:"Required"`
|
||||
TargetBranch string `json:"target_branch" binding:"Required"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AutoMerge *bool `json:"auto_merge"`
|
||||
}
|
||||
|
||||
// EditCascadeMergeRuleOption options for editing a cascade merge rule
|
||||
type EditCascadeMergeRuleOption struct {
|
||||
SourceBranch *string `json:"source_branch"`
|
||||
TargetBranch *string `json:"target_branch"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AutoMerge *bool `json:"auto_merge"`
|
||||
}
|
||||
@@ -2411,6 +2411,56 @@
|
||||
"repo.settings.protected_branch": "Branch Protection",
|
||||
"repo.settings.protected_branch.save_rule": "Save Rule",
|
||||
"repo.settings.protected_branch.delete_rule": "Delete Rule",
|
||||
"repo.settings.org_protected_branch": "Organization Branch Protection",
|
||||
"repo.settings.org_protected_branch_desc": "These rules are defined by the organization and are enforced on top of this repository's own rules — the stricter of the two applies. They cannot be edited here.",
|
||||
"repo.settings.org_protected_branch.inherited": "Organization",
|
||||
"repo.settings.org_protected_branch.read_only": "Read-only",
|
||||
"repo.settings.org_protected_branch.approvals": "Required approvals",
|
||||
"repo.settings.org_protected_branch.signed": "Signed commits",
|
||||
"repo.settings.org_protected_branch.status_check": "Required status checks",
|
||||
"repo.settings.org_protected_branch.direct_push": "Direct push",
|
||||
"repo.settings.org_protected_branch.force_push": "Force push",
|
||||
"repo.settings.org_protected_branch.deletion": "Branch deletion",
|
||||
"repo.settings.org_protected_branch.merge": "Merge restricted to",
|
||||
"repo.settings.org_protected_branch.protected_files": "Protected files",
|
||||
"repo.settings.org_protected_branch.also": "Also enforces",
|
||||
"repo.settings.org_protected_branch.blocked": "Blocked",
|
||||
"repo.settings.org_protected_branch.allowed": "Allowed",
|
||||
"repo.settings.org_protected_branch.restricted": "Restricted to specific teams",
|
||||
"repo.settings.org_protected_branch.write_access": "Anyone with write access",
|
||||
"repo.settings.org_protected_branch.teams": "Teams: %s",
|
||||
"repo.settings.org_protected_branch.any": "Any configured checks",
|
||||
"repo.settings.org_protected_branch.block_outdated": "Block on outdated branch",
|
||||
"repo.settings.org_protected_branch.block_rejected": "Block on rejected reviews",
|
||||
"repo.settings.org_protected_branch.block_admin": "Block admin merge override",
|
||||
"repo.settings.org_protected_tag": "Organization Tag Protection",
|
||||
"repo.settings.org_protected_tag_desc": "These tag protection rules are defined by the organization and are enforced on top of this repository's own rules. They cannot be edited here.",
|
||||
"repo.settings.org_protected_tag.read_only": "Read-only",
|
||||
"repo.settings.org_push_policy": "Organization Push Policy",
|
||||
"repo.settings.org_push_policy_desc": "This push policy is defined by the organization and enforced on every push to this repository. It cannot be edited here.",
|
||||
"repo.settings.org_push_policy.inherited": "Organization",
|
||||
"repo.settings.org_push_policy.read_only": "Read-only",
|
||||
"repo.settings.org_push_policy.branch_pattern": "Allowed branch names",
|
||||
"repo.settings.org_push_policy.tag_pattern": "Allowed tag names",
|
||||
"repo.settings.org_push_policy.require_secret_block": "Block pushes containing secrets",
|
||||
"repo.settings.org_push_policy.max_file_size": "Maximum file size",
|
||||
"repo.settings.org_push_policy.blocked_files": "Blocked file paths",
|
||||
"repo.settings.org_push_policy.enabled": "Enabled",
|
||||
"repo.settings.org_repo_defaults": "Organization Repository Defaults",
|
||||
"repo.settings.org_repo_defaults_desc": "These defaults are defined by the organization and were applied to this repository when it was created or transferred into the organization. They are shown for reference and cannot be edited here.",
|
||||
"repo.settings.org_repo_defaults.inherited": "Organization",
|
||||
"repo.settings.org_repo_defaults.read_only": "Read-only",
|
||||
"repo.settings.org_repo_defaults.force_private": "Force private repositories",
|
||||
"repo.settings.org_repo_defaults.merge_styles": "Allowed merge styles",
|
||||
"repo.settings.org_repo_defaults.default_merge_style": "Default merge style",
|
||||
"repo.settings.org_repo_defaults.delete_branch_after_merge": "Delete branch after merge",
|
||||
"repo.settings.org_repo_defaults.style_merge": "Merge commits",
|
||||
"repo.settings.org_repo_defaults.style_rebase": "Rebase",
|
||||
"repo.settings.org_repo_defaults.style_rebase_merge": "Rebase with merge commit",
|
||||
"repo.settings.org_repo_defaults.style_squash": "Squash",
|
||||
"repo.settings.org_repo_defaults.style_fast_forward_only": "Fast-forward only",
|
||||
"repo.settings.org_repo_defaults.enabled": "Enabled",
|
||||
"repo.settings.org_repo_defaults.disabled": "Disabled",
|
||||
"repo.settings.protected_branch_can_push": "Allow push?",
|
||||
"repo.settings.protected_branch_can_push_yes": "You can push",
|
||||
"repo.settings.protected_branch_can_push_no": "You cannot push",
|
||||
@@ -3009,6 +3059,11 @@
|
||||
"org.settings.issue_status_created": "Issue status created.",
|
||||
"org.settings.issue_status_updated": "Issue status updated.",
|
||||
"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_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.",
|
||||
|
||||
+123
-35
@@ -508,41 +508,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be an team member, or a site admin
|
||||
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.Org.Organization == nil {
|
||||
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||
return
|
||||
}
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
return 0, true, true
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
orgID = ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
} else if isOwner {
|
||||
return orgID, true, true
|
||||
}
|
||||
|
||||
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
}
|
||||
return orgID, isTeamMember, true
|
||||
}
|
||||
|
||||
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be a team member, or a site admin
|
||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
return
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if isOwner {
|
||||
return
|
||||
}
|
||||
|
||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isTeamMember {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,6 +1300,23 @@ func Routes() *web.Router {
|
||||
})
|
||||
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
|
||||
}, 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.Get("", repo.ListTags)
|
||||
m.Get("/*", repo.GetTag)
|
||||
@@ -1745,7 +1800,7 @@ func Routes() *web.Router {
|
||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||
})
|
||||
}, reqOrgVisible())
|
||||
m.Group("/hooks", func() {
|
||||
m.Combo("").Get(org.ListHooks).
|
||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||
@@ -1768,6 +1823,34 @@ func Routes() *web.Router {
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/tag_protections", func() {
|
||||
m.Combo("").Get(org.ListOrgTagProtections).
|
||||
Post(bind(api.CreateOrgTagProtectionOption{}), org.CreateOrgTagProtection)
|
||||
m.Group("/{id}", func() {
|
||||
m.Get("", org.GetOrgTagProtection)
|
||||
m.Patch("", bind(api.EditOrgTagProtectionOption{}), org.EditOrgTagProtection)
|
||||
m.Delete("", org.DeleteOrgTagProtection)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/push_policy", func() {
|
||||
m.Combo("").Get(org.GetOrgPushPolicy).
|
||||
Patch(bind(api.EditOrgPushPolicyOption{}), org.EditOrgPushPolicy).
|
||||
Delete(org.DeleteOrgPushPolicy)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/repo_defaults", func() {
|
||||
m.Combo("").Get(org.GetOrgRepoDefaults).
|
||||
Patch(bind(api.EditOrgRepoDefaultsOption{}), org.EditOrgRepoDefaults).
|
||||
Delete(org.DeleteOrgRepoDefaults)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/email_domain_policy", func() {
|
||||
m.Combo("").Get(org.GetOrgEmailDomainPolicy).
|
||||
Patch(bind(api.EditOrgEmailDomainPolicyOption{}), org.EditOrgEmailDomainPolicy).
|
||||
Delete(org.DeleteOrgEmailDomainPolicy)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/blocks", func() {
|
||||
m.Get("", org.ListBlocks)
|
||||
m.Group("/{username}", func() {
|
||||
@@ -1782,6 +1865,11 @@ func Routes() *web.Router {
|
||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||
})
|
||||
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-types", org.ListIssueTypes)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
@@ -1799,12 +1887,12 @@ func Routes() *web.Router {
|
||||
m.Group("/repos", func() {
|
||||
m.Get("", reqToken(), org.GetTeamRepos)
|
||||
m.Combo("/{org}/{reponame}").
|
||||
Put(reqToken(), org.AddTeamRepository).
|
||||
Delete(reqToken(), org.RemoveTeamRepository).
|
||||
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
|
||||
Get(reqToken(), org.GetTeamRepo)
|
||||
})
|
||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
|
||||
|
||||
m.Group("/admin", func() {
|
||||
m.Group("/cron", func() {
|
||||
|
||||
@@ -47,6 +47,9 @@ func toAPIOrgBranchProtection(ctx *context.APIContext, rule *git_model.OrgProtec
|
||||
EnableForcePush: rule.CanForcePush,
|
||||
EnableForcePushAllowlist: rule.EnableForcePushAllowlist,
|
||||
ForcePushAllowlistTeams: resolveTeamNames(rule.ForcePushAllowlistTeamIDs),
|
||||
EnableDelete: rule.CanDelete,
|
||||
EnableDeleteAllowlist: rule.EnableDeleteAllowlist,
|
||||
DeleteAllowlistTeams: resolveTeamNames(rule.DeleteAllowlistTeamIDs),
|
||||
EnableMergeWhitelist: rule.EnableMergeWhitelist,
|
||||
MergeWhitelistTeams: resolveTeamNames(rule.MergeWhitelistTeamIDs),
|
||||
EnableStatusCheck: rule.EnableStatusCheck,
|
||||
@@ -211,6 +214,10 @@ func CreateOrgBranchProtection(ctx *context.APIContext) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deleteTeams, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rule := &git_model.OrgProtectedBranch{
|
||||
OrgID: orgID,
|
||||
@@ -222,6 +229,9 @@ func CreateOrgBranchProtection(ctx *context.APIContext) {
|
||||
CanForcePush: form.EnablePush && form.EnableForcePush,
|
||||
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
|
||||
ForcePushAllowlistTeamIDs: forcePushTeams,
|
||||
CanDelete: form.EnableDelete,
|
||||
EnableDeleteAllowlist: form.EnableDelete && form.EnableDeleteAllowlist,
|
||||
DeleteAllowlistTeamIDs: deleteTeams,
|
||||
EnableMergeWhitelist: form.EnableMergeWhitelist,
|
||||
MergeWhitelistTeamIDs: mergeTeams,
|
||||
EnableStatusCheck: form.EnableStatusCheck,
|
||||
@@ -323,6 +333,19 @@ func EditOrgBranchProtection(ctx *context.APIContext) {
|
||||
}
|
||||
rule.ForcePushAllowlistTeamIDs = ids
|
||||
}
|
||||
if form.EnableDelete != nil {
|
||||
rule.CanDelete = *form.EnableDelete
|
||||
}
|
||||
if form.EnableDeleteAllowlist != nil {
|
||||
rule.EnableDeleteAllowlist = *form.EnableDeleteAllowlist
|
||||
}
|
||||
if form.DeleteAllowlistTeams != nil {
|
||||
ids, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rule.DeleteAllowlistTeamIDs = ids
|
||||
}
|
||||
if form.EnableMergeWhitelist != nil {
|
||||
rule.EnableMergeWhitelist = *form.EnableMergeWhitelist
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
func toAPIOrgEmailDomainPolicy(policy *git_model.OrgEmailDomainPolicy, orgID int64) *api.OrgEmailDomainPolicy {
|
||||
if policy == nil {
|
||||
return &api.OrgEmailDomainPolicy{OrgID: orgID}
|
||||
}
|
||||
return &api.OrgEmailDomainPolicy{
|
||||
OrgID: policy.OrgID,
|
||||
AllowedDomains: policy.AllowedDomains,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgEmailDomainPolicy get the organization's email domain policy
|
||||
func GetOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// EditOrgEmailDomainPolicy create or update the organization's email domain policy
|
||||
func EditOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgEmailDomainPolicyOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if policy == nil {
|
||||
policy = &git_model.OrgEmailDomainPolicy{OrgID: orgID}
|
||||
}
|
||||
if form.AllowedDomains != nil {
|
||||
policy.AllowedDomains = *form.AllowedDomains
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgEmailDomainPolicy(ctx, policy); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgEmailDomainPolicy remove the organization's email domain policy
|
||||
func DeleteOrgEmailDomainPolicy(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgEmailDomainPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -6,7 +6,9 @@ package org
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
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"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
@@ -162,3 +164,124 @@ func ListIssueTypes(ctx *context.APIContext) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// toAPIOrgPushPolicy converts the model to its API representation. A nil policy is
|
||||
// rendered as an all-empty policy so clients always get a consistent shape.
|
||||
func toAPIOrgPushPolicy(policy *git_model.OrgPushPolicy, orgID int64) *api.OrgPushPolicy {
|
||||
if policy == nil {
|
||||
return &api.OrgPushPolicy{OrgID: orgID}
|
||||
}
|
||||
return &api.OrgPushPolicy{
|
||||
OrgID: policy.OrgID,
|
||||
BranchNamePattern: policy.BranchNamePattern,
|
||||
TagNamePattern: policy.TagNamePattern,
|
||||
RequireSecretBlock: policy.RequireSecretBlock,
|
||||
MaxFileSize: policy.MaxFileSize,
|
||||
BlockedFilePatterns: policy.BlockedFilePatterns,
|
||||
Created: policy.CreatedUnix.AsTime(),
|
||||
Updated: policy.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgPushPolicy get the organization's push policy
|
||||
func GetOrgPushPolicy(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// EditOrgPushPolicy create or update the organization's push policy
|
||||
func EditOrgPushPolicy(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgPushPolicyOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
policy, err := git_model.GetOrgPushPolicy(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if policy == nil {
|
||||
policy = &git_model.OrgPushPolicy{OrgID: orgID}
|
||||
}
|
||||
|
||||
if form.BranchNamePattern != nil {
|
||||
policy.BranchNamePattern = *form.BranchNamePattern
|
||||
}
|
||||
if form.TagNamePattern != nil {
|
||||
policy.TagNamePattern = *form.TagNamePattern
|
||||
}
|
||||
if form.RequireSecretBlock != nil {
|
||||
policy.RequireSecretBlock = *form.RequireSecretBlock
|
||||
}
|
||||
if form.MaxFileSize != nil {
|
||||
policy.MaxFileSize = *form.MaxFileSize
|
||||
}
|
||||
if form.BlockedFilePatterns != nil {
|
||||
policy.BlockedFilePatterns = *form.BlockedFilePatterns
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgPushPolicy(ctx, policy); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgPushPolicy(policy, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgPushPolicy remove the organization's push policy
|
||||
func DeleteOrgPushPolicy(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgPushPolicy(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// toAPIOrgRepoDefaults converts the model to its API representation. A nil value is
|
||||
// rendered as the effective defaults (all merge styles allowed) so clients always
|
||||
// get a consistent shape.
|
||||
func toAPIOrgRepoDefaults(d *git_model.OrgRepoDefaults, orgID int64) *api.OrgRepoDefaults {
|
||||
if d == nil {
|
||||
return &api.OrgRepoDefaults{
|
||||
OrgID: orgID,
|
||||
AllowMerge: true,
|
||||
AllowRebase: true,
|
||||
AllowRebaseMerge: true,
|
||||
AllowSquash: true,
|
||||
AllowFastForwardOnly: true,
|
||||
}
|
||||
}
|
||||
return &api.OrgRepoDefaults{
|
||||
OrgID: d.OrgID,
|
||||
ForcePrivate: d.ForcePrivate,
|
||||
ApplyPRDefaults: d.ApplyPRDefaults,
|
||||
AllowMerge: d.AllowMerge,
|
||||
AllowRebase: d.AllowRebase,
|
||||
AllowRebaseMerge: d.AllowRebaseMerge,
|
||||
AllowSquash: d.AllowSquash,
|
||||
AllowFastForwardOnly: d.AllowFastForwardOnly,
|
||||
DefaultMergeStyle: d.DefaultMergeStyle,
|
||||
DeleteBranchAfterMerge: d.DeleteBranchAfterMerge,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaults get the organization's default repository settings
|
||||
func GetOrgRepoDefaults(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||
}
|
||||
|
||||
// EditOrgRepoDefaults create or update the organization's default repository settings
|
||||
func EditOrgRepoDefaults(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgRepoDefaultsOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if defaults == nil {
|
||||
defaults = &git_model.OrgRepoDefaults{
|
||||
OrgID: orgID,
|
||||
AllowMerge: true,
|
||||
AllowRebase: true,
|
||||
AllowRebaseMerge: true,
|
||||
AllowSquash: true,
|
||||
AllowFastForwardOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
if form.ForcePrivate != nil {
|
||||
defaults.ForcePrivate = *form.ForcePrivate
|
||||
}
|
||||
if form.ApplyPRDefaults != nil {
|
||||
defaults.ApplyPRDefaults = *form.ApplyPRDefaults
|
||||
}
|
||||
if form.AllowMerge != nil {
|
||||
defaults.AllowMerge = *form.AllowMerge
|
||||
}
|
||||
if form.AllowRebase != nil {
|
||||
defaults.AllowRebase = *form.AllowRebase
|
||||
}
|
||||
if form.AllowRebaseMerge != nil {
|
||||
defaults.AllowRebaseMerge = *form.AllowRebaseMerge
|
||||
}
|
||||
if form.AllowSquash != nil {
|
||||
defaults.AllowSquash = *form.AllowSquash
|
||||
}
|
||||
if form.AllowFastForwardOnly != nil {
|
||||
defaults.AllowFastForwardOnly = *form.AllowFastForwardOnly
|
||||
}
|
||||
if form.DefaultMergeStyle != nil {
|
||||
defaults.DefaultMergeStyle = *form.DefaultMergeStyle
|
||||
}
|
||||
if form.DeleteBranchAfterMerge != nil {
|
||||
defaults.DeleteBranchAfterMerge = *form.DeleteBranchAfterMerge
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgRepoDefaults(ctx, defaults); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgRepoDefaults remove the organization's default repository settings
|
||||
func DeleteOrgRepoDefaults(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgRepoDefaults(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// toAPIOrgTagProtection converts an org tag rule to its API representation.
|
||||
func toAPIOrgTagProtection(ctx *context.APIContext, rule *git_model.OrgProtectedTag) *api.OrgTagProtection {
|
||||
teams, err := organization.FindOrgTeams(ctx, rule.OrgID)
|
||||
if err != nil {
|
||||
teams = nil
|
||||
}
|
||||
teamNamesByID := make(map[int64]string, len(teams))
|
||||
for _, t := range teams {
|
||||
teamNamesByID[t.ID] = t.Name
|
||||
}
|
||||
names := make([]string, 0, len(rule.AllowlistTeamIDs))
|
||||
for _, id := range rule.AllowlistTeamIDs {
|
||||
if name, ok := teamNamesByID[id]; ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return &api.OrgTagProtection{
|
||||
ID: rule.ID,
|
||||
OrgID: rule.OrgID,
|
||||
NamePattern: rule.NamePattern,
|
||||
WhitelistTeams: names,
|
||||
Created: rule.CreatedUnix.AsTime(),
|
||||
Updated: rule.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListOrgTagProtections list org-level tag protection rules
|
||||
func ListOrgTagProtections(ctx *context.APIContext) {
|
||||
rules, err := git_model.FindOrgProtectedTags(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
apiRules := make([]*api.OrgTagProtection, len(rules))
|
||||
for i, rule := range rules {
|
||||
apiRules[i] = toAPIOrgTagProtection(ctx, rule)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, apiRules)
|
||||
}
|
||||
|
||||
// GetOrgTagProtection get a specific org-level tag protection rule
|
||||
func GetOrgTagProtection(ctx *context.APIContext) {
|
||||
rule, err := git_model.GetOrgProtectedTagByID(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule))
|
||||
}
|
||||
|
||||
// CreateOrgTagProtection create an org-level tag protection rule
|
||||
func CreateOrgTagProtection(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.CreateOrgTagProtectionOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
existing, err := git_model.GetOrgProtectedTagByNamePattern(ctx, orgID, form.NamePattern)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
ctx.APIError(http.StatusForbidden, "org tag protection rule already exists for this pattern")
|
||||
return
|
||||
}
|
||||
|
||||
teams, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rule := &git_model.OrgProtectedTag{
|
||||
OrgID: orgID,
|
||||
NamePattern: form.NamePattern,
|
||||
AllowlistTeamIDs: teams,
|
||||
}
|
||||
if err := git_model.CreateOrgProtectedTag(ctx, rule); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, toAPIOrgTagProtection(ctx, rule))
|
||||
}
|
||||
|
||||
// EditOrgTagProtection edit an org-level tag protection rule
|
||||
func EditOrgTagProtection(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgTagProtectionOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if form.NamePattern != nil {
|
||||
rule.NamePattern = *form.NamePattern
|
||||
}
|
||||
if form.WhitelistTeams != nil {
|
||||
ids, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rule.AllowlistTeamIDs = ids
|
||||
}
|
||||
|
||||
if err := git_model.UpdateOrgProtectedTag(ctx, rule); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule))
|
||||
}
|
||||
|
||||
// DeleteOrgTagProtection delete an org-level tag protection rule
|
||||
func DeleteOrgTagProtection(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := git_model.DeleteOrgProtectedTag(ctx, orgID, rule.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
activities_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/activities"
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
|
||||
access_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||
@@ -491,6 +492,8 @@ func AddTeamMember(ctx *context.APIContext) {
|
||||
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
} else if git_model.IsErrEmailDomainNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
@@ -1365,6 +1365,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.APIError(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
func toCascadeRuleAPI(rule *repo_model.CascadeMergeRule) *api.CascadeMergeRule {
|
||||
return &api.CascadeMergeRule{
|
||||
ID: rule.ID,
|
||||
SourceBranch: rule.SourceBranch,
|
||||
TargetBranch: rule.TargetBranch,
|
||||
Enabled: rule.Enabled,
|
||||
AutoMerge: rule.AutoMerge,
|
||||
CreatedAt: rule.CreatedUnix.AsTime(),
|
||||
UpdatedAt: rule.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func ListCascadeRules(ctx *context.APIContext) {
|
||||
rules, err := repo_model.GetCascadeRulesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
apiRules := make([]*api.CascadeMergeRule, len(rules))
|
||||
for i, rule := range rules {
|
||||
apiRules[i] = toCascadeRuleAPI(rule)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, apiRules)
|
||||
}
|
||||
|
||||
func CreateCascadeRule(ctx *context.APIContext) {
|
||||
var req api.CreateCascadeMergeRuleOption
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.SourceBranch == "" || req.TargetBranch == "" {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch are required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.SourceBranch == req.TargetBranch {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||
return
|
||||
}
|
||||
|
||||
rule := &repo_model.CascadeMergeRule{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
SourceBranch: req.SourceBranch,
|
||||
TargetBranch: req.TargetBranch,
|
||||
Enabled: true,
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
rule.Enabled = *req.Enabled
|
||||
}
|
||||
if req.AutoMerge != nil {
|
||||
rule.AutoMerge = *req.AutoMerge
|
||||
}
|
||||
|
||||
if err := repo_model.CreateCascadeRule(ctx, rule); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, toCascadeRuleAPI(rule))
|
||||
}
|
||||
|
||||
func GetCascadeRule(ctx *context.APIContext) {
|
||||
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||
}
|
||||
|
||||
func EditCascadeRule(ctx *context.APIContext) {
|
||||
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
var req api.EditCascadeMergeRuleOption
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.SourceBranch != nil {
|
||||
rule.SourceBranch = *req.SourceBranch
|
||||
}
|
||||
if req.TargetBranch != nil {
|
||||
rule.TargetBranch = *req.TargetBranch
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
rule.Enabled = *req.Enabled
|
||||
}
|
||||
if req.AutoMerge != nil {
|
||||
rule.AutoMerge = *req.AutoMerge
|
||||
}
|
||||
|
||||
if rule.SourceBranch == rule.TargetBranch {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_model.UpdateCascadeRule(ctx, rule); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule))
|
||||
}
|
||||
|
||||
func DeleteCascadeRule(ctx *context.APIContext) {
|
||||
rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if rule == nil || rule.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_model.DeleteCascadeRule(ctx, ctx.Repo.Repository.ID, rule.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||
)
|
||||
|
||||
type apiSecurityAlert struct {
|
||||
ID int64 `json:"id"`
|
||||
Scanner string `json:"scanner"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
RuleID string `json:"rule_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
LineNumber int `json:"line_number,omitempty"`
|
||||
CommitSHA string `json:"commit_sha,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type apiSecurityConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockOnPush bool `json:"block_on_push"`
|
||||
SecretScanner bool `json:"secret_scanner"`
|
||||
DependScanner bool `json:"depend_scanner"`
|
||||
CodeScanner bool `json:"code_scanner"`
|
||||
}
|
||||
|
||||
// ListSecurityAlerts returns all security alerts for a repo.
|
||||
func ListSecurityAlerts(ctx *context.APIContext) {
|
||||
status := ctx.FormString("status")
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
var alerts []*security_model.SecurityAlert
|
||||
var err error
|
||||
|
||||
switch status {
|
||||
case "", "active":
|
||||
alerts, err = security_model.GetActiveAlerts(ctx, repoID)
|
||||
default:
|
||||
alerts, err = security_model.GetAllAlerts(ctx, repoID)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*apiSecurityAlert, len(alerts))
|
||||
for i, a := range alerts {
|
||||
result[i] = toAPISecurityAlert(a)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetSecurityAlert returns a single security alert.
|
||||
func GetSecurityAlert(ctx *context.APIContext) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
alert, err := security_model.GetAlertByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||
}
|
||||
|
||||
// UpdateSecurityAlert changes the status of a security alert.
|
||||
func UpdateSecurityAlert(ctx *context.APIContext) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
alert, err := security_model.GetAlertByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
|
||||
status := security_model.AlertStatus(req.Status)
|
||||
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "status must be 'resolved' or 'dismissed'")
|
||||
return
|
||||
}
|
||||
|
||||
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
alert, _ = security_model.GetAlertByID(ctx, id)
|
||||
ctx.JSON(http.StatusOK, toAPISecurityAlert(alert))
|
||||
}
|
||||
|
||||
// TriggerSecurityScan runs all enabled scanners against HEAD.
|
||||
func TriggerSecurityScan(ctx *context.APIContext) {
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ctx.APIError(http.StatusBadRequest, "no commits in repository")
|
||||
return
|
||||
}
|
||||
|
||||
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
|
||||
|
||||
alerts, err := security_model.GetActiveAlerts(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*apiSecurityAlert, len(alerts))
|
||||
for i, a := range alerts {
|
||||
result[i] = toAPISecurityAlert(a)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetSecurityConfig returns the scanner config for a repo.
|
||||
func GetSecurityConfig(ctx *context.APIContext) {
|
||||
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||
}
|
||||
|
||||
// UpdateSecurityConfig updates the scanner config for a repo.
|
||||
func UpdateSecurityConfig(ctx *context.APIContext) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
BlockOnPush *bool `json:"block_on_push"`
|
||||
SecretScanner *bool `json:"secret_scanner"`
|
||||
DependScanner *bool `json:"depend_scanner"`
|
||||
CodeScanner *bool `json:"code_scanner"`
|
||||
}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.BlockOnPush != nil {
|
||||
cfg.BlockOnPush = *req.BlockOnPush
|
||||
}
|
||||
if req.SecretScanner != nil {
|
||||
cfg.SecretScanner = *req.SecretScanner
|
||||
}
|
||||
if req.DependScanner != nil {
|
||||
cfg.DependScanner = *req.DependScanner
|
||||
}
|
||||
if req.CodeScanner != nil {
|
||||
cfg.CodeScanner = *req.CodeScanner
|
||||
}
|
||||
|
||||
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg))
|
||||
}
|
||||
|
||||
func toAPISecurityAlert(a *security_model.SecurityAlert) *apiSecurityAlert {
|
||||
return &apiSecurityAlert{
|
||||
ID: a.ID,
|
||||
Scanner: string(a.Scanner),
|
||||
Severity: string(a.Severity),
|
||||
Status: string(a.Status),
|
||||
RuleID: a.RuleID,
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
FilePath: a.FilePath,
|
||||
LineNumber: a.LineNumber,
|
||||
CommitSHA: a.CommitSHA,
|
||||
Fingerprint: a.Fingerprint,
|
||||
CreatedAt: a.CreatedUnix.AsTime(),
|
||||
UpdatedAt: a.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurityConfig {
|
||||
return &apiSecurityConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
BlockOnPush: cfg.BlockOnPush,
|
||||
SecretScanner: cfg.SecretScanner,
|
||||
DependScanner: cfg.DependScanner,
|
||||
CodeScanner: cfg.CodeScanner,
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cascade"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron"
|
||||
feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed"
|
||||
indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer"
|
||||
@@ -47,6 +48,7 @@ import (
|
||||
repo_migrations "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/migrations"
|
||||
mirror_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mirror"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/oauth2_provider"
|
||||
org_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/org"
|
||||
packages_spec "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/packages/pkgspec"
|
||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||
release_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/release"
|
||||
@@ -153,6 +155,8 @@ func InitWebInstalled(ctx context.Context) {
|
||||
mustInit(webhook.Init)
|
||||
mustInit(pull_service.Init)
|
||||
mustInit(automerge.Init)
|
||||
cascade.Init()
|
||||
org_service.Init()
|
||||
mustInit(task.Init)
|
||||
mustInit(repo_migrations.Init)
|
||||
eventsource.GetManager().Init()
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
asymkey_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/asymkey"
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit"
|
||||
gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||
)
|
||||
|
||||
type preReceiveContext struct {
|
||||
@@ -40,9 +43,6 @@ type preReceiveContext struct {
|
||||
canCreatePullRequest bool
|
||||
checkedCanCreatePullRequest bool
|
||||
|
||||
canWriteCode bool
|
||||
checkedCanWriteCode bool
|
||||
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
gotProtectedTags bool
|
||||
|
||||
@@ -50,24 +50,36 @@ type preReceiveContext struct {
|
||||
|
||||
opts *private.HookOptions
|
||||
|
||||
branchName string
|
||||
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||
canWriteCodeUnitCached *bool
|
||||
}
|
||||
|
||||
// CanWriteCode returns true if pusher can write code
|
||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
||||
if !ctx.checkedCanWriteCode {
|
||||
if !ctx.loadPusherAndPermission() {
|
||||
return false
|
||||
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||
if ctx.canWriteCodeUnitCached == nil {
|
||||
var canWrite bool
|
||||
if ctx.loadPusherAndPermission() {
|
||||
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
}
|
||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
ctx.checkedCanWriteCode = true
|
||||
ctx.canWriteCodeUnitCached = &canWrite
|
||||
}
|
||||
return ctx.canWriteCode
|
||||
return *ctx.canWriteCodeUnitCached
|
||||
}
|
||||
|
||||
// AssertCanWriteCode returns true if pusher can write code
|
||||
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
|
||||
if !ctx.CanWriteCode() {
|
||||
// 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 issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||
}
|
||||
|
||||
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||
if !ctx.canWriteCodeRef(refFullName) {
|
||||
if ctx.Written() {
|
||||
return false
|
||||
}
|
||||
@@ -129,7 +141,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
||||
preReceiveFor(ourCtx, refFullName)
|
||||
default:
|
||||
ourCtx.AssertCanWriteCode()
|
||||
ourCtx.assertCanWriteRef(refFullName)
|
||||
}
|
||||
if ctx.Written() {
|
||||
return
|
||||
@@ -141,9 +153,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
||||
|
||||
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
||||
branchName := refFullName.BranchName()
|
||||
ctx.branchName = branchName
|
||||
|
||||
if !ctx.AssertCanWriteCode() {
|
||||
if !ctx.assertCanWriteRef(refFullName) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,6 +162,29 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
gitRepo := ctx.Repo.GitRepo
|
||||
objectFormat := ctx.Repo.GetObjectFormat()
|
||||
|
||||
if ctx.checkOrgPushPolicyBranch(oldCommitID, newCommitID, branchName) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
|
||||
defaultBranch = repo.DefaultWikiBranch
|
||||
@@ -421,12 +455,16 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
|
||||
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||
if !ctx.AssertCanWriteCode() {
|
||||
if !ctx.assertCanWriteRef(refFullName) {
|
||||
return
|
||||
}
|
||||
|
||||
tagName := refFullName.TagName()
|
||||
|
||||
if ctx.checkOrgPushPolicyTag(tagName) {
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.gotProtectedTags {
|
||||
var err error
|
||||
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
|
||||
@@ -440,7 +478,7 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||
ctx.gotProtectedTags = true
|
||||
}
|
||||
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, ctx.protectedTags, ctx.Repo.Repository, tagName, ctx.opts.UserID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: err.Error(),
|
||||
@@ -568,3 +606,102 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
|
||||
ctx.loadedPusher = true
|
||||
return true
|
||||
}
|
||||
|
||||
// checkOrgPushPolicyBranch enforces the owning organization's push policy on a
|
||||
// branch push. It writes a 403 response and returns true when the push is rejected.
|
||||
// Content checks (blocked paths, max file size) fail open on unexpected errors so a
|
||||
// policy or parsing bug can never block every push in the organization.
|
||||
func (ctx *preReceiveContext) checkOrgPushPolicyBranch(oldCommitID, newCommitID, branchName string) bool {
|
||||
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
log.Error("GetOrgPushPolicyForRepo for %-v: %v", ctx.Repo.Repository, err)
|
||||
return false
|
||||
}
|
||||
if policy == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !policy.BranchNameAllowed(branchName) {
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
UserMsg: fmt.Sprintf("Branch name %q is not allowed by the organization push policy (pattern: %s)", branchName, policy.BranchNamePattern),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Deletions have no content to inspect.
|
||||
if newCommitID == ctx.Repo.GetObjectFormat().EmptyObjectID().String() {
|
||||
return false
|
||||
}
|
||||
|
||||
if globs := policy.BlockedFileGlobs(); len(globs) > 0 {
|
||||
if _, err := pull_service.CheckFileProtection(ctx.Repo.GitRepo, branchName, oldCommitID, newCommitID, globs, 10, ctx.env); err != nil {
|
||||
if pull_service.IsErrFilePathProtected(err) {
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
UserMsg: "Push rejected by the organization push policy: a changed file matches a blocked path pattern",
|
||||
})
|
||||
return true
|
||||
}
|
||||
log.Error("org push policy blocked-path check for %-v: %v", ctx.Repo.Repository, err) // fail open
|
||||
}
|
||||
}
|
||||
|
||||
if policy.MaxFileSize > 0 {
|
||||
if path, size := ctx.largestBlobOverLimit(newCommitID, policy.MaxFileSize); path != "" {
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
UserMsg: fmt.Sprintf("Push rejected by the organization push policy: %q is %d bytes, over the %d-byte limit", path, size, policy.MaxFileSize),
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkOrgPushPolicyTag enforces the organization tag naming policy. Returns true
|
||||
// (with a 403 written) when the tag name is rejected.
|
||||
func (ctx *preReceiveContext) checkOrgPushPolicyTag(tagName string) bool {
|
||||
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
log.Error("GetOrgPushPolicyForRepo for %-v: %v", ctx.Repo.Repository, err)
|
||||
return false
|
||||
}
|
||||
if policy == nil || policy.TagNameAllowed(tagName) {
|
||||
return false
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
UserMsg: fmt.Sprintf("Tag name %q is not allowed by the organization push policy (pattern: %s)", tagName, policy.TagNamePattern),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// largestBlobOverLimit returns the first file (and its size) in the pushed tip tree
|
||||
// that exceeds limit bytes, or ("", 0) if none — or on any error (fail open).
|
||||
func (ctx *preReceiveContext) largestBlobOverLimit(commitID string, limit int64) (string, int64) {
|
||||
output, _, err := gitrepo.RunCmdString(ctx,
|
||||
ctx.Repo.Repository,
|
||||
gitcmd.NewCommand("ls-tree", "-r", "--long").
|
||||
AddDynamicArguments(commitID).
|
||||
WithEnv(ctx.env),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("org push policy ls-tree for %-v: %v", ctx.Repo.Repository, err)
|
||||
return "", 0
|
||||
}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
tab := strings.IndexByte(line, '\t')
|
||||
if tab < 0 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line[:tab]) // mode, type, hash, size
|
||||
if len(fields) < 4 || fields[1] != "blob" {
|
||||
continue
|
||||
}
|
||||
size, perr := strconv.ParseInt(fields[3], 10, 64)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
if size > limit {
|
||||
return line[tab+1:], size
|
||||
}
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package private
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// together with a protected branch or a tag to escalate into full repository write.
|
||||
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||
|
||||
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||
// repo owner write access to exactly this head branch and nothing else.
|
||||
pr := &issues_model.PullRequest{
|
||||
Issue: &issues_model.Issue{
|
||||
RepoID: baseRepo.ID,
|
||||
PosterID: headRepo.OwnerID,
|
||||
},
|
||||
HeadRepoID: headRepo.ID,
|
||||
BaseRepoID: baseRepo.ID,
|
||||
HeadBranch: "granted-branch",
|
||||
BaseBranch: "master",
|
||||
AllowMaintainerEdit: true,
|
||||
}
|
||||
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||
|
||||
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||
maintainer := baseRepo.Owner
|
||||
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{
|
||||
PrivateContext: mockCtx,
|
||||
loadedPusher: true,
|
||||
user: maintainer,
|
||||
userPerm: headPerm,
|
||||
}
|
||||
|
||||
// The granted branch must be writable...
|
||||
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||
|
||||
// ...but another branch in the same push must NOT inherit that grant.
|
||||
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
|
||||
|
||||
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
|
||||
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
|
||||
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
|
||||
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
|
||||
}
|
||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the passcode with the stored TOTP secret.
|
||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
||||
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
||||
if ok {
|
||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||
u, err := user_model.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
twofa.LastUsedPasscode = form.Passcode
|
||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
handleSignIn(ctx, u, remember)
|
||||
return
|
||||
|
||||
@@ -368,9 +368,21 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
|
||||
// Reactivate user if they are deactivated
|
||||
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
|
||||
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||
if !u.IsActive {
|
||||
opts.IsActive = optional.Some(true)
|
||||
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetExternalLogin", err)
|
||||
return
|
||||
}
|
||||
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
|
||||
if isDisabledByAutoSync {
|
||||
opts.IsActive = optional.Some(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Update GroupClaims
|
||||
@@ -514,17 +526,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
||||
}
|
||||
|
||||
// search in external linked users
|
||||
externalLoginUser := &user_model.ExternalLoginUser{
|
||||
ExternalID: gothUser.UserID,
|
||||
LoginSourceID: authSource.ID,
|
||||
}
|
||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
||||
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||
if err != nil {
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
if hasUser {
|
||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
||||
return user, gothUser, err
|
||||
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
||||
if err != nil && !user_model.IsErrUserNotExist(err) {
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
if err == nil && user.IsIndividual() {
|
||||
return user, gothUser, nil
|
||||
}
|
||||
|
||||
// The external login record is stale: the linked user no longer exists, or it exists but is
|
||||
// not an individual user (only individual users can sign in, so a link pointing at an
|
||||
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
|
||||
// relink the external account to the correct user. Nothing is lost, because the link is
|
||||
// recreated automatically on the next sign-in.
|
||||
reason := "linked user does not exist"
|
||||
if err == nil {
|
||||
reason = fmt.Sprintf("linked user type is %d", user.Type)
|
||||
}
|
||||
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
|
||||
|
||||
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
|
||||
return nil, goth.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// no user found to login
|
||||
|
||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
||||
regenerateScratchToken = true
|
||||
} else {
|
||||
passcode := ctx.FormString("passcode")
|
||||
ok, err := twofa.ValidateTOTP(passcode)
|
||||
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
|
||||
return
|
||||
}
|
||||
if !ok || twofa.LastUsedPasscode == passcode {
|
||||
if !ok {
|
||||
ctx.Data["IsResetForm"] = true
|
||||
ctx.Data["Err_Passcode"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||
return
|
||||
}
|
||||
|
||||
twofa.LastUsedPasscode = passcode
|
||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
|
||||
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||
if !checkRepoFeedTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
var commits []*git.Commit
|
||||
var err error
|
||||
if ctx.Repo.Commit != nil {
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
|
||||
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||
if !checkRepoFeedTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
fileName := ctx.Repo.TreePath
|
||||
if len(fileName) == 0 {
|
||||
return
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
|
||||
// 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) {
|
||||
if !checkRepoFeedTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
IncludeTags: !isReleasesOnly,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
|
||||
@@ -4,9 +4,18 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||
"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
|
||||
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
||||
if ctx.Repo.TreePath == "" {
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
|
||||
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
||||
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
||||
if !checkRepoFeedTokenScope(ctx) {
|
||||
return
|
||||
}
|
||||
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: repo,
|
||||
Actor: ctx.Doer,
|
||||
|
||||
@@ -27,9 +27,35 @@ func SettingsIssueStatuses(ctx *context.Context) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
@@ -98,7 +98,7 @@ func Teams(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["OrgListTeams"] = teams
|
||||
ctx.Data["Teams"] = teams
|
||||
ctx.Data["Keyword"] = keyword
|
||||
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
|
||||
@@ -261,7 +261,7 @@ func MergeUpstream(ctx *context.Context) {
|
||||
branchName := ctx.FormString("branch")
|
||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.JSONErrorNotFound()
|
||||
return
|
||||
} else if pull_service.IsErrMergeConflicts(err) {
|
||||
|
||||
+19
-28
@@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
|
||||
// httpBase does the common work for git http services,
|
||||
// including early response, authentication, repository lookup and permission check.
|
||||
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||
|
||||
if ctx.FormString("go-get") == "1" {
|
||||
context.EarlyResponseForGoGetMeta(ctx)
|
||||
return nil
|
||||
@@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
|
||||
isWiki := false
|
||||
unitType := unit.TypeCode
|
||||
|
||||
if strings.HasSuffix(reponame, ".wiki") {
|
||||
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||
if strings.HasSuffix(repoName, ".wiki") {
|
||||
isWiki = true
|
||||
unitType = unit.TypeWiki
|
||||
reponame = reponame[:len(reponame)-5]
|
||||
repoName = repoName[:len(repoName)-5]
|
||||
}
|
||||
|
||||
owner := ctx.ContextUser
|
||||
@@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
}
|
||||
|
||||
repoExist := true
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||
if err != nil {
|
||||
if !repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.ServerError("GetRepositoryByName", err)
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
@@ -127,31 +125,24 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only public pull don't need auth.
|
||||
// For private repos, also allow anonymous pull if the specific unit
|
||||
// (code or wiki) has AnonymousAccessMode >= Read.
|
||||
isPublicPull := repoExist && isPull && !repo.IsPrivate
|
||||
if repoExist && isPull && repo.IsPrivate {
|
||||
repoUnit := repo.MustGetUnit(ctx, unitType)
|
||||
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
|
||||
isPublicPull = true
|
||||
// Only public pulls don't need auth: repo must exist, not require-sign-in
|
||||
canAnonymousPull := false
|
||||
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
|
||||
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
|
||||
canAnonymousPull = true
|
||||
}
|
||||
}
|
||||
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
|
||||
|
||||
// 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
|
||||
if !canAnonymousPull && ctx.Doer == nil {
|
||||
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetDoerRepoPermission", err)
|
||||
return nil
|
||||
}
|
||||
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
|
||||
}
|
||||
|
||||
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
|
||||
}
|
||||
|
||||
// check access
|
||||
if askAuth {
|
||||
// rely on the results of Contexter
|
||||
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
|
||||
if !ctx.IsSigned {
|
||||
// TODO: support digit auth - which would be Authorization header with digit
|
||||
if setting.OAuth2.Enabled {
|
||||
@@ -237,7 +228,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||
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 {
|
||||
log.Error("pushCreateRepo: %v", err)
|
||||
ctx.Status(http.StatusNotFound)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// prepareOrgPushPolicyView surfaces the owning organization's push policy (if any)
|
||||
// as a read-only informational section on the repo's branch protection settings page.
|
||||
// The policy is enforced org-wide in the pre-receive hook and cannot be edited from
|
||||
// the repository. Template data is only populated when the repo is owned by an
|
||||
// organization that has a push policy configured (nil policy renders nothing).
|
||||
// See issue #738.
|
||||
func prepareOrgPushPolicyView(ctx *context.Context) error {
|
||||
if !ctx.Repo.Owner.IsOrganization() {
|
||||
return nil
|
||||
}
|
||||
policy, err := git_model.GetOrgPushPolicyForRepo(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
ctx.Data["OrgPushPolicy"] = policy
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareOrgRepoDefaultsView surfaces the owning organization's repository defaults
|
||||
// (if any) as a read-only informational section. These defaults are applied when a
|
||||
// repository is created in, or transferred into, the organization; they are shown
|
||||
// for reference only and are not editable here. Template data is only populated when
|
||||
// the repo is owned by an organization that has repo defaults configured (nil renders
|
||||
// nothing). See issue #738.
|
||||
func prepareOrgRepoDefaultsView(ctx *context.Context) error {
|
||||
if !ctx.Repo.Owner.IsOrganization() {
|
||||
return nil
|
||||
}
|
||||
defaults, err := git_model.GetOrgRepoDefaultsForRepo(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if defaults == nil {
|
||||
return nil
|
||||
}
|
||||
ctx.Data["OrgRepoDefaults"] = defaults
|
||||
return nil
|
||||
}
|
||||
@@ -34,6 +34,64 @@ const (
|
||||
tplProtectedBranch templates.TplName = "repo/settings/protected_branch"
|
||||
)
|
||||
|
||||
// orgBranchProtectionView is a read-only presentation of an org-level branch
|
||||
// protection rule for the repo settings page, with team IDs resolved to names.
|
||||
type orgBranchProtectionView struct {
|
||||
Rule *git_model.OrgProtectedBranch
|
||||
PushTeams string
|
||||
ForcePushTeams string
|
||||
DeleteTeams string
|
||||
MergeTeams string
|
||||
ApprovalTeams string
|
||||
StatusContexts string
|
||||
}
|
||||
|
||||
// prepareOrgProtectedBranches loads the owning organization's branch protection
|
||||
// rules and exposes them (with team IDs resolved to names) as read-only view models
|
||||
// under the "OrgProtectedBranches" template key.
|
||||
func prepareOrgProtectedBranches(ctx *context.Context) error {
|
||||
orgRules, err := git_model.FindOrgProtectedBranchRules(ctx, ctx.Repo.Owner.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(orgRules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
teams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teamNames := make(map[int64]string, len(teams))
|
||||
for _, t := range teams {
|
||||
teamNames[t.ID] = t.Name
|
||||
}
|
||||
join := func(ids []int64) string {
|
||||
names := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if n, ok := teamNames[id]; ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
views := make([]*orgBranchProtectionView, len(orgRules))
|
||||
for i, r := range orgRules {
|
||||
views[i] = &orgBranchProtectionView{
|
||||
Rule: r,
|
||||
PushTeams: join(r.WhitelistTeamIDs),
|
||||
ForcePushTeams: join(r.ForcePushAllowlistTeamIDs),
|
||||
DeleteTeams: join(r.DeleteAllowlistTeamIDs),
|
||||
MergeTeams: join(r.MergeWhitelistTeamIDs),
|
||||
ApprovalTeams: join(r.ApprovalsWhitelistTeamIDs),
|
||||
StatusContexts: strings.Join(r.StatusCheckContexts, ", "),
|
||||
}
|
||||
}
|
||||
ctx.Data["OrgProtectedBranches"] = views
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProtectedBranchRules render the page to protect the repository
|
||||
func ProtectedBranchRules(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.branches")
|
||||
@@ -46,6 +104,26 @@ func ProtectedBranchRules(ctx *context.Context) {
|
||||
}
|
||||
ctx.Data["ProtectedBranches"] = rules
|
||||
|
||||
// Surface the organization-level rules that also apply to this repo (read-only),
|
||||
// so admins can see the org "floor" that is layered on top of the repo's own
|
||||
// rules at enforcement time. See issue #727.
|
||||
if ctx.Repo.Owner.IsOrganization() {
|
||||
if err := prepareOrgProtectedBranches(ctx); err != nil {
|
||||
ctx.ServerError("prepareOrgProtectedBranches", err)
|
||||
return
|
||||
}
|
||||
// Surface the org-wide push policy and the org repo defaults that apply to
|
||||
// this repo (read-only, informational). See issue #738.
|
||||
if err := prepareOrgPushPolicyView(ctx); err != nil {
|
||||
ctx.ServerError("prepareOrgPushPolicyView", err)
|
||||
return
|
||||
}
|
||||
if err := prepareOrgRepoDefaultsView(ctx); err != nil {
|
||||
ctx.ServerError("prepareOrgRepoDefaultsView", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
repo.PrepareBranchList(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
|
||||
@@ -138,6 +138,13 @@ func DeleteProtectedTagPost(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
||||
}
|
||||
|
||||
// orgProtectedTagView is a read-only presentation of an org-level tag rule for the
|
||||
// repo settings page, with allowlist team IDs resolved to names.
|
||||
type orgProtectedTagView struct {
|
||||
Rule *git_model.OrgProtectedTag
|
||||
Teams string
|
||||
}
|
||||
|
||||
func setTagsContext(ctx *context.Context) error {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.tags")
|
||||
ctx.Data["PageIsSettingsTags"] = true
|
||||
@@ -163,6 +170,36 @@ func setTagsContext(ctx *context.Context) error {
|
||||
return err
|
||||
}
|
||||
ctx.Data["Teams"] = teams
|
||||
|
||||
// Surface the organization's tag protection rules read-only, so admins can see
|
||||
// the org "floor" layered on top of this repo's own protected tags (#727).
|
||||
orgRules, err := git_model.FindOrgProtectedTags(ctx, ctx.Repo.Owner.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindOrgProtectedTags", err)
|
||||
return err
|
||||
}
|
||||
if len(orgRules) > 0 {
|
||||
allTeams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindOrgTeams", err)
|
||||
return err
|
||||
}
|
||||
teamNames := make(map[int64]string, len(allTeams))
|
||||
for _, t := range allTeams {
|
||||
teamNames[t.ID] = t.Name
|
||||
}
|
||||
views := make([]*orgProtectedTagView, len(orgRules))
|
||||
for i, r := range orgRules {
|
||||
names := make([]string, 0, len(r.AllowlistTeamIDs))
|
||||
for _, id := range r.AllowlistTeamIDs {
|
||||
if n, ok := teamNames[id]; ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
views[i] = &orgProtectedTagView{Rule: r, Teams: strings.Join(names, ", ")}
|
||||
}
|
||||
ctx.Data["OrgProtectedTags"] = views
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1091,6 +1091,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/issue-statuses", func() {
|
||||
m.Get("", org.SettingsIssueStatuses)
|
||||
m.Post("", org.SettingsIssueStatusesCreatePost)
|
||||
m.Post("/apply-preset", org.SettingsIssueStatusesApplyPresetPost)
|
||||
m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost)
|
||||
})
|
||||
|
||||
@@ -448,6 +448,24 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
|
||||
}
|
||||
|
||||
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return perm.CanWrite(unit_model.TypeActions), nil
|
||||
}
|
||||
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
|
||||
}
|
||||
|
||||
func ifNeedApprovalWith(
|
||||
ctx context.Context,
|
||||
run *actions_model.ActionRun,
|
||||
repo *repo_model.Repository,
|
||||
user *user_model.User,
|
||||
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
|
||||
hasMergedPR func(context.Context, int64, int64) (bool, error),
|
||||
) (bool, error) {
|
||||
// 1. don't need approval if it's not a fork PR
|
||||
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
||||
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
||||
@@ -462,27 +480,24 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep
|
||||
}
|
||||
|
||||
// don't need approval if the user can write
|
||||
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
|
||||
if ok, err := canWriteActions(ctx, repo, user); err != nil {
|
||||
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
|
||||
} else if perm.CanWrite(unit_model.TypeActions) {
|
||||
} else if ok {
|
||||
log.Trace("do not need approval because user %d can write", user.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// don't need approval if the user has been approved before
|
||||
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
|
||||
RepoID: repo.ID,
|
||||
TriggerUserID: user.ID,
|
||||
Approved: true,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("CountRuns: %w", err)
|
||||
} else if count > 0 {
|
||||
log.Trace("do not need approval because user %d has been approved before", user.ID)
|
||||
// trust the user only after a merged PR — matching GitHub Actions. Approving one
|
||||
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
|
||||
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
|
||||
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
|
||||
} else if merged {
|
||||
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// otherwise, need approval
|
||||
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
|
||||
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
actions_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/actions"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
actions_module "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIfNeedApproval(t *testing.T) {
|
||||
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
|
||||
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
|
||||
errPerm := errors.New("perm error")
|
||||
errMerge := errors.New("merge error")
|
||||
|
||||
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
|
||||
|
||||
repo := &repo_model.Repository{ID: 1}
|
||||
normalUser := &user_model.User{ID: 10}
|
||||
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
|
||||
|
||||
t.Run("not a fork PR never needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("restricted user always needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
})
|
||||
|
||||
t.Run("canWriteActions error is propagated", func(t *testing.T) {
|
||||
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return false, errPerm
|
||||
}
|
||||
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
|
||||
require.ErrorIs(t, err, errPerm)
|
||||
})
|
||||
|
||||
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
|
||||
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
|
||||
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
|
||||
require.ErrorIs(t, err, errMerge)
|
||||
})
|
||||
|
||||
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
|
||||
// The perm and merge functions must not be called for a restricted user.
|
||||
called := false
|
||||
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
called = true
|
||||
return true, nil
|
||||
}
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
assert.False(t, called, "permission check must not run for restricted user")
|
||||
})
|
||||
}
|
||||
@@ -176,7 +176,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
|
||||
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||
|
||||
@@ -88,8 +88,8 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stored tokens, since they are invalid. This
|
||||
// also provents us from checking this in subsequent runs.
|
||||
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION
|
||||
// Delete stored tokens, since they are invalid. This also prevents us from checking this in subsequent runs.
|
||||
u.AccessToken = ""
|
||||
u.RefreshToken = ""
|
||||
u.ExpiresAt = time.Time{}
|
||||
|
||||
@@ -57,12 +57,7 @@ func TestSource(t *testing.T) {
|
||||
err := source.refresh(t.Context(), provider, e)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
||||
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "refresh", e.RefreshToken)
|
||||
@@ -82,12 +77,7 @@ func TestSource(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
||||
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Empty(t, e.RefreshToken)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package cascade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify"
|
||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
notify_service.RegisterNotifier(&cascadeNotifier{})
|
||||
}
|
||||
|
||||
type cascadeNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
func (n *cascadeNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
handleCascade(ctx, doer, pr)
|
||||
}
|
||||
|
||||
func (n *cascadeNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
handleCascade(ctx, doer, pr)
|
||||
}
|
||||
|
||||
func handleCascade(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("cascade: LoadBaseRepo for PR #%d: %v", pr.Index, err)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := repo_model.GetCascadeRulesForBranch(ctx, pr.BaseRepo.ID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
log.Error("cascade: GetCascadeRulesForBranch repo=%d branch=%s: %v", pr.BaseRepo.ID, pr.BaseBranch, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if err := createCascadePR(ctx, doer, pr, rule); err != nil {
|
||||
log.Error("cascade: failed to create PR %s→%s in repo %d: %v", rule.SourceBranch, rule.TargetBranch, rule.RepoID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createCascadePR(ctx context.Context, doer *user_model.User, mergedPR *issues_model.PullRequest, rule *repo_model.CascadeMergeRule) error {
|
||||
repo := mergedPR.BaseRepo
|
||||
|
||||
existingPRs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repo.ID, rule.TargetBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check existing PRs: %w", err)
|
||||
}
|
||||
for _, existing := range existingPRs {
|
||||
if existing.HeadBranch == rule.SourceBranch && existing.HeadRepoID == repo.ID {
|
||||
log.Info("cascade: PR already exists %s→%s in %s/%s (#%d), skipping",
|
||||
rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name, existing.Index)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("cascade: merge %s into %s", rule.SourceBranch, rule.TargetBranch)
|
||||
body := fmt.Sprintf("Auto-created by cascade merge rule after PR #%d was merged.\n\nSource: `%s` → Target: `%s`",
|
||||
mergedPR.Index, rule.SourceBranch, rule.TargetBranch)
|
||||
|
||||
issue := &issues_model.Issue{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
Title: title,
|
||||
Content: body,
|
||||
PosterID: doer.ID,
|
||||
Poster: doer,
|
||||
IsPull: true,
|
||||
}
|
||||
|
||||
pullRequest := &issues_model.PullRequest{
|
||||
HeadRepoID: repo.ID,
|
||||
BaseRepoID: repo.ID,
|
||||
HeadBranch: rule.SourceBranch,
|
||||
BaseBranch: rule.TargetBranch,
|
||||
HeadRepo: repo,
|
||||
BaseRepo: repo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
|
||||
if err := pull_service.NewPullRequest(ctx, &pull_service.NewPullRequestOptions{
|
||||
Repo: repo,
|
||||
Issue: issue,
|
||||
PullRequest: pullRequest,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("NewPullRequest: %w", err)
|
||||
}
|
||||
|
||||
log.Info("cascade: created PR #%d (%s→%s) in %s/%s",
|
||||
issue.Index, rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name)
|
||||
return nil
|
||||
}
|
||||
@@ -24,19 +24,22 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
|
||||
}
|
||||
|
||||
// since user only get notifications when he has access to use minimal access mode
|
||||
if n.Repository != nil {
|
||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
|
||||
if err != nil {
|
||||
log.Error("GetIndividualUserRepoPermission failed: %v", err)
|
||||
return result
|
||||
}
|
||||
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
|
||||
result.Repository = ToRepo(ctx, n.Repository, perm)
|
||||
// This permission is not correct and we should not be reporting it
|
||||
for repository := result.Repository; repository != nil; repository = repository.Parent {
|
||||
repository.Permissions = nil
|
||||
}
|
||||
}
|
||||
if n.Repository == nil {
|
||||
return result
|
||||
}
|
||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
|
||||
if err != nil {
|
||||
log.Error("GetIndividualUserRepoPermission failed: %v", err)
|
||||
return result
|
||||
}
|
||||
// if the user has been revoked access to the repo, do not leak repo or subject info
|
||||
if !perm.HasAnyUnitAccessOrPublicAccess() {
|
||||
return result
|
||||
}
|
||||
result.Repository = ToRepo(ctx, n.Repository, perm)
|
||||
// This permission is not correct and we should not be reporting it
|
||||
for repository := result.Repository; repository != nil; repository = repository.Parent {
|
||||
repository.Permissions = nil
|
||||
}
|
||||
|
||||
// handle Subject
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user