mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-05 21:23:55 +02:00
Merge branch 'main' into update-eslint-plugins
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
name: free-disk-space
|
||||
description: Free space on / before large cache restores
|
||||
|
||||
# Delete preinstalled toolchains which gitea doesn't use
|
||||
# Delete preinstalled toolchains which gitea doesn't use and show disk space usage
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: sudo rm -rf /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet
|
||||
run: |
|
||||
echo "free space before cleanup:"
|
||||
df -h /
|
||||
for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do
|
||||
sudo rm -rf "$dir" &
|
||||
done
|
||||
wait
|
||||
echo "free space after cleanup:"
|
||||
df -h /
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Restore the go module, build, and golangci-lint caches. Save only o
|
||||
# Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml.
|
||||
# The lint job restores but does not save the gobuild cache, so only one writer
|
||||
# (the gobuild job) populates it and there is no contention on the cache key.
|
||||
# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean
|
||||
# cache and size stays bounded; do not add restore-keys here. PR runs keep them.
|
||||
|
||||
inputs:
|
||||
lint-cache:
|
||||
@@ -18,7 +20,6 @@ runs:
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
|
||||
restore-keys: gomod-${{ runner.os }}-${{ runner.arch }}
|
||||
- if: ${{ github.workflow != 'cache-seeder' }}
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
@@ -30,7 +31,6 @@ runs:
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
|
||||
restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }}
|
||||
- if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }}
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
@@ -42,7 +42,6 @@ runs:
|
||||
with:
|
||||
path: ~/.cache/golangci-lint
|
||||
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
|
||||
restore-keys: golint-${{ runner.os }}-${{ runner.arch }}
|
||||
- if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }}
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
|
||||
@@ -64,6 +64,11 @@ jobs:
|
||||
- ".golangci.yml"
|
||||
- ".editorconfig"
|
||||
- "options/locale/locale_en-US.json"
|
||||
- "models/fixtures/**"
|
||||
- "tests/*.ini.tmpl"
|
||||
- "tests/gitea-repositories-meta/**"
|
||||
- "tests/testdata/**"
|
||||
- "tools/test-integration.sh"
|
||||
|
||||
frontend:
|
||||
- "*.ts"
|
||||
|
||||
Generated
-5
File diff suppressed because one or more lines are too long
@@ -17,6 +17,8 @@ import wc from 'eslint-plugin-wc';
|
||||
import {defineConfig, globalIgnores} from 'eslint/config';
|
||||
import type {ESLint} from 'eslint';
|
||||
|
||||
import unescapedHtmlLiteral from './tools/eslint-rules/unescaped-html-literal.ts';
|
||||
|
||||
const jsExts = ['js', 'mjs', 'cjs'] as const;
|
||||
const tsExts = ['ts', 'mts', 'cts'] as const;
|
||||
|
||||
@@ -64,6 +66,7 @@ export default defineConfig([
|
||||
'@typescript-eslint': typescriptPlugin.plugin,
|
||||
'array-func': arrayFunc,
|
||||
'de-morgan': deMorgan,
|
||||
'gitea': {rules: {'unescaped-html-literal': unescapedHtmlLiteral}},
|
||||
'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203
|
||||
regexp,
|
||||
sonarjs,
|
||||
@@ -960,6 +963,7 @@ export default defineConfig([
|
||||
plugins: {vitest},
|
||||
languageOptions: {globals: globals.vitest},
|
||||
rules: {
|
||||
'gitea/unescaped-html-literal': [0],
|
||||
'vitest/consistent-test-filename': [0],
|
||||
'vitest/consistent-test-it': [0],
|
||||
'vitest/expect-expect': [0],
|
||||
|
||||
@@ -5,14 +5,14 @@ go 1.26.3
|
||||
require (
|
||||
codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570
|
||||
connectrpc.com/connect v1.20.0
|
||||
gitea.com/gitea/runner v1.0.5
|
||||
gitea.com/gitea/runner v1.0.6
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a
|
||||
gitea.com/go-chi/cache v0.2.1
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
|
||||
gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||
gitea.dev/actions-proto-go v0.5.0
|
||||
gitea.dev/actions-proto-go v0.6.0
|
||||
gitea.dev/sdk v1.0.1
|
||||
github.com/42wim/httpsig v1.2.4
|
||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
||||
@@ -24,7 +24,7 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.25.0
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17
|
||||
github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.14
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
||||
github.com/blevesearch/bleve/v2 v2.6.0
|
||||
@@ -42,9 +42,9 @@ require (
|
||||
github.com/ethantkoenig/rupture v1.0.1
|
||||
github.com/felixge/fgprof v0.9.5
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/getkin/kin-openapi v0.138.0
|
||||
github.com/getkin/kin-openapi v0.139.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/go-enry/go-enry/v2 v2.9.6
|
||||
@@ -53,7 +53,7 @@ require (
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-redsync/redsync/v4 v4.16.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/go-webauthn/webauthn v0.17.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
|
||||
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
@@ -78,7 +78,7 @@ require (
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/microsoft/go-mssqldb v1.9.7
|
||||
github.com/minio/minio-go/v7 v7.1.0
|
||||
github.com/minio/minio-go/v7 v7.2.0
|
||||
github.com/msteinert/pam/v2 v2.1.0
|
||||
github.com/niklasfasching/go-org v1.9.1
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
@@ -101,7 +101,7 @@ require (
|
||||
github.com/yohcop/openid-go v1.0.1
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.30.0
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.34.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.41.0
|
||||
@@ -176,7 +176,6 @@ require (
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
@@ -185,11 +184,10 @@ require (
|
||||
github.com/go-enry/go-oniguruma v1.2.1 // indirect
|
||||
github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.5 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
@@ -233,8 +231,8 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.12 // indirect
|
||||
github.com/oasdiff/yaml v0.1.0 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.3.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
|
||||
@@ -10,8 +10,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
gitea.com/gitea/runner v1.0.5 h1:9Idm8XXxXp4aHoLp0Ba0AEwv87YUblp9gk4qbih+DlE=
|
||||
gitea.com/gitea/runner v1.0.5/go.mod h1:ff8SKVat/6FywBGpN4dKi2x3AlHZUfLOA/vVWHf7+Cw=
|
||||
gitea.com/gitea/runner v1.0.6 h1:QG0YB97z1S7bu3q5VX5sOEs5gyER6AG/oSGFS1dJ1Hg=
|
||||
gitea.com/gitea/runner v1.0.6/go.mod h1:q+WGNAMeOZL4fRqvBTbAFgR2tupWtzLOrzoyKcky3QQ=
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a h1:JHoBrfuTSF9Ke9aNfSYj1XRPBHjKPgCApVprnt2Am0M=
|
||||
gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a/go.mod h1:FOsLJIMdpiHzBp3Vby6Wfkdw2ppGscrjgU1IC7E4/zQ=
|
||||
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
|
||||
@@ -26,8 +26,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHq
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I=
|
||||
gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||
gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY=
|
||||
gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||
gitea.dev/sdk v1.0.1 h1:CWXQUQvp2I6YKOWkhYo1Flx2sRNfMK1X9Op4oR2awXs=
|
||||
gitea.dev/sdk v1.0.1/go.mod h1:jCf5Uzz0Jkb61jxNgMxLOCWwle1J1B2nKdcRtxuK9rY=
|
||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||
@@ -94,8 +94,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
@@ -274,8 +274,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
|
||||
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
||||
github.com/getkin/kin-openapi v0.139.0 h1:pBFXcZJFwz9J1X64jzxlOoNgFm+TF7kNrs9+HJVN6Ic=
|
||||
github.com/getkin/kin-openapi v0.139.0/go.mod h1:NGxPfE4PwS/TRXEbyx2RrxDFPZvxcWw31Tw8XXjPZLs=
|
||||
github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
|
||||
github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
@@ -285,8 +285,8 @@ github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1T
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
@@ -305,8 +305,6 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
@@ -330,10 +328,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
|
||||
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
|
||||
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
|
||||
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
@@ -538,8 +536,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
|
||||
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
|
||||
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
|
||||
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
@@ -569,10 +567,10 @@ github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsR
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
||||
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
|
||||
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
|
||||
github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
|
||||
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
|
||||
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
|
||||
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
|
||||
@@ -750,8 +748,8 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.30.0 h1:guXC+uOA325P1FIwUVesPoDcnogHnbPrhV8c+wMHZPE=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.30.0/go.mod h1:0upZTKi9YMMvhavTlKJ3YgQUZE/GJkcLmXT/+6knTXU=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.34.0 h1:3Gv+41azLlj97fUkhkaTCnIOm45mHDwLlAcTDEwHE2s=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.34.0/go.mod h1:T+hA9p13Fxyh4FkVbcEy36HlAGs37QBCifhh7Zt4+dg=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
|
||||
@@ -505,13 +505,19 @@ func NewCommitStatus(ctx context.Context, opts NewCommitStatusOptions) error {
|
||||
opts.CommitStatus.Description = strings.TrimSpace(opts.CommitStatus.Description)
|
||||
opts.CommitStatus.Context = strings.TrimSpace(opts.CommitStatus.Context)
|
||||
opts.CommitStatus.TargetURL = strings.TrimSpace(opts.CommitStatus.TargetURL)
|
||||
opts.CommitStatus.ContextHash = strings.TrimSpace(opts.CommitStatus.ContextHash)
|
||||
opts.CommitStatus.SHA = opts.SHA.String()
|
||||
opts.CommitStatus.CreatorID = opts.Creator.ID
|
||||
opts.CommitStatus.RepoID = opts.Repo.ID
|
||||
opts.CommitStatus.Index = idx
|
||||
log.Debug("NewCommitStatus[%s, %s]: %d", opts.Repo.FullName(), opts.SHA, opts.CommitStatus.Index)
|
||||
|
||||
opts.CommitStatus.ContextHash = hashCommitStatusContext(opts.CommitStatus.Context)
|
||||
// Callers may pre-compute a ContextHash to keep entries that share a
|
||||
// human-readable Context separated (e.g. two workflow files with the
|
||||
// same `name:` — issue #35699). Only derive from Context when unset.
|
||||
if opts.CommitStatus.ContextHash == "" {
|
||||
opts.CommitStatus.ContextHash = HashCommitStatusContext(opts.CommitStatus.Context)
|
||||
}
|
||||
|
||||
// Insert new CommitStatus
|
||||
if err = db.Insert(ctx, opts.CommitStatus); err != nil {
|
||||
@@ -529,8 +535,11 @@ type SignCommitWithStatuses struct {
|
||||
*asymkey_model.SignCommit
|
||||
}
|
||||
|
||||
// hashCommitStatusContext hash context
|
||||
func hashCommitStatusContext(context string) string {
|
||||
// HashCommitStatusContext returns the sha1 hash used to dedupe commit statuses
|
||||
// by Context. Callers that need to keep statuses with the same display Context
|
||||
// separated (e.g. distinct workflow files sharing a `name:`) can mix extra
|
||||
// disambiguating data into the input.
|
||||
func HashCommitStatusContext(context string) string {
|
||||
return fmt.Sprintf("%x", sha1.Sum([]byte(context)))
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
||||
// LFS objects are repository code content, so authorization must require
|
||||
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||
// reuse of an existing LFS object across repositories.
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||
cond := builder.In(
|
||||
"`lfs_meta_object`.repository_id",
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||
)
|
||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HostMatchList is used to check if a host or IP is in a list.
|
||||
@@ -23,10 +24,64 @@ type HostMatchList struct {
|
||||
ipNets []*net.IPNet
|
||||
}
|
||||
|
||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
||||
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||
const MatchBuiltinExternal = "external"
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
||||
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range []string{
|
||||
// IPv4
|
||||
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||
// IPv6
|
||||
"100::/64", // RFC 6666 discard-only
|
||||
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||
"2001:db8::/32", // RFC 3849 documentation
|
||||
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||
} {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
})
|
||||
|
||||
// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose
|
||||
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
for _, ipNet := range reservedIPNets() {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||
// Also called LAN/Intranet.
|
||||
const MatchBuiltinPrivate = "private"
|
||||
|
||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||
@@ -100,11 +155,11 @@ func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
||||
for _, builtin := range hl.builtins {
|
||||
switch builtin {
|
||||
case MatchBuiltinExternal:
|
||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
||||
if ip.IsGlobalUnicast() && !isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinPrivate:
|
||||
if ip.IsPrivate() {
|
||||
if isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinLoopback:
|
||||
|
||||
@@ -159,3 +159,58 @@ 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
|
||||
"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
|
||||
} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||
assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
|
||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
// A binary package control file holds exactly one stanza. Stop at the
|
||||
// blank line that terminates it, otherwise a crafted control file could
|
||||
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||
// fields) into the generated repository "Packages" index.
|
||||
if control.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
|
||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
||||
assert.NotNil(t, p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SingleStanzaOnly", func(t *testing.T) {
|
||||
// A control file with a trailing stanza must not leak the extra fields into
|
||||
// p.Control, otherwise buildPackagesIndices would emit a second package entry
|
||||
// with an attacker-chosen Filename into the repository "Packages" index.
|
||||
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
|
||||
|
||||
p, err := ParseControlFile(content)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "realpkg", p.Name)
|
||||
assert.Equal(t, "1.0.0", p.Version)
|
||||
assert.NotContains(t, p.Control, "openssl")
|
||||
assert.NotContains(t, p.Control, "evil.deb")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
@@ -26,8 +25,14 @@ var (
|
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||
)
|
||||
|
||||
var versionMatcher = sync.OnceValue(func() *regexp.Regexp {
|
||||
return regexp.MustCompile(`\A[0-9]+(?:\.[0-9a-zA-Z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z`)
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
nameMatcher, versionMatcher *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
// https://github.com/rubygems/rubygems/blob/master/lib/rubygems/specification.rb (VALID_NAME_PATTERN)
|
||||
ret.nameMatcher = regexp.MustCompile(`\A[\w.-]+\z`)
|
||||
ret.versionMatcher = regexp.MustCompile(`\A[0-9]+(?:\.[0-9a-zA-Z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z`)
|
||||
return ret
|
||||
})
|
||||
|
||||
// Package represents a RubyGems package
|
||||
@@ -175,11 +180,11 @@ func parseMetadataFile(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spec.Name) == 0 || strings.Contains(spec.Name, "/") {
|
||||
if !globalVars().nameMatcher.MatchString(spec.Name) {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
|
||||
if !versionMatcher().MatchString(spec.Version.Version) {
|
||||
if !globalVars().versionMatcher.MatchString(spec.Version.Version) {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ version:
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, rp)
|
||||
})
|
||||
|
||||
t.Run("InvalidName", func(t *testing.T) {
|
||||
// a name carrying a newline would be re-emitted verbatim into the
|
||||
// line-based compact index, letting an upload forge extra entries
|
||||
for _, quotedName := range []string{`"evil\n1.0.0"`, `"a b"`, `"a/b"`, `""`} {
|
||||
content := test.CompressGzip("name: " + quotedName + "\nversion:\n version: 1\n")
|
||||
rp, err := parseMetadataFile(content)
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "name %s should be rejected", quotedName)
|
||||
assert.Nil(t, rp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseMetadataFile(t *testing.T) {
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Service) Register(
|
||||
}
|
||||
|
||||
labels := req.Msg.Labels
|
||||
hasCancellingSupport, _ := runnerRequestHasCancellingCapability(req.Msg)
|
||||
hasCancellingSupport := slices.Contains(req.Msg.GetCapabilities(), runnerCapabilityCancelling)
|
||||
|
||||
// create new runner
|
||||
name := util.EllipsisDisplayString(req.Msg.Name, 255)
|
||||
@@ -116,26 +116,11 @@ func (s *Service) Register(
|
||||
// state and will run post-step cleanup before finalizing the task.
|
||||
const runnerCapabilityCancelling = "cancelling"
|
||||
|
||||
type capabilityGetter interface {
|
||||
GetCapabilities() []string
|
||||
}
|
||||
|
||||
type declareRequest interface {
|
||||
proto.Message
|
||||
GetVersion() string
|
||||
GetLabels() []string
|
||||
}
|
||||
|
||||
func runnerRequestHasCancellingCapability(req proto.Message) (bool, bool) {
|
||||
if req == nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if typedReq, ok := any(req).(capabilityGetter); ok {
|
||||
return slices.Contains(typedReq.GetCapabilities(), runnerCapabilityCancelling), true
|
||||
}
|
||||
|
||||
return false, false
|
||||
GetCapabilities() []string
|
||||
}
|
||||
|
||||
func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declareRequest) []string {
|
||||
@@ -143,8 +128,8 @@ func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declare
|
||||
runner.Version = req.GetVersion()
|
||||
|
||||
cols := []string{"agent_labels", "version"}
|
||||
hasCancellingSupport, capabilityStateKnown := runnerRequestHasCancellingCapability(req)
|
||||
if capabilityStateKnown && runner.HasCancellingSupport != hasCancellingSupport {
|
||||
hasCancellingSupport := slices.Contains(req.GetCapabilities(), runnerCapabilityCancelling)
|
||||
if runner.HasCancellingSupport != hasCancellingSupport {
|
||||
runner.HasCancellingSupport = hasCancellingSupport
|
||||
cols = append(cols, "has_cancelling_support")
|
||||
}
|
||||
|
||||
@@ -12,47 +12,22 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type capabilityRegisterRequest struct {
|
||||
*runnerv1.RegisterRequest
|
||||
capabilities []string
|
||||
}
|
||||
|
||||
func (r *capabilityRegisterRequest) GetCapabilities() []string {
|
||||
return r.capabilities
|
||||
}
|
||||
|
||||
type capabilityDeclareRequest struct {
|
||||
*runnerv1.DeclareRequest
|
||||
capabilities []string
|
||||
}
|
||||
|
||||
func (r *capabilityDeclareRequest) GetCapabilities() []string {
|
||||
return r.capabilities
|
||||
}
|
||||
|
||||
func TestRunnerRequestHasCancellingCapabilityTypedAccessor(t *testing.T) {
|
||||
registerReq := &capabilityRegisterRequest{
|
||||
RegisterRequest: &runnerv1.RegisterRequest{},
|
||||
capabilities: []string{runnerCapabilityCancelling, "other"},
|
||||
func TestApplyDeclareRequestToRunnerAdvertisedCapabilityEnablesCancelling(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{}
|
||||
req := &runnerv1.DeclareRequest{
|
||||
Version: "1.2.3",
|
||||
Labels: []string{"linux"},
|
||||
Capabilities: []string{runnerCapabilityCancelling, "other"},
|
||||
}
|
||||
hasCapability, known := runnerRequestHasCancellingCapability(registerReq)
|
||||
assert.True(t, hasCapability)
|
||||
assert.True(t, known)
|
||||
|
||||
declareReq := &capabilityDeclareRequest{
|
||||
DeclareRequest: &runnerv1.DeclareRequest{},
|
||||
capabilities: nil,
|
||||
}
|
||||
hasCapability, known = runnerRequestHasCancellingCapability(declareReq)
|
||||
assert.False(t, hasCapability)
|
||||
assert.True(t, known)
|
||||
|
||||
hasCapability, known = runnerRequestHasCancellingCapability(nil)
|
||||
assert.False(t, hasCapability)
|
||||
assert.False(t, known)
|
||||
cols := applyDeclareRequestToRunner(runner, req)
|
||||
assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols)
|
||||
assert.True(t, runner.HasCancellingSupport)
|
||||
assert.Equal(t, "1.2.3", runner.Version)
|
||||
assert.Equal(t, []string{"linux"}, runner.AgentLabels)
|
||||
}
|
||||
|
||||
func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T) {
|
||||
func TestApplyDeclareRequestToRunnerMissingCapabilityDisablesCancelling(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
@@ -61,26 +36,22 @@ func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T
|
||||
Labels: []string{"linux"},
|
||||
}
|
||||
|
||||
cols := applyDeclareRequestToRunner(runner, req)
|
||||
assert.Equal(t, []string{"agent_labels", "version"}, cols)
|
||||
assert.True(t, runner.HasCancellingSupport)
|
||||
assert.Equal(t, "1.2.3", runner.Version)
|
||||
assert.Equal(t, []string{"linux"}, runner.AgentLabels)
|
||||
}
|
||||
|
||||
func TestApplyDeclareRequestToRunnerUpdatesTypedCapabilityState(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
req := &capabilityDeclareRequest{
|
||||
DeclareRequest: &runnerv1.DeclareRequest{
|
||||
Version: "1.2.3",
|
||||
Labels: []string{"linux"},
|
||||
},
|
||||
capabilities: []string{},
|
||||
}
|
||||
|
||||
cols := applyDeclareRequestToRunner(runner, req)
|
||||
assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols)
|
||||
assert.False(t, runner.HasCancellingSupport)
|
||||
}
|
||||
|
||||
func TestApplyDeclareRequestToRunnerUnchangedCapabilityOmitsColumn(t *testing.T) {
|
||||
runner := &actions_model.ActionRunner{
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
req := &runnerv1.DeclareRequest{
|
||||
Version: "1.2.3",
|
||||
Labels: []string{"linux"},
|
||||
Capabilities: []string{runnerCapabilityCancelling},
|
||||
}
|
||||
|
||||
cols := applyDeclareRequestToRunner(runner, req)
|
||||
assert.Equal(t, []string{"agent_labels", "version"}, cols)
|
||||
assert.True(t, runner.HasCancellingSupport)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ func ListForks(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -79,6 +80,7 @@ func ListForks(ctx *context.APIContext) {
|
||||
apiForks[i] = convert.ToRepo(ctx, fork, permission)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, apiForks)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package private
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -18,7 +19,6 @@ import (
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func authInternal(next http.Handler) http.Handler {
|
||||
@@ -50,6 +50,18 @@ func bind[T any](_ T) any {
|
||||
}
|
||||
}
|
||||
|
||||
// setRealIP sets RemoteAddr from the trusted X-Real-IP header set by the internal API
|
||||
// client (see modules/private.NewInternalRequest); the internal API is gated by InternalToken.
|
||||
// It replaces chi's deprecated middleware.RealIP, which is unsafe on public-facing endpoints.
|
||||
func setRealIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if ip := req.Header.Get("X-Real-IP"); net.ParseIP(ip) != nil {
|
||||
req.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// Routes registers all internal APIs routes to web application.
|
||||
// These APIs will be invoked by internal commands for example `gitea serv` and etc.
|
||||
func Routes() *web.Router {
|
||||
@@ -58,7 +70,7 @@ func Routes() *web.Router {
|
||||
r.AfterRouting(authInternal)
|
||||
// Log the real ip address of the request from SSH is really helpful for diagnosing sometimes.
|
||||
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
|
||||
r.AfterRouting(chi_middleware.RealIP)
|
||||
r.AfterRouting(setRealIP)
|
||||
|
||||
r.Get("/dummy", misc.DummyOK)
|
||||
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
|
||||
|
||||
@@ -525,10 +525,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxCommitSigning(ctx *context.Con
|
||||
}
|
||||
|
||||
if data.requireSigned && !data.willSign {
|
||||
data.infoProtectionBlockers.AddErrorItem(
|
||||
svg.RenderHTML("octicon-x"),
|
||||
ctx.Locale.Tr("repo.pulls.require_signed_wont_sign"),
|
||||
)
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.require_signed_wont_sign"))
|
||||
if wontSignReason != "" {
|
||||
data.infoProtectionBlockers.AddInfoItem(
|
||||
svg.RenderHTML("octicon-unlock"),
|
||||
@@ -1053,29 +1050,28 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxProtectedRules(ctx *context.Co
|
||||
if pb.EnableApprovalsWhitelist {
|
||||
blockerInfo = ctx.Locale.Tr("repo.pulls.blocked_by_approvals_whitelisted", grantedApprovals, pb.RequiredApprovals)
|
||||
}
|
||||
data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), blockerInfo)
|
||||
data.infoProtectionBlockers.AddErrorItem(blockerInfo)
|
||||
}
|
||||
|
||||
data.isBlockedByRejection = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull)
|
||||
if data.isBlockedByRejection {
|
||||
data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_rejection"))
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_rejection"))
|
||||
}
|
||||
|
||||
data.isBlockedByOfficialReviewRequests = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull)
|
||||
if data.isBlockedByOfficialReviewRequests {
|
||||
data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_official_review_requests"))
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_official_review_requests"))
|
||||
}
|
||||
|
||||
data.isBlockedByOutdatedBranch = issues_model.MergeBlockedByOutdatedBranch(pb, pull)
|
||||
if data.isBlockedByOutdatedBranch {
|
||||
data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_outdated_branch"))
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_outdated_branch"))
|
||||
}
|
||||
|
||||
data.isBlockedByChangedProtectedFiles = len(pull.ChangedProtectedFiles) != 0
|
||||
if data.isBlockedByChangedProtectedFiles {
|
||||
detailItems := escapeStringSliceToHTML(pull.ChangedProtectedFiles)
|
||||
data.infoProtectionBlockers.AddErrorItem(
|
||||
svg.RenderHTML("octicon-x"),
|
||||
ctx.Locale.TrN(len(pull.ChangedProtectedFiles), "repo.pulls.blocked_by_changed_protected_files_1", "repo.pulls.blocked_by_changed_protected_files_n"),
|
||||
detailItems,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -484,15 +483,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
|
||||
|
||||
if data.enableStatusCheck {
|
||||
if statusCheckData.RequiredChecksState.IsError() || statusCheckData.RequiredChecksState.IsFailure() {
|
||||
data.infoProtectionBlockers.AddErrorItem(
|
||||
svg.RenderHTML("octicon-x"),
|
||||
ctx.Locale.Tr("repo.pulls.required_status_check_failed"),
|
||||
)
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_failed"))
|
||||
} else if !statusCheckData.RequiredChecksState.IsSuccess() {
|
||||
data.infoProtectionBlockers.AddErrorItem(
|
||||
svg.RenderHTML("octicon-x"),
|
||||
ctx.Locale.Tr("repo.pulls.required_status_check_missing"),
|
||||
)
|
||||
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_missing"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
)
|
||||
|
||||
type pullMergeBoxInfoItem struct {
|
||||
ItemClass string
|
||||
SvgIconHTML template.HTML
|
||||
InfoHTML template.HTML
|
||||
ListItems []template.HTML
|
||||
@@ -42,10 +41,9 @@ func (c *pullMergeBoxInfoItemCollection) AddInfoItem(svg, info template.HTML, op
|
||||
})
|
||||
}
|
||||
|
||||
func (c *pullMergeBoxInfoItemCollection) AddErrorItem(svg, info template.HTML, optItems ...[]template.HTML) {
|
||||
func (c *pullMergeBoxInfoItemCollection) AddErrorItem(info template.HTML, optItems ...[]template.HTML) {
|
||||
c.items = append(c.items, &pullMergeBoxInfoItem{
|
||||
ItemClass: "tw-text-red",
|
||||
SvgIconHTML: svg,
|
||||
SvgIconHTML: svg.RenderHTML("octicon-x", 16, "tw-text-red"),
|
||||
InfoHTML: info,
|
||||
ListItems: util.OptionalArg(optItems),
|
||||
})
|
||||
@@ -151,10 +149,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context
|
||||
ctx.Locale.Tr("repo.pulls.is_empty"),
|
||||
)
|
||||
} else {
|
||||
prInfo.MergeBoxData.infoProtectionBlockers.AddErrorItem(
|
||||
svg.RenderHTML("octicon-x"),
|
||||
ctx.Locale.Tr("repo.pulls.cannot_auto_merge_desc"),
|
||||
)
|
||||
prInfo.MergeBoxData.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.cannot_auto_merge_desc"))
|
||||
prInfo.MergeBoxData.infoProtectionBlockers.AddInfoItem(
|
||||
svg.RenderHTML("octicon-info"),
|
||||
ctx.Locale.Tr("repo.pulls.cannot_auto_merge_helper"),
|
||||
|
||||
@@ -139,10 +139,24 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
|
||||
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
|
||||
// TODO: store workflow name as a field in ActionRun to avoid parsing
|
||||
runName := path.Base(run.WorkflowID)
|
||||
// fall back to the file name when the workflow has no non-blank `name:`
|
||||
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
|
||||
runName = wfs[0].Name
|
||||
if name := strings.TrimSpace(wfs[0].Name); name != "" {
|
||||
runName = name
|
||||
}
|
||||
}
|
||||
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
|
||||
// Mix the workflow file path into the hash so two workflow files that
|
||||
// share the same `name:` and job name produce distinct commit statuses
|
||||
// even though they render identically — matching GitHub's behavior
|
||||
// (issue #35699).
|
||||
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
|
||||
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
|
||||
// the legacy hash is still the "latest" for this SHA, reuse that hash so
|
||||
// the new row supersedes it; otherwise the old pending status would stay
|
||||
// stuck forever (it lives in its own dedupe group). Only relevant for
|
||||
// in-flight workflows at upgrade time.
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
state := toCommitStatus(job.Status)
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
description := toCommitStatusDescription(job)
|
||||
@@ -152,7 +166,13 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.Context == ctxName {
|
||||
if v.ContextHash == legacyHash && v.Context == ctxName {
|
||||
ctxHash = legacyHash
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.ContextHash == ctxHash {
|
||||
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
||||
return nil
|
||||
}
|
||||
@@ -166,6 +186,7 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
||||
TargetURL: targetURL,
|
||||
Description: description,
|
||||
Context: ctxName,
|
||||
ContextHash: ctxHash,
|
||||
State: state,
|
||||
CreatorID: creator.ID,
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
@@ -146,6 +148,158 @@ func TestGetCommitActionsStatusMap(t *testing.T) {
|
||||
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
|
||||
// two workflow files with the same `name:` and same job name must produce
|
||||
// two distinct commit statuses, not be deduplicated into one.
|
||||
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
payload := []byte(`
|
||||
name: test-run
|
||||
on: pull_request
|
||||
jobs:
|
||||
my-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`)
|
||||
|
||||
for _, spec := range []struct {
|
||||
workflowID string
|
||||
runID, jobID int64
|
||||
}{
|
||||
{"workflow1.yaml", 99101, 99201},
|
||||
{"workflow2.yaml", 99102, 99202},
|
||||
} {
|
||||
run := &actions_model.ActionRun{
|
||||
ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-test", Status: actions_model.StatusWaiting,
|
||||
WorkflowPayload: payload,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, run, job))
|
||||
}
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both workflow files should produce a row even though the display
|
||||
// Context is identical — matching GitHub's behavior.
|
||||
hashes := map[string]struct{}{}
|
||||
targets := map[string]struct{}{}
|
||||
for _, st := range statuses {
|
||||
hashes[st.ContextHash] = struct{}{}
|
||||
targets[st.TargetURL] = struct{}{}
|
||||
assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
|
||||
}
|
||||
assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
|
||||
assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
|
||||
// status created before the fix (hashed from Context alone) must still be
|
||||
// superseded by a follow-up event, instead of being orphaned in its own dedupe
|
||||
// group while a new row accumulates under the new hash.
|
||||
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
ctxName := "legacy.yaml / my-job (push)"
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
sha, err := git.NewIDFromString(branch.CommitID)
|
||||
require.NoError(t, err)
|
||||
creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo,
|
||||
Creator: creator,
|
||||
SHA: sha,
|
||||
CommitStatus: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
Context: ctxName,
|
||||
ContextHash: legacyHash,
|
||||
TargetURL: "https://example.invalid/legacy",
|
||||
Description: "Waiting to run",
|
||||
},
|
||||
}))
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-job", Status: actions_model.StatusSuccess,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
|
||||
|
||||
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
// The new row must reuse the legacy hash so GetLatestCommitStatus returns
|
||||
// only one entry for this Context — the success, not the orphaned pending.
|
||||
matches := 0
|
||||
for _, s := range latest {
|
||||
if s.Context == ctxName {
|
||||
matches++
|
||||
assert.Equal(t, legacyHash, s.ContextHash)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, matches)
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
|
||||
// non-blank `name:` uses the file name in the Context, not an empty
|
||||
// "/ job (event)" — covers both an omitted and a whitespace-only name.
|
||||
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
for _, tc := range []struct {
|
||||
workflowID string
|
||||
runID, jobID int64
|
||||
payload string
|
||||
}{
|
||||
{"unnamed.yaml", 99401, 99411, "on: push\n"},
|
||||
{"blank.yaml", 99402, 99412, "name: \" \"\non: push\n"},
|
||||
} {
|
||||
run := &actions_model.ActionRun{
|
||||
ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-test", Status: actions_model.StatusWaiting,
|
||||
WorkflowPayload: []byte(tc.payload + `jobs:
|
||||
my-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
|
||||
|
||||
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
|
||||
require.Len(t, statuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -129,11 +129,7 @@
|
||||
<div class="flex-text-inline">
|
||||
{{if .Author}}
|
||||
{{ctx.AvatarUtils.Avatar .Author 20}}
|
||||
{{if .Author.FullName}}
|
||||
<a href="{{.Author.HomeLink}}"><strong>{{.Author.FullName}}</strong></a>
|
||||
{{else}}
|
||||
<a href="{{.Author.HomeLink}}"><strong>{{.Commit.Author.Name}}</strong></a>
|
||||
{{end}}
|
||||
<strong>{{.Author.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 20}}
|
||||
<strong>{{.Commit.Author.Name}}</strong>
|
||||
@@ -141,18 +137,19 @@
|
||||
</div>
|
||||
<span class="tw-text-text-light">{{DateUtils.TimeSince .Commit.Committer.When}}</span>
|
||||
|
||||
<div class="flex-text-inline">
|
||||
{{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}}
|
||||
{{$committerIsAuthor := and (eq .Commit.Committer.Name .Commit.Author.Name) (eq .Commit.Committer.Email .Commit.Author.Email)}}
|
||||
{{if not $committerIsAuthor}}
|
||||
<div class="flex-text-inline">
|
||||
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span>
|
||||
{{if and .Verification.CommittingUser .Verification.CommittingUser.ID}}
|
||||
{{if and .Verification.CommittingUser}}
|
||||
{{ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}}
|
||||
<a href="{{.Verification.CommittingUser.HomeLink}}"><strong>{{.Commit.Committer.Name}}</strong></a>
|
||||
<strong>{{.Verification.CommittingUser.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 20}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Email 20}}
|
||||
<strong>{{.Commit.Committer.Name}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .CommitOtherParticipants}}
|
||||
<div class="flex-text-inline">
|
||||
@@ -162,16 +159,12 @@
|
||||
{{$gitIdentity := $participant.GitIdentity}}
|
||||
{{if $user}}
|
||||
{{ctx.AvatarUtils.Avatar $user 20}}
|
||||
<a class="muted" href="{{$user.HomeLink}}"><strong>{{$user.GetDisplayName}}</strong></a>
|
||||
<strong>{{$user.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{$gitName := $gitIdentity.Name}}
|
||||
{{$gitEmail := $gitIdentity.Email}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}}
|
||||
{{if $gitEmail}}
|
||||
<a class="muted" href="mailto:{{$gitEmail}}"><strong>{{$gitName}}</strong></a>
|
||||
{{else}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitEmail 20}}{{/* use the same layout as the "author" above */}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{{if $infoSection.InfoItems}}
|
||||
<div class="item">
|
||||
{{range $infoItem := $infoSection.InfoItems}}
|
||||
<div class="flex-text-block {{$infoItem.ItemClass}}">{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}</div>
|
||||
<div class="flex-text-block">{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}</div>
|
||||
{{if $infoItem.ListItems}}
|
||||
<ul class="tw-pl-[36px]">{{/* align with the info icon and text */}}
|
||||
{{range $listItem := $infoItem.ListItems}}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{$cs.Context}} <span class="tw-text-text-light-2">{{$cs.Description}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-details">
|
||||
<div class="flex-text-block">
|
||||
{{if and $statusCheckData $statusCheckData.IsContextRequired}}
|
||||
{{if (call $statusCheckData.IsContextRequired $cs.Context)}}
|
||||
<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>
|
||||
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -680,14 +680,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
|
||||
assert.Len(t, latestCommitStatuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
|
||||
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
}
|
||||
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
|
||||
// insertFakeStatus inserts a success status that lands in the same dedupe
|
||||
// group as `prev` — the actions runner mixes the workflow file path into
|
||||
// ContextHash, so we must reuse it (rather than recomputing from Context).
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
|
||||
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: targetURL,
|
||||
Context: context,
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: prev.TargetURL,
|
||||
Context: prev.Context,
|
||||
ContextHash: prev.ContextHash,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -822,7 +826,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -89,7 +89,7 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
|
||||
assert.Equal(t, "0", resp.Header().Get("X-Total-Count"))
|
||||
})
|
||||
|
||||
t.Run("Logged in", func(t *testing.T) {
|
||||
t.Run("LoggedIn", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
|
||||
@@ -107,6 +107,36 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
|
||||
assert.Len(t, forks, 2)
|
||||
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||
})
|
||||
|
||||
t.Run("RespHeaderLinks", func(t *testing.T) {
|
||||
t.Run("Page1", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=1&limit=1").AddTokenAuth(user1Token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
linkHeader := resp.Header().Get("Link")
|
||||
assert.NotEmpty(t, linkHeader, "Link header should not be empty")
|
||||
assert.Contains(t, linkHeader, `rel="next"`)
|
||||
assert.Contains(t, linkHeader, `rel="last"`)
|
||||
assert.Contains(t, linkHeader, `/api/v1/repos/user2/repo1/forks?limit=1&page=2>`)
|
||||
|
||||
forks := DecodeJSON(t, resp, []*api.Repository{})
|
||||
assert.Len(t, forks, 1)
|
||||
})
|
||||
|
||||
t.Run("Page2", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=2&limit=1").AddTokenAuth(user1Token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
forks := DecodeJSON(t, resp, []*api.Repository{})
|
||||
assert.Len(t, forks, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func testGetPrivateReposForks(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ RUN_MODE = prod
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = gitea-test.db
|
||||
SQLITE_JOURNAL_MODE = WAL
|
||||
|
||||
[indexer]
|
||||
REPO_INDEXER_ENABLED = true
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// MIT license, Copyright (c) GitHub, Inc.
|
||||
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import rule from './unescaped-html-literal.ts';
|
||||
import {RuleTester} from 'eslint';
|
||||
|
||||
class VitestRuleTester extends RuleTester {
|
||||
static describe = describe;
|
||||
static it = it;
|
||||
static itOnly = it.only;
|
||||
}
|
||||
|
||||
const ruleTester = new VitestRuleTester();
|
||||
|
||||
ruleTester.run('unescaped-html-literal', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: '`Hello World!`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: "'Hello World!'",
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: '"Hello World!"',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: 'const helloTemplate = () => html`<div>Hello World!</div>`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
{
|
||||
code: 'const helloTemplate = (name) => html`<div>Hello ${name}!</div>`;',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "const helloHTML = '<div>Hello, World!</div>'",
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = "<h1>Hello, World!</h1>"',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = `<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = ` \n\t<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const helloHTML = foo`<div>Hello ${name}!</div>`',
|
||||
languageOptions: {ecmaVersion: 2017},
|
||||
errors: [
|
||||
{
|
||||
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// MIT license, Copyright (c) GitHub, Inc.
|
||||
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
|
||||
import type {JSRuleDefinition, JSRuleDefinitionTypeOptions} from 'eslint';
|
||||
|
||||
const htmlOpenTag = /^\s*<[a-zA-Z]/;
|
||||
|
||||
const rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
messages: {
|
||||
unescapedHtmlLiteral: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unescapedHtmlLiteral',
|
||||
});
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
const templateStart = node.quasis[0]?.value.raw;
|
||||
if (!templateStart || !htmlOpenTag.test(templateStart)) return;
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unescapedHtmlLiteral',
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
+4
-1
@@ -4,7 +4,10 @@ import {stringPlugin} from 'vite-string-plugin';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['web_src/**/*.test.ts'],
|
||||
include: [
|
||||
'web_src/**/*.test.ts',
|
||||
'tools/eslint-rules/**/*.test.ts',
|
||||
],
|
||||
setupFiles: ['web_src/js/vitest.setup.ts'],
|
||||
environment: 'happy-dom',
|
||||
testTimeout: 20000,
|
||||
|
||||
@@ -848,6 +848,7 @@ table th[data-sortt-desc] .svg {
|
||||
}
|
||||
|
||||
/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */
|
||||
.ui.modal .header.flex-left-right,
|
||||
.flex-left-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1855,8 +1855,10 @@ tbody.commit-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commit-status-item {
|
||||
.commit-status-item { /* the item can be used at 2 places: PR's merge box (commit-status-list), commit's status popup (no commit-status-list) */
|
||||
height: 40px;
|
||||
padding-top: 0 !important; /* use "height" + "align items center", don't use padding-y (from the list container) to layout */
|
||||
padding-bottom: 0 !important;
|
||||
display: flex;
|
||||
gap: var(--gap-block);
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
@@ -45,10 +45,11 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
|
||||
function addCopyLink(file: Partial<CustomDropzoneFile>) {
|
||||
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
|
||||
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
|
||||
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
||||
</div>`);
|
||||
const copyLinkEl = createElementFromHTML<HTMLDivElement>(html`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svgRaw('octicon-copy', 14)} Copy link</a>
|
||||
</div>
|
||||
`);
|
||||
copyLinkEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {createElementFromHTML, showElem} from '../utils/dom.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
let i18nTextEdited: string;
|
||||
let i18nTextOptions: string;
|
||||
@@ -12,21 +13,22 @@ let i18nTextDeleteFromHistory: string;
|
||||
let i18nTextDeleteFromHistoryConfirm: string;
|
||||
|
||||
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
|
||||
const elDetailDialog = createElementFromHTML(`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svg('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<div>${itemTitleHtml}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
const elDetailDialog = createElementFromHTML(html`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svgRaw('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<div>${htmlRaw(itemTitleHtml)}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>`);
|
||||
`);
|
||||
document.body.append(elDetailDialog);
|
||||
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
|
||||
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
|
||||
@@ -93,12 +95,13 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
|
||||
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
|
||||
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
|
||||
const menuHtml = `
|
||||
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
const menuHtml = html`
|
||||
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
`;
|
||||
|
||||
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
|
||||
elHeaderLeft.append(createElementFromHTML(menuHtml));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {
|
||||
addDelegatedEventListener,
|
||||
@@ -274,15 +274,13 @@ export function initRepoPullRequestReview() {
|
||||
|
||||
let ntr = tr.nextElementSibling;
|
||||
if (!ntr?.classList.contains('add-comment')) {
|
||||
ntr = createElementFromHTML(`
|
||||
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
|
||||
${isSplit ? `
|
||||
<td class="add-comment-left" colspan="4"></td>
|
||||
<td class="add-comment-right" colspan="4"></td>
|
||||
` : `
|
||||
<td class="add-comment-left add-comment-right" colspan="5"></td>
|
||||
`}
|
||||
</tr>`);
|
||||
const tdSplit = html`<td class="add-comment-left" colspan="4"></td><td class="add-comment-right" colspan="4"></td>`;
|
||||
const tdUnified = html`<td class="add-comment-left add-comment-right" colspan="5"></td>`;
|
||||
ntr = createElementFromHTML(html`
|
||||
<tr class="add-comment" data-line-type="${lineType}">
|
||||
${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)}
|
||||
</tr>
|
||||
`);
|
||||
tr.after(ntr);
|
||||
}
|
||||
const td = ntr.querySelector(`.add-comment-${side}`)!;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
|
||||
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
||||
import type {Intent} from '../types.ts';
|
||||
@@ -44,9 +44,8 @@ type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast};
|
||||
|
||||
/** See https://github.com/apvarun/toastify-js#api for options */
|
||||
function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast | null {
|
||||
const body = useHtmlBody ? message : htmlEscape(message);
|
||||
const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
|
||||
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : '';
|
||||
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${message}` : preventDuplicates) : '';
|
||||
|
||||
// prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
|
||||
if (preventDuplicates) {
|
||||
@@ -61,12 +60,13 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
|
||||
}
|
||||
|
||||
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
|
||||
const bodyHtml = useHtmlBody ? message : htmlEscape(message);
|
||||
const toast = Toastify({
|
||||
selector: parent,
|
||||
text: `
|
||||
<div class='toast-icon'>${svg(icon)}</div>
|
||||
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div>
|
||||
<button class='btn toast-close'>${svg('octicon-x')}</button>
|
||||
text: html`
|
||||
<div class='toast-icon'>${svgRaw(icon)}</div>
|
||||
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${htmlRaw(bodyHtml)}</div>
|
||||
<button class='btn toast-close'>${svgRaw('octicon-x')}</button>
|
||||
`,
|
||||
escapeMarkup: false,
|
||||
gravity: gravity ?? 'top',
|
||||
|
||||
@@ -199,6 +199,10 @@ export function svg(name: SvgName, size = 16, classNames?: string | string[]): s
|
||||
return serializeXml(svgNode);
|
||||
}
|
||||
|
||||
export function svgRaw(name: SvgName, size = 16, classNames?: string | string[]) {
|
||||
return htmlRaw(svg(name, size, classNames));
|
||||
}
|
||||
|
||||
export function svgParseOuterInner(name: SvgName) {
|
||||
const svgStr = svgs[name];
|
||||
if (!svgStr) throw new Error(`Unknown SVG icon: ${name}`);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<TR data-x="1"><td>foo</td></TR>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<trx></trx>').outerHTML).toEqual('<trx></trx>');
|
||||
});
|
||||
|
||||
test('createElementFromAttrs', () => {
|
||||
|
||||
@@ -267,8 +267,14 @@ export function isElemVisible(el: HTMLElement): boolean {
|
||||
|
||||
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||
htmlString = htmlString.trim();
|
||||
const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
|
||||
const startsWithTag = (s: string, tag: string) => {
|
||||
return s.startsWith('<') &&
|
||||
s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() &&
|
||||
!isLetter(s[1 + tag.length].charCodeAt(0));
|
||||
};
|
||||
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
||||
if (htmlString.startsWith('<tr')) {
|
||||
if (startsWithTag(htmlString, 'tr')) {
|
||||
const container = document.createElement('table');
|
||||
container.innerHTML = htmlString;
|
||||
return container.querySelector<T>('tr')!;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {html, htmlRaw} from './html.ts';
|
||||
|
||||
export function urlQueryEscape(s: string) {
|
||||
// See "TestQueryEscape" in backend
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
@@ -35,9 +37,9 @@ const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\
|
||||
const trailingPunctPattern = /[.,;:!?]+$/;
|
||||
|
||||
// Convert URLs to clickable links in HTML, preserving existing HTML tags
|
||||
export function linkifyURLs(html: string): string {
|
||||
export function linkifyURLs(htmlString: string): string {
|
||||
let inAnchor = false;
|
||||
return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
|
||||
return htmlString.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
|
||||
// skip URLs inside existing <a> tags
|
||||
if (openTag === 'a') {
|
||||
inAnchor = true;
|
||||
@@ -54,6 +56,6 @@ export function linkifyURLs(html: string): string {
|
||||
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
|
||||
const trailing = trailingPunct ? trailingPunct[0] : '';
|
||||
// safe because regexp only matches valid URLs (no quotes or angle brackets)
|
||||
return `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${trailing}`;
|
||||
return html`<a href="${htmlRaw(cleanUrl)}" target="_blank">${htmlRaw(cleanUrl)}</a>${htmlRaw(trailing)}`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user