Compare commits

..

4 Commits

Author SHA1 Message Date
jmiller 240fe1ebe5 feat: security scanning API endpoints + pre-receive hook blocking (#692)
PR RC Release / Build RC Release (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 37s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 50s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Add REST API for security alerts (list, get, update status, trigger scan)
and scanner config (get, update). Wire block_on_push into the pre-receive
hook so pushes containing detected secrets are rejected with details.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:14:46 -05:00
jmiller ecc1f20162 feat: cascade merge — auto-create PRs to downstream branches after merge (#460)
Adds configurable cascade rules per repo. When a PR merges into a
source branch, the system auto-creates PRs to each configured target
branch. Skips if a matching PR already exists.

- Model: CascadeMergeRule (repo_id, source, target, enabled, auto_merge)
- Migration v362 creates cascade_merge_rule table
- Notifier hooks into MergePullRequest/AutoMergePullRequest events
- API: CRUD at /repos/{owner}/{repo}/cascade_rules (admin only)

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:04:40 -05:00
jmiller 965abb54b8 feat: add issue status presets and cross-org migration (#507)
4 built-in presets: default, software-development, support-tickets,
bug-tracking. API endpoints to list presets, apply to org, and copy
statuses between orgs. Web UI dropdown on org settings page.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:03:15 -05:00
jmiller b94f41b597 feat(orgs): auto-create default teams on org creation (#513)
New organizations now get three default teams in addition to Owners:
- Developers (write: code, issues, PRs, wiki, projects; read: releases)
- Reviewers (read: code, issues, PRs, releases, wiki)
- CI/CD (write: actions, packages, releases; read: code)

Teams are defined in DefaultOrgTeams and created inside the same
transaction as the org, so creation is atomic.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:00:17 -05:00
119 changed files with 521 additions and 9505 deletions
-3
View File
@@ -120,6 +120,3 @@ 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 -1
View File
@@ -1,7 +1,7 @@
---
name: Feature Request
about: Suggest a new feature or enhancement
title: '(feat) '
title: '[FEATURE] '
labels: 'enhancement'
assignees: ''
+13 -51
View File
@@ -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.01.00
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +=======================================================================+
@@ -64,14 +64,10 @@ jobs:
promote-rc:
name: Promote to RC
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!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')
)
(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
@@ -79,7 +75,6 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
submodules: recursive
- name: Setup mokocli tools
env:
@@ -95,7 +90,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
@@ -104,39 +99,11 @@ jobs:
- name: Rename branch to rc
run: |
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"
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 }}"
- name: Checkout rc and configure git
run: |
@@ -196,13 +163,9 @@ jobs:
release:
name: Build & Release Pipeline
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
)
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
@@ -210,7 +173,6 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes
run: |
@@ -246,7 +208,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
+1 -2
View File
@@ -33,8 +33,7 @@ jobs:
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
# URL-encode the branch name's slashes (no PHP dependency on the runner)
ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
-10
View File
@@ -1,10 +0,0 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
+1 -6
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/ci-generic.yml
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
@@ -32,8 +32,6 @@ 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
@@ -132,9 +130,6 @@ 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
+1 -1
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.mokogitea/workflows/cleanup.yml
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+13 -23
View File
@@ -52,61 +52,51 @@ jobs:
REGISTRY_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
TAG: ${{ steps.config.outputs.tag }}
run: |
# Inject runner-side values (TAG, REGISTRY_TOKEN) into the remote shell's
# environment via a command prefix, then use a *quoted* heredoc so every
# $var below expands in exactly one place: the remote dev host. This avoids
# the local-vs-remote expansion trap that previously left TAG empty.
HEALTH_FMT='${{ '{{' }}.State.Health.Status${{ '}}' }}'
ssh -i ~/.ssh/deploy_key -p ${{ env.DEPLOY_PORT }} \
-o ConnectTimeout=30 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} \
"TAG='$TAG' REGISTRY_TOKEN='$REGISTRY_TOKEN' bash -s" <<'DEPLOY_EOF'
${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} bash -s <<DEPLOY_EOF
set -e
echo 'SSH connected to dev environment'
if [ -z "$TAG" ]; then
echo 'ERROR: TAG is empty; refusing to build an untagged image' >&2
exit 1
fi
HEALTH_FMT='{{.State.Health.Status}}'
echo 'Cleaning Docker build cache...'
docker builder prune -af 2>/dev/null || true
docker image prune -af 2>/dev/null || true
echo 'Pulling source...'
SOURCE_DIR=/opt/gitea-dev/source
if [ ! -d "$SOURCE_DIR/.git" ]; then
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git "$SOURCE_DIR"
if [ ! -d \$SOURCE_DIR/.git ]; then
git clone -b dev https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git \$SOURCE_DIR
fi
cd "$SOURCE_DIR"
cd \$SOURCE_DIR
git remote set-url origin https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork.git 2>/dev/null || true
git fetch origin dev
git reset --hard origin/dev
echo "Building Docker image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG"
echo 'Building Docker image...'
docker build --no-cache --build-arg GOFLAGS='-p 1' \
--tag "${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG" \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG \
-f Dockerfile .
echo 'Pushing to registry...'
echo "$REGISTRY_TOKEN" | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
docker push "${{ env.REGISTRY }}/${{ env.IMAGE }}:$TAG"
echo '\$REGISTRY_TOKEN' | docker login ${{ env.REGISTRY }} -u ${{ env.DEPLOY_USER }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:\$TAG
echo 'Restarting dev container...'
cd /opt/gitea-dev
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:$TAG|" docker-compose.yml
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:\$TAG|" docker-compose.yml
docker compose up -d mokogitea-dev
echo 'Health check...'
for i in 1 2 3 4 5 6 7 8; do
sleep 15
if docker inspect --format="$HEALTH_FMT" mokogitea-dev 2>/dev/null | grep -q healthy; then
if docker inspect --format='\$HEALTH_FMT' mokogitea-dev 2>/dev/null | grep -q healthy; then
echo 'Dev container healthy!'
exit 0
fi
echo "Waiting... (attempt $i/8)"
echo "Waiting... (attempt \$i/8)"
done
echo 'Health check failed'
docker logs mokogitea-dev --tail 20
+3 -12
View File
@@ -47,15 +47,6 @@ 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}"
@@ -65,7 +56,7 @@ jobs:
echo "RC version: $RC_VERSION (tag: $RC_TAG)"
- name: Update updates.xml RC channel
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
RC_VERSION: ${{ steps.version.outputs.version }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -115,7 +106,7 @@ jobs:
PYEOF
- name: Create RC release
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -162,7 +153,7 @@ jobs:
PYEOF
- name: Commit updates.xml
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
+126
View File
@@ -0,0 +1,126 @@
# 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
+1 -1
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.mokogitea/workflows/notify.yml
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
+10 -17
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# 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" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,8 +86,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
@@ -127,8 +126,6 @@ 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
@@ -150,12 +147,11 @@ jobs:
- name: Detect platform
id: platform
run: |
# 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)"
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
@@ -496,9 +492,6 @@ 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
+2 -6
View File
@@ -48,13 +48,9 @@ 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: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
)
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
-31
View File
@@ -1,31 +0,0 @@
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 }}
-35
View File
@@ -3,13 +3,6 @@
## [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)
@@ -47,35 +40,7 @@
- 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
- Fork server binary now compiles: `routers/api/v1/api.go` called `organization.HasOrgOrUserVisible`, which had been renamed to `IsOwnerVisibleToDoer`; the one missed call site broke `go build` of the entire `routers/api/v1` package (CI's Lint & Validate does not run a full build, so it went unnoticed) (#735)
- Dev deploy workflow: the build/deploy step referenced runner-side values as `\$TAG` / `\$REGISTRY_TOKEN` inside an unquoted SSH heredoc, deferring expansion to the remote shell where those names are unset — the Docker tag collapsed to an empty `mokogitea:` and every dev deploy failed with `invalid reference format`. Runner values are now injected via an ssh env-prefix and the heredoc is quoted so each `$var` expands in exactly one place (#737)
- Repaired unit-test compile and `go vet` failures: `CryptoRandomInt/String/Bytes` now return two values (updated `modules/util/util_test.go`), removed a redundant `&&` condition in `issue_comment.go`, and cleaned up isolated integration-test compile errors (#736)
- Removed a stray `package-lock.json` (13.9k lines) that a `git add -A` had accidentally swept into the org-push-policy branch (#734)
- 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)
+2 -7
View File
@@ -1,6 +1,6 @@
# MokoGitea
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org-level governance, org metadata, CI standardization, and project board API.
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, CI standardization, and project board API.
![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square)
@@ -11,13 +11,8 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cas
- **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, 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
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection
- **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)
- **Org Governance** -- organization-wide rules that layer onto every repository: branch protection as a most-restrictive floor a repo cannot weaken, tag protection (team allowlist), push policy (branch/tag naming, mandatory secret-block, max file size, blocked paths), repository defaults (force-private, PR merge settings), and member email-domain allowlists
- **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
+9 -14
View File
@@ -113,25 +113,23 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
return nil
}
// 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) {
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
switch verb {
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
return perm.AccessModeRead, true
return perm.AccessModeRead
case git.CmdVerbReceivePack:
return perm.AccessModeWrite, true
return perm.AccessModeWrite
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
switch lfsVerb {
case git.CmdSubVerbLfsUpload:
return perm.AccessModeWrite, true
return perm.AccessModeWrite
case git.CmdSubVerbLfsDownload:
return perm.AccessModeRead, true
return perm.AccessModeRead
}
}
return perm.AccessModeNone, false
// should be unreachable
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
return perm.AccessModeNone
}
func runServ(ctx context.Context, c *cli.Command) error {
@@ -249,10 +247,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
}
requestedMode, ok := getAccessMode(verb, lfsVerb)
if !ok {
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
}
requestedMode := getAccessMode(verb, lfsVerb)
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
if extra.HasError() {
-56
View File
@@ -1,56 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"github.com/stretchr/testify/assert"
)
func TestGetAccessMode(t *testing.T) {
cases := []struct {
verb, lfsVerb string
expected perm.AccessMode
}{
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
}
for _, tc := range cases {
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
assert.True(t, ok)
assert.Equal(t, tc.expected, mode)
})
}
}
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
// it would pass the `userMode < mode` permission check in routers/private/serv.go
// and hand out valid LFS JWTs for any private repository.
func TestGetAccessModeUnknownVerb(t *testing.T) {
cases := []struct{ verb, lfsVerb string }{
{git.CmdVerbLfsAuthenticate, ""},
{git.CmdVerbLfsAuthenticate, "badverb"},
{git.CmdVerbLfsTransfer, "badverb"},
{"git-unknown-verb", ""},
}
for _, tc := range cases {
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
assert.False(t, ok)
assert.Equal(t, perm.AccessModeNone, mode)
})
}
}
+2
View File
@@ -51,6 +51,8 @@ ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+2
View File
@@ -48,6 +48,8 @@ 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
+1
Submodule mcp-mokogitea-api added at dbaf91546e
+4
View File
@@ -64,6 +64,7 @@ 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
@@ -80,6 +81,9 @@ 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))
}
+4 -28
View File
@@ -21,7 +21,6 @@ import (
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
)
//
@@ -105,43 +104,20 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil
}
// 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) {
// ValidateTOTP validates the provided passcode.
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)
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth_test
import (
"testing"
"time"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: 1}
require.NoError(t, tfa.SetSecret(key.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
require.NoError(t, err)
assert.False(t, ok)
}
+2 -5
View File
@@ -196,10 +196,7 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
// 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)
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
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
}
@@ -223,7 +220,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.TypeCode)),
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
)
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
return err
-134
View File
@@ -1,134 +0,0 @@
// 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
}
-6
View File
@@ -33,9 +33,6 @@ 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"`
@@ -99,9 +96,6 @@ 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,
-133
View File
@@ -1,133 +0,0 @@
// 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)
}
-130
View File
@@ -1,130 +0,0 @@
// 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
}
-88
View File
@@ -1,88 +0,0 @@
// 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
}
+7 -28
View File
@@ -85,40 +85,19 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
return results, nil
}
// 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.
// 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).
func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
rules, err := FindRepoProtectedBranchRules(ctx, repoID)
if err != nil {
return nil, err
}
repoRule := rules.GetFirstMatched(branchName)
orgRule, err := getFirstMatchOrgProtectedBranchRule(ctx, repoID, branchName)
if err != nil {
return nil, err
if matched := rules.GetFirstMatched(branchName); matched != nil {
return matched, nil
}
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) {
// Fall back to org-level rules
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
return nil, err
@@ -140,7 +119,7 @@ func getFirstMatchOrgProtectedBranchRule(ctx context.Context, repoID int64, bran
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
-178
View File
@@ -1,178 +0,0 @@
// 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
}
-8
View File
@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
@@ -861,11 +860,6 @@ 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
@@ -894,8 +888,6 @@ 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, "@")
-19
View File
@@ -4,9 +4,7 @@
package issues_test
import (
"strings"
"testing"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
@@ -41,7 +39,6 @@ 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)
@@ -373,22 +370,6 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
}
}
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
// cannot stall pull request processing through catastrophic regex backtracking:
// each compiled rule must enforce a bounded match time.
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
require.Len(t, rules, 1)
maliciousInput := strings.Repeat("a", 30) + "X"
start := time.Now()
_, err := rules[0].Rule.MatchString(maliciousInput)
elapsed := time.Since(start)
require.Error(t, err, "expected MatchTimeout error on pathological input")
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
}
func testGetApprovers(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
// Official reviews are already deduplicated. Allow unofficial reviews
-5
View File
@@ -439,11 +439,6 @@ func prepareMigrationTasks() []*migration {
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
}
-18
View File
@@ -1,18 +0,0 @@
// 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))
}
-25
View File
@@ -1,25 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
// 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))
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
// 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))
}
-31
View File
@@ -1,31 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
// 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))
}
-23
View File
@@ -1,23 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"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))
}
+1 -1
View File
@@ -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. FIXME: some tables do not have "id" column
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
for _, condition := range conditions {
switch cond := condition.(type) {
case *testCond:
+2 -11
View File
@@ -80,11 +80,8 @@ func init() {
}
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
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,
})
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
return db.GetEngine(ctx).Get(externalLoginUser)
}
// LinkExternalToUser link the external user to the user
@@ -121,12 +118,6 @@ 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
+1 -1
View File
@@ -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
}
-54
View File
@@ -1,54 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
"path/filepath"
"testing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
require.NoError(t, err)
entries, err := commit.Tree.ListEntries()
require.NoError(t, err)
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
for _, info := range infos {
nilCommits += util.Iif(info.Commit == nil, 1, 0)
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
}
return nilCommits, nonNilCommits
}
ctx, cancel := context.WithCancel(t.Context())
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
walkGitLogDebugBeforeNext = cancel
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
assert.Equal(t, 3, nilCommits)
walkGitLogDebugBeforeNext = nil
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
assert.Equal(t, 3, nonNilCommits)
assert.Equal(t, 0, nilCommits)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
entryPaths[i] = entry.Name()
}
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
if err != nil {
return err
}
@@ -1,8 +1,6 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
@@ -20,8 +18,10 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// 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 {
// 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
cmd := gitcmd.NewCommand()
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
@@ -54,62 +54,77 @@ 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) && !errors.Is(err, context.DeadlineExceeded) {
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
}
}()
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
return &logNameStatusRepoParser{
treepath: treepath,
paths: paths,
rd: bufReader,
close: func() {
ctxCancel()
stdoutReaderClose()
},
return bufReader, 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
close func()
cancel func()
}
// logNameStatusCommitData represents a commit artifact from git log raw
type logNameStatusCommitData struct {
// 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 {
CommitID string
ParentIDs []string
Paths []bool
}
// walkNext returns the next LogStatusCommitData
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
// Next returns the next LogStatusCommitData
func (g *LogNameStatusRepoParser) Next(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')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
return nil, err
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
}
}
}
ret := logNameStatusCommitData{}
ret := LogNameStatusCommitData{}
if bytes.Equal(g.next, []byte("commit\000")) {
g.next, err = g.rd.ReadSlice('\x00')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
return nil, err
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
}
}
}
@@ -258,10 +273,13 @@ diffloop:
}
}
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
// Close closes the parser
func (g *LogNameStatusRepoParser) Close() {
g.cancel()
}
// walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
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)
@@ -304,9 +322,11 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
}
}
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
defer func() { g.close() }()
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()
}()
results := make([]string, len(paths))
remaining := len(paths)
@@ -320,16 +340,25 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
heaploop:
for {
if walkGitLogDebugBeforeNext != nil {
walkGitLogDebugBeforeNext()
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
break heaploop
}
g.Close()
return nil, ctx.Err()
default:
}
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
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
}
parentRemaining.Remove(current.CommitID)
for i, found := range current.Paths {
@@ -366,14 +395,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.close()
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
parentRemaining = make(container.Set[string])
nextRestart = (remaining * 3) / 4
continue heaploop
@@ -381,6 +410,7 @@ heaploop:
}
parentRemaining.AddMultiple(current.ParentIDs...)
}
g.Close()
resultsMap := map[string]string{}
for i, pth := range paths {
-3
View File
@@ -121,9 +121,6 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
// 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")
}
-23
View File
@@ -4,10 +4,7 @@
package git
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -22,23 +19,3 @@ 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")
}
+7 -63
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
)
// HostMatchList is used to check if a host or IP is in a list.
@@ -24,61 +23,10 @@ type HostMatchList struct {
ipNets []*net.IPNet
}
// 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.
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
const MatchBuiltinExternal = "external"
// 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.
// 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.
const MatchBuiltinPrivate = "private"
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
@@ -145,22 +93,18 @@ func (hl *HostMatchList) checkPattern(host string) bool {
return false
}
// matchesIP determines if the given IP matches any of the configured rules
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
func (hl *HostMatchList) checkIP(ip net.IP) bool {
if slices.Contains(hl.patterns, "*") {
return true
}
for _, builtin := range hl.builtins {
switch builtin {
case MatchBuiltinExternal:
// External address must be a global unicast, must not be in reserved range and must not be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
return true
}
case MatchBuiltinPrivate:
// 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() {
if ip.IsPrivate() {
return true
}
case MatchBuiltinLoopback:
@@ -191,7 +135,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
return true
}
if ip := net.ParseIP(hostname); ip != nil {
return hl.matchesIP(ip)
return hl.checkIP(ip)
}
return false
}
@@ -202,7 +146,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.matchesIP(ip)
return hl.checkPattern(host) || hl.checkIP(ip)
}
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
-57
View File
@@ -159,60 +159,3 @@ func TestHostOrIPMatchesList(t *testing.T) {
}
test(cases)
}
// 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)
}
}
+2 -13
View File
@@ -146,26 +146,15 @@ func ParseControlFile(r io.Reader) (*Package, error) {
var depends strings.Builder
var control strings.Builder
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
s := bufio.NewScanner(r)
s := bufio.NewScanner(io.TeeReader(r, &control))
for s.Scan() {
line := s.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// A binary package control file holds exactly one stanza. Stop at the
// blank line that terminates it, otherwise a crafted control file could
// smuggle additional stanzas (with attacker-chosen Filename/Package
// fields) into the generated repository "Packages" index.
if control.Len() == 0 {
continue
}
break
continue
}
control.WriteString(line)
control.WriteByte('\n')
if line[0] == ' ' || line[0] == '\t' {
switch key {
case "Description":
-15
View File
@@ -184,19 +184,4 @@ 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")
})
}
-9
View File
@@ -17,9 +17,6 @@ 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"`
@@ -52,9 +49,6 @@ 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"`
@@ -82,9 +76,6 @@ 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"`
-15
View File
@@ -1,15 +0,0 @@
// 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"`
}
-30
View File
@@ -1,30 +0,0 @@
// 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"`
}
-32
View File
@@ -1,32 +0,0 @@
// 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"`
}
-30
View File
@@ -1,30 +0,0 @@
// 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"`
}
+10 -18
View File
@@ -86,35 +86,31 @@ func Test_NormalizeEOL(t *testing.T) {
}
func Test_RandomInt(t *testing.T) {
randInt, err := CryptoRandomInt(255)
assert.NoError(t, err)
randInt := CryptoRandomInt(255)
assert.GreaterOrEqual(t, randInt, int64(0))
assert.LessOrEqual(t, randInt, int64(255))
}
func Test_RandomString(t *testing.T) {
str1, err := CryptoRandomString(32)
assert.NoError(t, err)
str1 := CryptoRandomString(32)
var err error
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
assert.NoError(t, err)
assert.True(t, matches)
str2, err := CryptoRandomString(32)
assert.NoError(t, err)
str2 := CryptoRandomString(32)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
assert.NoError(t, err)
assert.True(t, matches)
assert.NotEqual(t, str1, str2)
str3, err := CryptoRandomString(256)
assert.NoError(t, err)
str3 := CryptoRandomString(256)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
assert.NoError(t, err)
assert.True(t, matches)
str4, err := CryptoRandomString(256)
assert.NoError(t, err)
str4 := CryptoRandomString(256)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
assert.NoError(t, err)
assert.True(t, matches)
@@ -123,19 +119,15 @@ func Test_RandomString(t *testing.T) {
}
func Test_RandomBytes(t *testing.T) {
bytes1, err := CryptoRandomBytes(32)
assert.NoError(t, err)
bytes1 := CryptoRandomBytes(32)
bytes2, err := CryptoRandomBytes(32)
assert.NoError(t, err)
bytes2 := CryptoRandomBytes(32)
assert.NotEqual(t, bytes1, bytes2)
bytes3, err := CryptoRandomBytes(256)
assert.NoError(t, err)
bytes3 := CryptoRandomBytes(256)
bytes4, err := CryptoRandomBytes(256)
assert.NoError(t, err)
bytes4 := CryptoRandomBytes(256)
assert.NotEqual(t, bytes3, bytes4)
}
-25
View File
@@ -2411,31 +2411,6 @@
"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.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",
+58 -124
View File
@@ -508,79 +508,41 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
}
}
// 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.IsOwnerVisibleToDoer(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
// reqTeamMembership user should be an team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
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()
}
return
}
denyNonTeamMember(ctx, orgID)
}
}
@@ -1698,29 +1660,29 @@ func Routes() *web.Router {
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
})
// m.Group("/projects", func() {
// m.Combo("").Get(repo.ListProjects).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectOption{}), repo.CreateProject)
// m.Group("/{id}", func() {
// m.Combo("").Get(repo.GetProject).
// Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject).
// Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject)
// m.Post("/{action}", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ChangeProjectStatus)
// m.Group("/columns", func() {
// m.Combo("").Get(repo.ListProjectColumns).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
// m.Group("/{columnId}", func() {
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn)
// m.Combo("/issues").Get(repo.ListProjectColumnIssues).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.AddProjectColumnIssueOption{}), repo.AddIssueToColumn)
// })
// })
// m.Group("/issues/{issueId}", func() {
// m.Patch("/move", reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.MoveProjectColumnIssueOption{}), repo.MoveIssueOnColumn)
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.RemoveIssueFromProject)
// })
// })
// })
// m.Group("/projects", func() {
// m.Combo("").Get(repo.ListProjects).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectOption{}), repo.CreateProject)
// m.Group("/{id}", func() {
// m.Combo("").Get(repo.GetProject).
// Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject).
// Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject)
// m.Post("/{action}", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ChangeProjectStatus)
// m.Group("/columns", func() {
// m.Combo("").Get(repo.ListProjectColumns).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
// m.Group("/{columnId}", func() {
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn)
// m.Combo("/issues").Get(repo.ListProjectColumnIssues).
// Post(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.AddProjectColumnIssueOption{}), repo.AddIssueToColumn)
// })
// })
// m.Group("/issues/{issueId}", func() {
// m.Patch("/move", reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.MoveProjectColumnIssueOption{}), repo.MoveIssueOnColumn)
// m.Delete("", reqToken(), reqRepoWriter(unit.TypeProjects), repo.RemoveIssueFromProject)
// })
// })
// })
// Repo custom fields (repo-scoped key-value metadata)
m.Group("/custom-fields", func() {
m.Get("", repo.GetRepoCustomFields)
@@ -1800,7 +1762,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)
@@ -1823,34 +1785,6 @@ 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() {
@@ -1887,12 +1821,12 @@ func Routes() *web.Router {
m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}").
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
m.Group("/admin", func() {
m.Group("/cron", func() {
-23
View File
@@ -47,9 +47,6 @@ 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,
@@ -214,10 +211,6 @@ func CreateOrgBranchProtection(ctx *context.APIContext) {
if !ok {
return
}
deleteTeams, ok := resolveTeamIDs(ctx, orgID, form.DeleteAllowlistTeams)
if !ok {
return
}
rule := &git_model.OrgProtectedBranch{
OrgID: orgID,
@@ -229,9 +222,6 @@ 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,
@@ -333,19 +323,6 @@ 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
}
-124
View File
@@ -1,124 +0,0 @@
// 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) {
// swagger:operation GET /orgs/{org}/email_domain_policy organization orgGetEmailDomainPolicy
// ---
// summary: Get the organization's email domain policy
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgEmailDomainPolicy"
// "404":
// "$ref": "#/responses/notFound"
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) {
// swagger:operation PATCH /orgs/{org}/email_domain_policy organization orgEditEmailDomainPolicy
// ---
// summary: Create or update the organization's email domain policy. Only fields that are set will be changed
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditOrgEmailDomainPolicyOption"
// responses:
// "200":
// "$ref": "#/responses/OrgEmailDomainPolicy"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
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) {
// swagger:operation DELETE /orgs/{org}/email_domain_policy organization orgDeleteEmailDomainPolicy
// ---
// summary: Remove the organization's email domain policy
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
if err := git_model.DeleteOrgEmailDomainPolicy(ctx, ctx.Org.Organization.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
+1 -19
View File
@@ -6,7 +6,6 @@ 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"
@@ -228,11 +227,7 @@ func ApplyIssueStatusPreset(ctx *context.APIContext) {
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)
}
ctx.APIErrorNotFound()
return
}
ctx.Status(http.StatusNoContent)
@@ -266,19 +261,6 @@ func CopyIssueStatusesFromOrg(ctx *context.APIContext) {
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
-145
View File
@@ -1,145 +0,0 @@
// 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) {
// swagger:operation GET /orgs/{org}/push_policy organization orgGetPushPolicy
// ---
// summary: Get the organization's push policy
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgPushPolicy"
// "404":
// "$ref": "#/responses/notFound"
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) {
// swagger:operation PATCH /orgs/{org}/push_policy organization orgEditPushPolicy
// ---
// summary: Create or update the organization's push policy. Only fields that are set will be changed
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditOrgPushPolicyOption"
// responses:
// "200":
// "$ref": "#/responses/OrgPushPolicy"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
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) {
// swagger:operation DELETE /orgs/{org}/push_policy organization orgDeletePushPolicy
// ---
// summary: Remove the organization's push policy
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
if err := git_model.DeleteOrgPushPolicy(ctx, ctx.Org.Organization.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
-174
View File
@@ -1,174 +0,0 @@
// 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) {
// swagger:operation GET /orgs/{org}/repo_defaults organization orgGetRepoDefaults
// ---
// summary: Get the organization's default repository settings
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgRepoDefaults"
// "404":
// "$ref": "#/responses/notFound"
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) {
// swagger:operation PATCH /orgs/{org}/repo_defaults organization orgEditRepoDefaults
// ---
// summary: Create or update the organization's default repository settings. Only fields that are set will be changed
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditOrgRepoDefaultsOption"
// responses:
// "200":
// "$ref": "#/responses/OrgRepoDefaults"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
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) {
// swagger:operation DELETE /orgs/{org}/repo_defaults organization orgDeleteRepoDefaults
// ---
// summary: Remove the organization's default repository settings
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
if err := git_model.DeleteOrgRepoDefaults(ctx, ctx.Org.Organization.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
-271
View File
@@ -1,271 +0,0 @@
// 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) {
// swagger:operation GET /orgs/{org}/tag_protections organization orgListTagProtections
// ---
// summary: List an organization's tag protection rules
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgTagProtectionList"
// "404":
// "$ref": "#/responses/notFound"
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) {
// swagger:operation GET /orgs/{org}/tag_protections/{id} organization orgGetTagProtection
// ---
// summary: Get a specific org-level tag protection rule
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the tag protection rule
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgTagProtection"
// "404":
// "$ref": "#/responses/notFound"
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) {
// swagger:operation POST /orgs/{org}/tag_protections organization orgCreateTagProtection
// ---
// summary: Create an org-level tag protection rule
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateOrgTagProtectionOption"
// responses:
// "201":
// "$ref": "#/responses/OrgTagProtection"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
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) {
// swagger:operation PATCH /orgs/{org}/tag_protections/{id} organization orgEditTagProtection
// ---
// summary: Edit an org-level tag protection rule. Only fields that are set will be changed
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the tag protection rule
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditOrgTagProtectionOption"
// responses:
// "200":
// "$ref": "#/responses/OrgTagProtection"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
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) {
// swagger:operation DELETE /orgs/{org}/tag_protections/{id} organization orgDeleteTagProtection
// ---
// summary: Delete an org-level tag protection rule
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: id of the tag protection rule
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
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)
}
-3
View File
@@ -9,7 +9,6 @@ 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"
@@ -492,8 +491,6 @@ 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)
}
-3
View File
@@ -1365,9 +1365,6 @@ 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
+9 -39
View File
@@ -30,7 +30,7 @@ type apiMetadata struct {
TargetVersion string `json:"target_version"`
PHPMinimum string `json:"php_minimum"`
Language string `json:"language"`
ExtensionType string `json:"extension_type"`
ExtensionType string `json:"extension_type"`
EntryPoint string `json:"entry_point"`
// deploy
@@ -44,13 +44,6 @@ type apiMetadata struct {
HealthURL string `json:"health_url,omitempty"`
}
// Manifest
// swagger:response Manifest
type swaggerResponseManifest struct {
// in:body
Body apiMetadata `json:"body"`
}
// GetRepoMetadata returns the manifest settings for a repository.
func GetRepoMetadata(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/manifest repository repoGetManifest
@@ -58,17 +51,6 @@ func GetRepoMetadata(ctx *context.APIContext) {
// summary: Get repo manifest settings
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/Manifest"
@@ -89,9 +71,9 @@ func GetRepoMetadata(ctx *context.APIContext) {
return
}
ctx.JSON(http.StatusOK, &apiMetadata{
Name: m.Name,
Org: m.Org,
Description: m.Description,
Name: m.Name,
Org: m.Org,
Description: m.Description,
LicenseSPDX: m.LicenseSPDX,
LicenseName: m.LicenseName,
@@ -107,7 +89,7 @@ func GetRepoMetadata(ctx *context.APIContext) {
TargetVersion: m.TargetVersion,
PHPMinimum: m.PHPMinimum,
Language: m.Language,
ExtensionType: m.ExtensionType,
ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
@@ -129,21 +111,9 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/Manifest"
// Decode into a map to detect which fields were actually sent.
var raw map[string]any
if err := json.NewDecoder(ctx.Req.Body).Decode(&raw); err != nil {
@@ -203,9 +173,9 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
}
ctx.JSON(http.StatusOK, &apiMetadata{
Name: m.Name,
Org: m.Org,
Description: m.Description,
Name: m.Name,
Org: m.Org,
Description: m.Description,
LicenseSPDX: m.LicenseSPDX,
LicenseName: m.LicenseName,
@@ -221,7 +191,7 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
TargetVersion: m.TargetVersion,
PHPMinimum: m.PHPMinimum,
Language: m.Language,
ExtensionType: m.ExtensionType,
ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
-6
View File
@@ -34,7 +34,6 @@ type apiSecurityConfig struct {
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.
@@ -154,7 +153,6 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
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)
@@ -179,9 +177,6 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
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)
@@ -215,6 +210,5 @@ func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurity
BlockOnPush: cfg.BlockOnPush,
SecretScanner: cfg.SecretScanner,
DependScanner: cfg.DependScanner,
CodeScanner: cfg.CodeScanner,
}
}
-38
View File
@@ -159,44 +159,6 @@ type swaggerParameterBodies struct {
// in:body
UpdateBranchProtectionPriories api.UpdateBranchProtectionPriories
// in:body
CreateOrgBranchProtectionOption api.CreateOrgBranchProtectionOption
// in:body
EditOrgBranchProtectionOption api.EditOrgBranchProtectionOption
// in:body
CreateOrgTagProtectionOption api.CreateOrgTagProtectionOption
// in:body
EditOrgTagProtectionOption api.EditOrgTagProtectionOption
// in:body
EditOrgPushPolicyOption api.EditOrgPushPolicyOption
// in:body
EditOrgRepoDefaultsOption api.EditOrgRepoDefaultsOption
// in:body
EditOrgEmailDomainPolicyOption api.EditOrgEmailDomainPolicyOption
// in:body
EditAccessTokenOption api.EditAccessTokenOption
// in:body
IssueBulkAssigneesOption api.IssueBulkAssigneesOption
// in:body
IssueBulkLabelsOption api.IssueBulkLabelsOption
// in:body
IssueBulkMilestoneOption api.IssueBulkMilestoneOption
// in:body
IssueBulkStateOption api.IssueBulkStateOption
// in:body
IssuePriorityDef api.IssuePriorityDef
// in:body
IssueStatusDef api.IssueStatusDef
// in:body
IssueTypeDef api.IssueTypeDef
// in:body
CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions
-49
View File
@@ -41,52 +41,3 @@ type swaggerResponseOrganizationPermissions struct {
// in:body
Body api.OrganizationPermissions `json:"body"`
}
// OrgBranchProtection
// swagger:response OrgBranchProtection
type swaggerResponseOrgBranchProtection struct {
// in:body
Body api.OrgBranchProtection `json:"body"`
}
// OrgBranchProtectionList
// swagger:response OrgBranchProtectionList
type swaggerResponseOrgBranchProtectionList struct {
// in:body
Body []*api.OrgBranchProtection `json:"body"`
}
// OrgTagProtection
// swagger:response OrgTagProtection
type swaggerResponseOrgTagProtection struct {
// in:body
Body api.OrgTagProtection `json:"body"`
}
// OrgTagProtectionList
// swagger:response OrgTagProtectionList
type swaggerResponseOrgTagProtectionList struct {
// in:body
Body []*api.OrgTagProtection `json:"body"`
}
// OrgPushPolicy
// swagger:response OrgPushPolicy
type swaggerResponseOrgPushPolicy struct {
// in:body
Body api.OrgPushPolicy `json:"body"`
}
// OrgRepoDefaults
// swagger:response OrgRepoDefaults
type swaggerResponseOrgRepoDefaults struct {
// in:body
Body api.OrgRepoDefaults `json:"body"`
}
// OrgEmailDomainPolicy
// swagger:response OrgEmailDomainPolicy
type swaggerResponseOrgEmailDomainPolicy struct {
// in:body
Body api.OrgEmailDomainPolicy `json:"body"`
}
-2
View File
@@ -48,7 +48,6 @@ 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"
@@ -156,7 +155,6 @@ func InitWebInstalled(ctx context.Context) {
mustInit(pull_service.Init)
mustInit(automerge.Init)
cascade.Init()
org_service.Init()
mustInit(task.Init)
mustInit(repo_migrations.Init)
eventsource.GetManager().Init()
+21 -140
View File
@@ -8,8 +8,6 @@ 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"
@@ -43,6 +41,9 @@ type preReceiveContext struct {
canCreatePullRequest bool
checkedCanCreatePullRequest bool
canWriteCode bool
checkedCanWriteCode bool
protectedTags []*git_model.ProtectedTag
gotProtectedTags bool
@@ -50,36 +51,24 @@ type preReceiveContext struct {
opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
branchName string
}
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
// CanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) CanWriteCode() bool {
if !ctx.checkedCanWriteCode {
if !ctx.loadPusherAndPermission() {
return false
}
ctx.canWriteCodeUnitCached = &canWrite
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.checkedCanWriteCode = true
}
return *ctx.canWriteCodeUnitCached
return 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) {
// AssertCanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
if !ctx.CanWriteCode() {
if ctx.Written() {
return false
}
@@ -141,7 +130,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName)
default:
ourCtx.assertCanWriteRef(refFullName)
ourCtx.AssertCanWriteCode()
}
if ctx.Written() {
return
@@ -153,8 +142,9 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName()
ctx.branchName = branchName
if !ctx.assertCanWriteRef(refFullName) {
if !ctx.AssertCanWriteCode() {
return
}
@@ -162,15 +152,9 @@ 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 err == nil {
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 {
@@ -455,16 +439,12 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
if !ctx.assertCanWriteRef(refFullName) {
if !ctx.AssertCanWriteCode() {
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)
@@ -478,7 +458,7 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
ctx.gotProtectedTags = true
}
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, ctx.protectedTags, ctx.Repo.Repository, tagName, ctx.opts.UserID)
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: err.Error(),
@@ -606,102 +586,3 @@ 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
}
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
require.NoError(t, baseRepo.LoadOwner(t.Context()))
require.NoError(t, headRepo.LoadOwner(t.Context()))
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
// repo owner write access to exactly this head branch and nothing else.
pr := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: headRepo.OwnerID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "granted-branch",
BaseBranch: "master",
AllowMaintainerEdit: true,
}
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
maintainer := baseRepo.Owner
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
require.NoError(t, err)
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
userPerm: headPerm,
}
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
// ...but another branch in the same push must NOT inherit that grant.
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
}
+9 -3
View File
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return
}
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok {
if ok && twofa.LastUsedPasscode != form.Passcode {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
@@ -81,6 +81,12 @@ func TwoFactorPost(ctx *context.Context) {
}
}
twofa.LastUsedPasscode = form.Passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
+9 -37
View File
@@ -368,21 +368,9 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
opts := &user_service.UpdateOptions{}
// 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.
// Reactivate user if they are deactivated
if !u.IsActive {
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
ctx.ServerError("GetExternalLogin", err)
return
}
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
opts.IsActive = optional.Some(true)
}
// Update GroupClaims
@@ -526,33 +514,17 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
}
// search in external linked users
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
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
}
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
}
// no user found to login
+9 -3
View File
@@ -177,17 +177,23 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
ok, err := twofa.ValidateTOTP(passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
return
}
if !ok {
if !ok || twofa.LastUsedPasscode == passcode {
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
}
}
}
-3
View File
@@ -15,9 +15,6 @@ 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 {
-3
View File
@@ -16,9 +16,6 @@ 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
-3
View File
@@ -15,9 +15,6 @@ 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,
-9
View File
@@ -4,18 +4,9 @@
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 == "" {
-3
View File
@@ -16,9 +16,6 @@ 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,
+1 -1
View File
@@ -98,7 +98,7 @@ func Teams(ctx *context.Context) {
}
}
ctx.Data["Teams"] = teams
ctx.Data["OrgListTeams"] = teams
ctx.Data["Keyword"] = keyword
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
+1 -1
View File
@@ -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) || errors.Is(err, util.ErrPermissionDenied) {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
+28 -19
View File
@@ -58,6 +58,8 @@ 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
@@ -91,11 +93,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
isWiki := false
unitType := unit.TypeCode
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if strings.HasSuffix(repoName, ".wiki") {
if strings.HasSuffix(reponame, ".wiki") {
isWiki = true
unitType = unit.TypeWiki
repoName = repoName[:len(repoName)-5]
reponame = reponame[:len(reponame)-5]
}
owner := ctx.ContextUser
@@ -105,14 +107,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
}
@@ -125,24 +127,31 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
// 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
// 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
}
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 := !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
}
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
}
// check access
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
if askAuth {
// rely on the results of Contexter
if !ctx.IsSigned {
// TODO: support digit auth - which would be Authorization header with digit
if setting.OAuth2.Enabled {
@@ -228,7 +237,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)
+1 -1
View File
@@ -185,7 +185,7 @@ func NewComment(ctx *context.Context) {
} // end if: handle close or reopen
// Handle custom status from the status dropdown (replaces close button for issues with org statuses).
if statusIDStr := ctx.Req.FormValue("status_id"); statusIDStr != "" {
if statusIDStr := ctx.Req.FormValue("status_id"); statusIDStr != "" && statusIDStr != "" {
if statusIDStr == "reopen" {
// Reopen via dropdown
if issue.IsClosed {
@@ -34,64 +34,6 @@ 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")
@@ -104,16 +46,6 @@ 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
}
}
repo.PrepareBranchList(ctx)
if ctx.Written() {
return
-37
View File
@@ -138,13 +138,6 @@ 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
@@ -170,36 +163,6 @@ 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
+12 -27
View File
@@ -448,24 +448,6 @@ 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
@@ -480,24 +462,27 @@ func ifNeedApprovalWith(
}
// don't need approval if the user can write
if ok, err := canWriteActions(ctx, repo, user); err != nil {
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
} else if ok {
} else if perm.CanWrite(unit_model.TypeActions) {
log.Trace("do not need approval because user %d can write", user.ID)
return false, nil
}
// 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)
// 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)
return false, nil
}
// otherwise, need approval
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
return true, nil
}
-102
View File
@@ -1,102 +0,0 @@
// 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")
})
}
+1 -2
View File
@@ -176,8 +176,7 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
}
return err
}
// 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 {
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
return util.NewInvalidArgumentErrorf("invalid provided OTP")
+2 -2
View File
@@ -88,8 +88,8 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
}
}
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION
// Delete stored tokens, since they are invalid. This also prevents us from checking this in subsequent runs.
// Delete stored tokens, since they are invalid. This
// also provents us from checking this in subsequent runs.
u.AccessToken = ""
u.RefreshToken = ""
u.ExpiresAt = time.Time{}
@@ -57,7 +57,12 @@ func TestSource(t *testing.T) {
err := source.refresh(t.Context(), provider, e)
assert.NoError(t, err)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "refresh", e.RefreshToken)
@@ -77,7 +82,12 @@ func TestSource(t *testing.T) {
})
assert.NoError(t, err)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Empty(t, e.RefreshToken)
+13 -16
View File
@@ -24,22 +24,19 @@ 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 {
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
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
}
}
}
// handle Subject
-30
View File
@@ -39,36 +39,6 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThreadOmitsSubjectWhenAccessRevoked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// repo 2 is private; user 4 has no access to it
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(ctx))
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4, RepoID: repo.ID})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
n := &activities_model.Notification{
ID: 12345,
UserID: user.ID,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourceIssue,
IssueID: issue.ID,
UpdatedUnix: timeutil.TimeStampNow(),
Issue: issue,
Repository: repo,
User: user,
}
thread := ToNotificationThread(ctx, n)
// must not leak private issue metadata once access is revoked
assert.Nil(t, thread.Repository)
assert.Nil(t, thread.Subject)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
-14
View File
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"slices"
"time"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
@@ -27,10 +26,6 @@ type ReviewRequestNotifier struct {
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
// codeOwnerMatchBudget caps the total wall-clock time spent evaluating all
// CODEOWNERS rules against all changed files for a single PR.
const codeOwnerMatchBudget = 2 * time.Second
func IsCodeOwnerFile(f string) bool {
return slices.Contains(codeOwnerFiles, f)
}
@@ -98,17 +93,8 @@ func PullRequestCodeOwnersReview(ctx context.Context, pr *issues_model.PullReque
uniqUsers := make(map[int64]*user_model.User)
uniqTeams := make(map[string]*org_model.Team)
// Bound the total time spent matching rules×files. The per-rule MatchTimeout
// only caps a single match; without an aggregate budget a crafted CODEOWNERS
// plus a PR touching many files could still exhaust CPU inside this loop.
matchDeadline := time.Now().Add(codeOwnerMatchBudget)
ruleLoop:
for _, rule := range rules {
for _, f := range changedFiles {
if time.Now().After(matchDeadline) {
log.Warn("CODEOWNERS matching for PR %s#%d exceeded its time budget; some rules were not evaluated", pr.BaseRepo.FullName(), pr.ID)
break ruleLoop
}
shouldMatch := !rule.Negative
matched, _ := rule.Rule.MatchString(f) // err only happens when timeouts, any error can be considered as not matched
if matched == shouldMatch {
-92
View File
@@ -1,92 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package org
import (
"context"
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
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"
repo_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/repository"
)
// Init registers the org notifier so organization repository defaults are applied to
// repositories as they are created in or transferred into the org. The notifier
// pattern avoids the services/repository -> services/org import cycle.
func Init() {
notify_service.RegisterNotifier(&repoDefaultsNotifier{})
}
type repoDefaultsNotifier struct {
notify_service.NullNotifier
}
func (n *repoDefaultsNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
applyOrgRepoDefaults(ctx, u, repo)
}
func (n *repoDefaultsNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
applyOrgRepoDefaults(ctx, nil, repo)
}
// applyOrgRepoDefaults applies the owning organization's default repository settings
// to a repo that has just joined the org. Best-effort: errors are logged and never
// propagated, so a defaults bug can never break repository creation or transfer.
func applyOrgRepoDefaults(ctx context.Context, owner *user_model.User, repo *repo_model.Repository) {
if owner == nil {
if err := repo.LoadOwner(ctx); err != nil {
log.Error("org repo defaults: load owner of repo %d: %v", repo.ID, err)
return
}
owner = repo.Owner
}
if owner == nil || !owner.IsOrganization() {
return
}
defaults, err := git_model.GetOrgRepoDefaults(ctx, owner.ID)
if err != nil {
log.Error("org repo defaults: load for org %d: %v", owner.ID, err)
return
}
if defaults == nil {
return
}
if defaults.ForcePrivate && !repo.IsPrivate {
repo.IsPrivate = true
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("org repo defaults: force private on repo %d: %v", repo.ID, err)
}
}
if defaults.ApplyPRDefaults {
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
if err != nil {
// The repository may not have pull requests enabled; nothing to apply.
return
}
cfg := prUnit.PullRequestsConfig()
cfg.AllowMerge = defaults.AllowMerge
cfg.AllowRebase = defaults.AllowRebase
cfg.AllowRebaseMerge = defaults.AllowRebaseMerge
cfg.AllowSquash = defaults.AllowSquash
cfg.AllowFastForwardOnly = defaults.AllowFastForwardOnly
cfg.DefaultDeleteBranchAfterMerge = defaults.DeleteBranchAfterMerge
if defaults.DefaultMergeStyle != "" {
cfg.DefaultMergeStyle = repo_model.MergeStyle(defaults.DefaultMergeStyle)
}
if err := repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{{
RepoID: repo.ID,
Type: unit.TypePullRequests,
Config: cfg,
}}, nil); err != nil {
log.Error("org repo defaults: update PR unit on repo %d: %v", repo.ID, err)
}
}
}
-7
View File
@@ -220,13 +220,6 @@ func AddTeamMember(ctx context.Context, team *organization.Team, user *user_mode
return err
}
// Enforce the organization email domain policy for new members.
if allowed, err := git_model.OrgEmailDomainAllowed(ctx, team.OrgID, user.Email); err != nil {
return err
} else if !allowed {
return git_model.ErrEmailDomainNotAllowed{Email: user.Email, OrgID: team.OrgID}
}
if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil {
return err
}
+3 -3
View File
@@ -11,8 +11,8 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
@@ -92,7 +92,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
// Trim '--' prefix to prevent command line argument vulnerability.
rel.TagName = strings.TrimPrefix(rel.TagName, "--")
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, rel.Repo, rel.TagName, rel.PublisherID)
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, rel.PublisherID)
if err != nil {
return false, err
}
@@ -439,7 +439,7 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re
if err != nil {
return fmt.Errorf("GetProtectedTags: %w", err)
}
isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, repo, rel.TagName, doer.ID)
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, doer.ID)
if err != nil {
return err
}

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