Compare commits

..

14 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] e28e79a648 fix(acme): avoid nilnil lint finding
Co-authored-by: techknowlogick <164197+techknowlogick@users.noreply.github.com>
2026-10-01 12:39:42 +00:00
copilot-swe-agent[bot] 6a0e125018 feat(acme): support EAB credentials
Co-authored-by: techknowlogick <164197+techknowlogick@users.noreply.github.com>
2026-09-30 23:11:43 +00:00
copilot-swe-agent[bot] 1c19e2ba43 Initial plan 2026-09-30 23:02:33 +00:00
Zain Qureshi b0d6cc1d22 docs: correct three stale defaults in app.example.ini (#39500)
Three commented defaults in `custom/conf/app.example.ini` differ from
what Gitea actually uses, so the file says they default to something
else:

- `MINIMUM_KEY_SIZE_CHECK`: example `false`, code `true`
(`modules/setting/ssh.go:55`, read at `:152`).
- `SSH_SERVER_HOST_KEYS`: example lists `ssh/gitea.rsa, ssh/gogs.rsa`,
code also loads `ssh/gitea.ed25519` and `ssh/gitea.ecdsa`
(`modules/setting/ssh.go:57`).
- `[queue] DATADIR`: example `queues/`, code `queues/common`
(`modules/setting/queue.go:33`), which the comment above it already
states.

Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 15:49:22 -07:00
silverwind 8936303510 fix: add missing checks to several API and web handlers (#39501)
Several handlers skipped checks that their sibling routes or settings already enforce. This brings them in line.

- Push mirror API honors `DISABLE_NEW_PUSH` and checks the caller's permission
- Media API serves small files with the usual content headers
- Issue attachment API ignores comment attachments
- Push-to-create respects `FORCE_PRIVATE`
- Profile feeds and follow actions respect `ENABLE_FEED` and owner visibility
- Tag delete route refuses release tags
- Refresh token grant only accepts refresh tokens
- Gitea migrations bound the source's page size

Co-authored-by: bircni <bircni@icloud.com>
2026-09-30 20:25:14 +02:00
Zettat123 3d085dbaf1 feat(admin): show and filter users by authentication source (#38900) 2026-09-30 11:28:35 -06:00
Nico Schlömer 9b2a3c267b fix(markup): don't escape ambiguous characters in MathML (#39493)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:37:30 +00:00
silverwind 590d2984d9 fix(ui): ignore code line anchors below 1, show JS errors in vite dev mode (#39432) 2026-09-30 15:03:40 +00:00
Nico Schlömer 61e0343580 fix(markup): skip post-processing inside MathML (#39497)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:41:41 +02:00
wxiaoguang 0b43bde974 fix: copy new access token to clipboard (#39496)
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 21:41:55 +08:00
wxiaoguang cc95f141f8 fix: npm route (#39488) 2026-09-30 19:49:50 +08:00
Roshan Ramani a25fbd43c4 docs: update app.example.ini for defaults changed in #39400 (#39456)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 11:28:59 +00:00
JerryLien f365a6b9c8 fix(actions): keep runs order after auto refresh (#39479)
On a repository's Actions tab, runs are sorted newest first on initial
page load. After the first auto refresh (added in #38329, every 3
seconds while runs are active and every 12 seconds otherwise), the same
runs may appear in a different order and move again as their status
changes.

Example with four runs (Gitea 28.0.0, SQLite):

```
page load:     #10 success, #9 failure, #8 success, #7 running
after refresh: #8 success, #10 success, #9 failure, #7 running
```

To reproduce, open the Actions tab of a repository with runs in
different statuses and wait for an auto refresh. On SQLite, the runs may
be regrouped by status, with each group ordered oldest first.

`preparePartialRefreshRuns` reloads the runs currently shown on the page
using `GetRunsByRepoAndID`. That query has no `ORDER BY`, while the
initial page load uses `FindRunOptions.ToOrders` and sorts by index
descending.

With SQLite, the query planner used the `(repo_id, status)` index, so
the returned row order differed from the original page order. Since the
query has no explicit ordering, this behavior is database-dependent. I
have not tested MySQL or PostgreSQL.

This change orders `GetRunsByRepoAndID` by index descending, the same
order `FindRunOptions.ToOrders` uses for the initial page load. The
refresh only reloads the runs already on the page, so they come back in
the original order, with or without filters and on any page.

The other caller of `GetRunsByRepoAndID`, run approval, does not depend
on result ordering.

Testing:

- Added `TestPreparePartialRefreshRunsKeepsRequestedOrder`. Without the
fix, runs 794, 793, 792, 791 are returned as 791, 792, 794, 793; with
the fix, the test passes.
- `go test` passes for `./routers/web/repo/actions/`,
`./models/actions/` and `./services/actions/`.
- `go vet` and `golangci-lint v2.13.2` pass for the changed packages.
- Manually tested by building Gitea 28.0.0 with this patch and running
it on our SQLite instance. The runs list keeps its newest-first order
across auto refreshes. The official 28.0.0 binary reproduces the
reordering.

AI-assisted: drafted with Claude Code (claude-opus-5-5), reviewed by me.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 09:17:04 +02:00
bircni e0095af8c3 ci: Also release for other versions than 1 majors (#39475) 2026-09-29 21:47:12 +02:00
84 changed files with 708 additions and 862 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 - uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea gitea_fork: giteabot/gitea
-19
View File
@@ -1,19 +0,0 @@
name: giteabot-review
# Relays PR reviews to giteabot.yml through its workflow_run trigger, because review
# runs on fork PRs get no secrets and a read-only token. The job itself does nothing.
on:
pull_request_review:
types:
- submitted
- edited
- dismissed
permissions: {}
jobs:
relay:
runs-on: ubuntu-latest
steps:
- run: "true"
+12 -9
View File
@@ -20,12 +20,13 @@ on:
- closed - closed
- review_requested - review_requested
- review_request_removed - review_request_removed
# Reviews arrive through giteabot-review because fork PR review runs get no secrets # Review events keep review-derived state such as lgtm labels and status checks
workflow_run: # in sync after approvals, edits, or dismissals.
workflows: pull_request_review:
- giteabot-review
types: types:
- requested - submitted
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and # Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly. # other housekeeping, even though main pushes now trigger it promptly.
schedule: schedule:
@@ -42,12 +43,12 @@ on:
permissions: {} permissions: {}
concurrency: concurrency:
group: ${{ format('{0}-{1}', github.workflow, github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && format('review-{0}', github.event.workflow_run.head_sha) || 'maintenance') }} group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
giteabot: giteabot:
if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review') if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
@@ -56,7 +57,9 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 # pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+21 -1
View File
@@ -21,8 +21,19 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v3/acme"
) )
func acmeExternalAccountBinding() (acme.EAB, bool, error) {
if setting.AcmeEABKID == "" && setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, nil
}
if setting.AcmeEABKID == "" || setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, errors.New("both ACME_EAB_KID and ACME_EAB_HMAC must be set")
}
return acme.EAB{KeyID: setting.AcmeEABKID, MACKey: setting.AcmeEABHMAC}, true, nil
}
func getCARoot(path string) (*x509.CertPool, error) { func getCARoot(path string) (*x509.CertPool, error) {
r, err := os.ReadFile(path) r, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -66,6 +77,14 @@ func runACME(listenAddr string, m http.Handler) error {
log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err) log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err)
} }
} }
externalAccountBinding, hasExternalAccount, err := acmeExternalAccountBinding()
if err != nil {
return err
}
var externalAccount *acme.EAB
if hasExternalAccount {
externalAccount = &externalAccountBinding
}
// FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https" // FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https"
// Ideally it should migrate to AppDataPath write to "AppDataPath/https" // Ideally it should migrate to AppDataPath write to "AppDataPath/https"
// And one more thing, no idea why we should set the global default variables here // And one more thing, no idea why we should set the global default variables here
@@ -84,6 +103,7 @@ func runACME(listenAddr string, m http.Handler) error {
Email: setting.AcmeEmail, Email: setting.AcmeEmail,
Agreed: setting.AcmeTOS, Agreed: setting.AcmeTOS,
Profile: setting.AcmeProfile, Profile: setting.AcmeProfile,
ExternalAccount: externalAccount,
DisableHTTPChallenge: !enableHTTPChallenge, DisableHTTPChallenge: !enableHTTPChallenge,
DisableTLSALPNChallenge: !enableTLSALPNChallenge, DisableTLSALPNChallenge: !enableTLSALPNChallenge,
ListenHost: setting.HTTPAddr, ListenHost: setting.HTTPAddr,
@@ -100,7 +120,7 @@ func runACME(listenAddr string, m http.Handler) error {
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519). // takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously. // Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext() ctx := graceful.GetManager().ShutdownContext()
err := magic.ManageSync(ctx, []string{setting.AppDomain}) err = magic.ManageSync(ctx, []string{setting.AppDomain})
if err != nil { if err != nil {
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain) cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain)
if cacheErr != nil || cert.Expired() { if cacheErr != nil || cert.Expired() {
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/mholt/acmez/v3/acme"
"github.com/stretchr/testify/assert"
)
func TestAcmeExternalAccountBinding(t *testing.T) {
t.Cleanup(test.MockVariableValue(&setting.AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&setting.AcmeEABHMAC, ""))
binding, configured, err := acmeExternalAccountBinding()
assert.NoError(t, err)
assert.False(t, configured)
assert.Empty(t, binding)
setting.AcmeEABKID = "kid"
_, _, err = acmeExternalAccountBinding()
assert.ErrorContains(t, err, "both ACME_EAB_KID and ACME_EAB_HMAC must be set")
setting.AcmeEABHMAC = "hmac"
binding, configured, err = acmeExternalAccountBinding()
assert.NoError(t, err)
assert.True(t, configured)
assert.Equal(t, acme.EAB{KeyID: "kid", MACKey: "hmac"}, binding)
}
+21 -18
View File
@@ -155,7 +155,7 @@
;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER. ;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
;BUILTIN_SSH_SERVER_USER = ;BUILTIN_SSH_SERVER_USER =
;; ;;
;; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL ;; Domain name to be exposed in clone URL, defaults to the domain part of ROOT_URL
;SSH_DOMAIN = ;SSH_DOMAIN =
;; ;;
;; Port number to be exposed in clone URL. ;; Port number to be exposed in clone URL.
@@ -198,7 +198,7 @@
;; For the built-in SSH server, choose the keypair to offer as the host key ;; For the built-in SSH server, choose the keypair to offer as the host key
;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub ;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub
;; relative paths are made absolute relative to the APP_DATA_PATH ;; relative paths are made absolute relative to the APP_DATA_PATH
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa ;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gitea.ed25519, ssh/gitea.ecdsa, ssh/gogs.rsa
;; ;;
;; Enable SSH Authorized Key Backup when rewriting all keys, default is false ;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
;SSH_AUTHORIZED_KEYS_BACKUP = false ;SSH_AUTHORIZED_KEYS_BACKUP = false
@@ -237,7 +237,7 @@
;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s ;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
;; ;;
;; Indicate whether to check minimum key size with corresponding type ;; Indicate whether to check minimum key size with corresponding type
;MINIMUM_KEY_SIZE_CHECK = false ;MINIMUM_KEY_SIZE_CHECK = true
;; ;;
;; TLS Settings: Either ACME or manual ;; TLS Settings: Either ACME or manual
;; (Other common TLS configuration are found before) ;; (Other common TLS configuration are found before)
@@ -264,6 +264,11 @@
;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates) ;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates)
;ACME_PROFILE = ;ACME_PROFILE =
;; ;;
;; External account binding credentials; set both to enable EAB
;; ACME_EAB_HMAC should be a base64url-encoded MAC key
;ACME_EAB_KID =
;ACME_EAB_HMAC =
;;
;; ACME live directory (not to be confused with ACME directory URL: ACME_URL) ;; ACME live directory (not to be confused with ACME directory URL: ACME_URL)
;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic) ;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
;ACME_DIRECTORY = https ;ACME_DIRECTORY = https
@@ -836,7 +841,7 @@ LEVEL = Info
;EMAIL_DOMAIN_BLOCKLIST = ;EMAIL_DOMAIN_BLOCKLIST =
;; ;;
;; Disallow registration, only allow admins to create accounts. ;; Disallow registration, only allow admins to create accounts.
;DISABLE_REGISTRATION = false ;DISABLE_REGISTRATION = true
;; ;;
;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false ;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false
;ALLOW_ONLY_INTERNAL_REGISTRATION = false ;ALLOW_ONLY_INTERNAL_REGISTRATION = false
@@ -968,12 +973,11 @@ LEVEL = Info
;; Value for the domain part of the user's email address in the git log if user ;; Value for the domain part of the user's email address in the git log if user
;; has set KeepEmailPrivate to true. The user's email will be replaced with a ;; has set KeepEmailPrivate to true. The user's email will be replaced with a
;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default ;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default
;; value is "noreply." + DOMAIN, where DOMAIN resolves to the value from server.DOMAIN ;; value is "noreply." + the domain part of ROOT_URL
;; Note: do not use the <DOMAIN> notation below ;NO_REPLY_ADDRESS =
;NO_REPLY_ADDRESS = ; noreply.<DOMAIN>
;; ;;
;; Show Registration button ;; Show Registration button, defaults to true only if both DISABLE_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are false
;SHOW_REGISTRATION_BUTTON = true ;SHOW_REGISTRATION_BUTTON = false
;; ;;
;; Show milestones dashboard page - a view of all the user's milestones ;; Show milestones dashboard page - a view of all the user's milestones
;SHOW_MILESTONES_DASHBOARD_PAGE = true ;SHOW_MILESTONES_DASHBOARD_PAGE = true
@@ -1670,13 +1674,13 @@ LEVEL = Info
;; ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; General queue queue type, currently support: persistable-channel, channel, level, redis, dummy ;; General queue type, currently support: level, channel, redis, dummy
;; default to persistable-channel ;; default to level
;TYPE = persistable-channel ;TYPE = level
;; ;;
;; data-dir for storing persistable queues and level queues, individual queues will default to `queues/common` meaning the queue is shared. ;; data-dir for storing level queues, individual queues will default to `queues/common` meaning the queue is shared.
;; Relative paths will be made absolute against "APP_DATA_PATH" ;; Relative paths will be made absolute against "APP_DATA_PATH"
;DATADIR = queues/ ;DATADIR = queues/common
;; ;;
;; Default queue length before a channel queue will block ;; Default queue length before a channel queue will block
;LENGTH = 100000 ;LENGTH = 100000
@@ -1684,7 +1688,7 @@ LEVEL = Info
;; Batch size to send for batched queues ;; Batch size to send for batched queues
;BATCH_LENGTH = 20 ;BATCH_LENGTH = 20
;; ;;
;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb ;; When `TYPE` is `level`, this provides a directory for the underlying leveldb
;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`. ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR. ;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR.
;CONN_STR = ;CONN_STR =
@@ -1752,7 +1756,6 @@ LEVEL = Info
;ENABLE_OPENID_SIGNIN = false ;ENABLE_OPENID_SIGNIN = false
;; ;;
;; Whether to allow registering via OpenID ;; Whether to allow registering via OpenID
;; Do not include to rely on rhw DISABLE_REGISTRATION setting
;;ENABLE_OPENID_SIGNUP = false ;;ENABLE_OPENID_SIGNUP = false
;; ;;
;; Allowed URI patterns (POSIX regexp). ;; Allowed URI patterns (POSIX regexp).
@@ -2015,8 +2018,8 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres" ;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
;; Default is "memory". "db" will reuse the configuration in [database] ;; Default is "file". "db" will reuse the configuration in [database]
;PROVIDER = memory ;PROVIDER = file
;; ;;
;; Provider config options ;; Provider config options
;; memory: doesn't have any config yet ;; memory: doesn't have any config yet
-4
View File
@@ -62,10 +62,6 @@ Operations that must roll back together should run inside `db.WithTx()` (or
Functions that participate in a transaction take a `context.Context` as their first Functions that participate in a transaction take a `context.Context` as their first
parameter so the transaction can be propagated. parameter so the transaction can be propagated.
PostgreSQL, MySQL and MSSQL (via `READ_COMMITTED_SNAPSHOT`) read the last committed
row version, so reads never wait for writers. Guard read-then-write logic with a
conditional `UPDATE` or a lock.
### XORM gotchas ### XORM gotchas
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates - Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
+2 -2
View File
@@ -68,6 +68,7 @@ require (
github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/meilisearch/meilisearch-go v0.36.3 github.com/meilisearch/meilisearch-go v0.36.3
github.com/mholt/acmez/v3 v3.1.6
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/microsoft/go-mssqldb v1.11.2 github.com/microsoft/go-mssqldb v1.11.2
@@ -109,7 +110,7 @@ require (
modernc.org/sqlite v1.59.0 modernc.org/sqlite v1.59.0
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.4.3 xorm.io/xorm v1.4.1
) )
require ( require (
@@ -199,7 +200,6 @@ require (
github.com/markbates/going v1.0.3 // indirect github.com/markbates/going v1.0.3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
+2 -2
View File
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM= xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
+4 -7
View File
@@ -298,12 +298,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil { if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err return nil, false, err
} }
// A short page means no waiting jobs remain beyond it.
isLastPage := len(jobs) < pickTaskBatchSize
if !isLastPage {
last := jobs[len(jobs)-1] // read before a lost claim bumps Updated
cursorUpdated, cursorID = last.Updated, last.ID
}
for _, v := range jobs { for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) { if !runner.CanMatchLabels(v.RunsOn) {
@@ -319,9 +313,12 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
// Another runner claimed this job concurrently; try the next one. // Another runner claimed this job concurrently; try the next one.
} }
if isLastPage { // A short page means no waiting jobs remain beyond it.
if len(jobs) < pickTaskBatchSize {
return nil, false, nil return nil, false, nil
} }
last := jobs[len(jobs)-1]
cursorUpdated, cursorID = last.Updated, last.ID
} }
} }
-6
View File
@@ -564,12 +564,6 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
return nil return nil
} }
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
grant.Scope = newScope
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
return err
}
// GetOAuth2GrantByID returns the grant with the given ID // GetOAuth2GrantByID returns the grant with the given ID
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) { func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
grant = new(OAuth2Grant) grant = new(OAuth2Grant)
-20
View File
@@ -5,9 +5,7 @@ package db
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"time"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -61,11 +59,6 @@ func InitEngine(ctx context.Context) error {
xe.SetMaxIdleConns(setting.Database.MaxIdleConns) xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime) xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
if setting.Database.Type.IsMySQL() {
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
if setting.Database.SlowQueryThreshold > 0 { if setting.Database.SlowQueryThreshold > 0 {
xe.AddHook(&EngineHook{ xe.AddHook(&EngineHook{
Threshold: setting.Database.SlowQueryThreshold, Threshold: setting.Database.SlowQueryThreshold,
@@ -110,10 +103,6 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
preprocessDatabaseCollation(xormEngine) preprocessDatabaseCollation(xormEngine)
if setting.Database.Type.IsMSSQL() {
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
}
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB. // We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly. // If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
// //
@@ -136,12 +125,3 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
return nil return nil
} }
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
defer cancel()
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
}
}
+4 -4
View File
@@ -311,17 +311,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
func (opts *CommitStatusOptions) ToOrders() string { func (opts *CommitStatusOptions) ToOrders() string {
switch opts.SortType { switch opts.SortType {
case "oldest": case "oldest":
return "created_unix ASC, `index` ASC" return "created_unix ASC"
case "recentupdate": case "recentupdate":
return "updated_unix DESC, `index` DESC" return "updated_unix DESC"
case "leastupdate": case "leastupdate":
return "updated_unix ASC, `index` ASC" return "updated_unix ASC"
case "leastindex": case "leastindex":
return "`index` DESC" return "`index` DESC"
case "highestindex": case "highestindex":
return "`index` ASC" return "`index` ASC"
default: default:
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable return "created_unix DESC"
} }
} }
+20 -7
View File
@@ -32,15 +32,28 @@ func TestGetCommitStatuses(t *testing.T) {
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults)) assert.Equal(t, 5, int(maxResults))
var indexes []int64 assert.Len(t, statuses, 5)
for _, status := range statuses {
indexes = append(indexes, status.Index) assert.Equal(t, "ci/awesomeness", statuses[0].Context)
} assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context())) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{ statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
ListOptions: db.ListOptions{Page: 2, PageSize: 50}, ListOptions: db.ListOptions{Page: 2, PageSize: 50},
RepoID: repo1.ID, RepoID: repo1.ID,
+16 -6
View File
@@ -123,13 +123,23 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
} }
// CanUserPush returns if some user could push to this protected branch // CanUserPush returns if some user could push to this protected branch
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool { func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
if !protectBranch.CanPush { if !protectBranch.CanPush {
return false return false
} }
if !protectBranch.EnableWhitelist { if !protectBranch.EnableWhitelist {
return permissionInRepo.CanWrite(unit.TypeCode) if err := protectBranch.LoadRepo(ctx); err != nil {
log.Error("LoadRepo: %v", err)
return false
}
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
if err != nil {
log.Error("HasAccessUnit: %v", err)
return false
}
return writeAccess
} }
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) { if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
@@ -150,17 +160,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
// CanUserForcePush returns if some user could force push to this protected branch // CanUserForcePush returns if some user could force push to this protected branch
// Since force-push extends normal push, we also check if user has regular push access // Since force-push extends normal push, we also check if user has regular push access
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool { func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
if !protectBranch.CanForcePush { if !protectBranch.CanForcePush {
return false return false
} }
if !protectBranch.EnableForcePushAllowlist { if !protectBranch.EnableForcePushAllowlist {
return protectBranch.CanUserPush(ctx, user, permissionInRepo) return protectBranch.CanUserPush(ctx, user)
} }
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) { if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
return protectBranch.CanUserPush(ctx, user, permissionInRepo) return protectBranch.CanUserPush(ctx, user)
} }
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 { if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
@@ -172,7 +182,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
log.Error("IsUserInTeams: %v", err) log.Error("IsUserInTeams: %v", err)
return false return false
} }
return in && protectBranch.CanUserPush(ctx, user, permissionInRepo) return in && protectBranch.CanUserPush(ctx, user)
} }
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch // IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
-15
View File
@@ -407,21 +407,6 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
return git.RefNameFromPullIndex(pr.Index).String() return git.RefNameFromPullIndex(pr.Index).String()
} }
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
BaseBranchArg string
HeadBranchArg string
LocalBranchArg string
},
) {
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
ret.LocalBranchArg = ret.HeadBranchArg
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
}
return ret
}
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) // GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int { func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
opts := FindCommentsOptions{ opts := FindCommentsOptions{
+4 -4
View File
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
Keyword string Keyword string
Types []UserType Types []UserType
UID int64 UID int64
LoginName string // this option should be used only for admin user LoginName string // this option should be used only for admin user
SourceID int64 // this option should be used only for admin user SourceID optional.Option[int64] // this option should be used only for admin user, Some(0) means local users
OrderBy db.SearchOrderBy OrderBy db.SearchOrderBy
Visible []structs.VisibleType Visible []structs.VisibleType
Actor *User // The user doing the search Actor *User // The user doing the search
@@ -106,8 +106,8 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session
cond = cond.And(builder.Eq{"id": opts.UID}) cond = cond.And(builder.Eq{"id": opts.UID})
} }
if opts.SourceID > 0 { if opts.SourceID.Has() {
cond = cond.And(builder.Eq{"login_source": opts.SourceID}) cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
} }
if opts.LoginName != "" { if opts.LoginName != "" {
cond = cond.And(builder.Eq{"login_name": opts.LoginName}) cond = cond.And(builder.Eq{"login_name": opts.LoginName})
+2 -8
View File
@@ -13,7 +13,6 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting"
) )
// BatchChecker provides a reader for check-attribute content that can be long running // BatchChecker provides a reader for check-attribute content that can be long running
@@ -121,17 +120,12 @@ func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
return fmt.Errorf("CheckPath timeout: %s", debugMsg) return fmt.Errorf("CheckPath timeout: %s", debugMsg)
} }
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
rs = NewAttributes() rs = NewAttributes()
for i := 0; i < c.attributesNum; i++ { for i := 0; i < c.attributesNum; i++ {
select { select {
case <-timeout.C: case <-time.After(5 * time.Second):
// there is no "hang" problem now. This code is just used to catch other potential problems. // there is no "hang" problem now. This code is just used to catch other potential problems.
err = reportTimeout() return nil, reportTimeout()
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
return nil, err
case attr, ok := <-c.stdOut.ReadAttribute(): case attr, ok := <-c.stdOut.ReadAttribute():
if !ok { if !ok {
return nil, c.ctx.Err() return nil, c.ctx.Err()
+5 -10
View File
@@ -49,8 +49,8 @@ type Command struct {
cmd *process.Cmd cmd *process.Cmd
cmdCtx context.Context cmdCtx context.Context
cmdCtxCancel process.CancelCauseFunc cmdCancel process.CancelCauseFunc
cmdFinished func() cmdFinished process.FinishedFunc
cmdStartTime time.Time cmdStartTime time.Time
pipelineFunc func(Context) error pipelineFunc func(Context) error
@@ -428,24 +428,19 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
if c.callerInfo == "" { if c.callerInfo == "" {
c.WithParentCallerInfo() c.WithParentCallerInfo()
} }
// these logs are for debugging purposes only, so no guarantee of correctness or stability // these logs are for debugging purposes only, so no guarantee of correctness or stability
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString) desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
log.Debug("git.Command: %s", desc) log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun) _, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
defer span.End()
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo) span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString) span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cmdCtxFinished func()
if c.cmdTimeout <= 0 { if c.cmdTimeout <= 0 {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc) c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
} else { } else {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc) c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
}
c.cmdFinished = func() {
cmdCtxFinished()
span.End()
} }
c.cmdStartTime = time.Now() c.cmdStartTime = time.Now()
+1 -1
View File
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
// * context canceled by pipeline caller with/without error (normal cancellation) // * context canceled by pipeline caller with/without error (normal cancellation)
// * context canceled by parent context (still context.Canceled error) // * context canceled by parent context (still context.Canceled error)
// * other causes // * other causes
c.cmd.cmdCtxCancel(pipelineError{err}) c.cmd.cmdCancel(pipelineError{err})
return err return err
} }
+13 -91
View File
@@ -8,13 +8,9 @@ package git
import ( import (
"errors" "errors"
"io"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/git/gitrepo" "gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -24,7 +20,6 @@ import (
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache" "github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/storage/filesystem" "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
) )
const isGogit = true const isGogit = true
@@ -36,98 +31,25 @@ type Repository struct {
gogitStorage *reindexingStorage gogitStorage *reindexingStorage
} }
// reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it // reindexingStorage picks up packs that git wrote after go-git loaded its index
// https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623 // https://github.com/go-git/go-git/issues/2439
// FIXME: gogit workaround, remove with the gogit build
type reindexingStorage struct { type reindexingStorage struct {
*filesystem.Storage *filesystem.Storage
packs []plumbing.Hash packs []plumbing.Hash
} }
func isRepackError(err error) bool { func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist) obj, err := s.Storage.EncodedObject(t, h)
} if !errors.Is(err, plumbing.ErrObjectNotFound) {
return obj, err
// retry reruns fn while a concurrent repack keeps changing the packs
func (s *reindexingStorage) retry(fn func() error) error {
for {
err := fn()
if !isRepackError(err) {
return err
}
packs, _ := s.ObjectPacks()
if slices.Equal(packs, s.packs) {
return err
}
s.packs = packs
s.Reindex()
} }
} packs, _ := s.ObjectPacks()
if slices.Equal(packs, s.packs) {
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (obj plumbing.EncodedObject, err error) { return obj, err
err = s.retry(func() (err error) {
obj, err = s.Storage.EncodedObject(t, h)
return err
})
if err != nil {
return nil, err
} }
if _, ok := obj.(*plumbing.MemoryObject); ok { s.packs = packs
return obj, nil s.Reindex()
} return s.Storage.EncodedObject(t, h)
return &lazyObject{EncodedObject: obj, storage: s}, nil
}
// lazyObject looks up a large object again when its file got removed before Reader reopened it
// FIXME: gogit workaround, remove with the gogit build
type lazyObject struct {
plumbing.EncodedObject
storage *reindexingStorage
}
func (o *lazyObject) Reader() (rc io.ReadCloser, err error) {
rc, err = o.EncodedObject.Reader()
if !isRepackError(err) {
return rc, err
}
err = o.storage.retry(func() error {
obj, err := o.storage.Storage.EncodedObject(o.Type(), o.Hash())
if err == nil {
o.EncodedObject = obj
rc, err = obj.Reader()
}
return err
})
return rc, err
}
// packIdxFS lists packs like git, only while their .idx exists
// FIXME: gogit workaround, remove with the gogit build
type packIdxFS struct {
billy.Filesystem
}
func (f packIdxFS) ReadDir(dir string) ([]os.FileInfo, error) {
if dir != f.Join("objects", "pack") {
return f.Filesystem.ReadDir(dir)
}
dirFile, err := os.Open(filepath.Join(f.Root(), dir))
if err != nil {
return nil, err
}
defer dirFile.Close()
infos, err := dirFile.Readdir(-1) // skips files removed before their lstat, unlike billy's ReadDir
if err != nil {
return nil, err
}
names := make(container.Set[string], len(infos))
for _, info := range infos {
names.Add(info.Name())
}
return slices.DeleteFunc(infos, func(info os.FileInfo) bool {
base, isPack := strings.CutSuffix(info.Name(), ".pack")
return isPack && !names.Contains(base+".idx")
}), nil
} }
func openRepositoryInternal(gitRepo *Repository) error { func openRepositoryInternal(gitRepo *Repository) error {
@@ -149,7 +71,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
altFs = osfs.New("/") altFs = osfs.New("/")
} }
gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type() gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type()
storage := filesystem.NewStorageWithOptions(packIdxFS{fs}, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs}) storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
packs, _ := storage.ObjectPacks() packs, _ := storage.ObjectPacks()
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs} gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs) gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
-41
View File
@@ -4,15 +4,9 @@
package git package git
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -45,41 +39,6 @@ func TestRepository_GetBranches(t *testing.T) {
assert.ElementsMatch(t, []string{}, branches) assert.ElementsMatch(t, []string{}, branches)
} }
// FIXME: covers the gogit workarounds in repo_base_gogit.go, remove with the gogit build
func TestReadsAfterConcurrentRepack(t *testing.T) {
repoDir := t.TempDir()
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
content := strings.Repeat("a", int(setting.Git.LargeObjectThreshold)+1)
for _, from := range []string{"", "from refs/heads/main^0\n"} {
stdin := fmt.Sprintf("commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n%sM 100644 inline f\ndata %d\n%s\n", from, len(content), content)
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
require.NoError(t, gitcmd.NewCommand("repack", "-d").WithDir(repoDir).Run(t.Context()))
}
repo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer repo.Close()
require.False(t, repo.IsObjectExist(t.Context(), "0000000000000000000000000000000000000001"))
blobRepo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer blobRepo.Close()
commit, err := blobRepo.GetBranchCommit(t.Context(), "main")
require.NoError(t, err)
readBlob := func() string {
data, err := commit.GetFileContent(t.Context(), blobRepo, "f", len(content))
require.NoError(t, err)
return data
}
require.Equal(t, content, readBlob())
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "objects", "pack", "pack-"+strings.Repeat("1", 40)+".pack"), nil, 0o644))
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
require.NoError(t, err)
assert.Equal(t, []string{"main"}, branches)
assert.Equal(t, content, readBlob())
}
func BenchmarkRepository_GetBranches(b *testing.B) { func BenchmarkRepository_GetBranches(b *testing.B) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path) bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
-2
View File
@@ -122,8 +122,6 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
ts.parent = parentSpan ts.parent = parentSpan
} }
// FIXME: this ctx handling is not right. The returned ctx should inherit the ctx passed in, but not from span's internal contexts
// The returned ctx only needs to inherit the values of the internal contexts of spans
parentCtx := ctx parentCtx := ctx
for internalSpanIdx, tsp := range starters { for internalSpanIdx, tsp := range starters {
var internalSpan traceSpanInternal var internalSpan traceSpanInternal
+4 -7
View File
@@ -6,17 +6,14 @@ package gtprof
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv // Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
const ( const (
TraceSpanContext = "context"
TraceSpanHTTP = "http" TraceSpanHTTP = "http"
TraceSpanGitRun = "git-run" TraceSpanGitRun = "git-run"
TraceSpanDatabase = "database" TraceSpanDatabase = "database"
) )
const ( const (
TraceAttrGeneralName = "general.name" TraceAttrFuncCaller = "func.caller"
TraceAttrGeneralDesc = "general.desc" TraceAttrDbSQL = "db.sql"
TraceAttrFuncCaller = "func.caller" TraceAttrGitCommand = "git.command"
TraceAttrDbSQL = "db.sql" TraceAttrHTTPRoute = "http.route"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
) )
-4
View File
@@ -45,10 +45,6 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions) return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
} }
func MarshalDeterministic(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
}
func (j *JSONv2) Marshal(v any) ([]byte, error) { func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions) return jsonv2.Marshal(v, j.marshalOptions)
} }
+51 -23
View File
@@ -121,7 +121,6 @@ type PackageMetadataVersion struct {
Engines map[string]string `json:"engines,omitempty"` Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"` CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"` OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"` Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"` Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"` AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
@@ -130,9 +129,12 @@ type PackageMetadataVersion struct {
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version // PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
type PackageDistribution struct { type PackageDistribution struct {
Integrity string `json:"integrity"` Integrity string `json:"integrity"`
Shasum string `json:"shasum"` Shasum string `json:"shasum"`
Tarball string `json:"tarball"` Tarball string `json:"tarball"`
FileCount int `json:"fileCount,omitempty"`
UnpackedSize int `json:"unpackedSize,omitempty"`
NpmSignature string `json:"npm-signature,omitempty"`
} }
type PackageSearch struct { type PackageSearch struct {
@@ -224,7 +226,7 @@ func (r *Repository) UnmarshalJSON(data []byte) error {
} }
// Bin maps command names to executable files. npm also allows a single string, // Bin maps command names to executable files. npm also allows a single string,
// in which case the command is named after the package (resolved in parseUploadPackage). // in which case the command is named after the package (resolved in ParsePackage).
type Bin map[string]string type Bin map[string]string
// UnmarshalJSON is needed because the bin field can be a string or an object. // UnmarshalJSON is needed because the bin field can be a string or an object.
@@ -262,7 +264,7 @@ type packageUpload struct {
// is non-nil on success; a body without `_attachments` is a deprecate request, // is non-nil on success; a body without `_attachments` is a deprecate request,
// otherwise it is a "publish". // otherwise it is a "publish".
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) { func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
body, err := io.ReadAll(r) body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -278,6 +280,16 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
return p, nil, err return p, nil, err
} }
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
// surface as ErrInvalidAttachment once name/version validation has passed.
func ParsePackage(r io.Reader) (*Package, error) {
var upload packageUpload
if err := json.NewDecoder(r).Decode(&upload); err != nil {
return nil, err
}
return parseUploadPackage(&upload)
}
// parseUploadPackage builds a Package from a decoded publish body. // parseUploadPackage builds a Package from a decoded publish body.
func parseUploadPackage(upload *packageUpload) (*Package, error) { func parseUploadPackage(upload *packageUpload) (*Package, error) {
for _, meta := range upload.Versions { for _, meta := range upload.Versions {
@@ -331,7 +343,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
Engines: meta.Engines, Engines: meta.Engines,
CPU: meta.CPU, CPU: meta.CPU,
OS: meta.OS, OS: meta.OS,
Libc: meta.Libc,
Directories: meta.Directories, Directories: meta.Directories,
Funding: meta.Funding, Funding: meta.Funding,
AcceptDependencies: meta.AcceptDependencies, AcceptDependencies: meta.AcceptDependencies,
@@ -345,12 +356,12 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version)) p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance` attachment := func() *PackageAttachment {
if attachment == nil && len(upload.Attachments) == 1 {
for _, a := range upload.Attachments { for _, a := range upload.Attachments {
attachment = a return a
} }
} return nil
}()
if attachment == nil || len(attachment.Data) == 0 { if attachment == nil || len(attachment.Data) == 0 {
return nil, ErrInvalidAttachment return nil, ErrInvalidAttachment
} }
@@ -382,6 +393,8 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
return nil, ErrInvalidIntegrity return nil, ErrInvalidIntegrity
} }
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
// packument can lie about either.
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data) p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
return p, nil return p, nil
@@ -397,7 +410,11 @@ const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball. // maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
// inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing // inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
// and hasInstallScript (package/package.json declares any of preinstall,
// install, postinstall). Both must be derived server-side because the client
// can lie in the packument. Any read/decode error yields (false, false) so a
// malformed archive does not block publishing.
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) { func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
gr, err := gzip.NewReader(bytes.NewReader(data)) gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil { if err != nil {
@@ -405,16 +422,11 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
} }
defer gr.Close() defer gr.Close()
var hasGypFile bool
var pkg struct {
Scripts map[string]string `json:"scripts"`
Gypfile any `json:"gypfile"`
}
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes)) tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
for { for {
hdr, err := tr.Next() hdr, err := tr.Next()
if err != nil { if err != nil {
break return hasShrinkwrap, hasInstallScript
} }
// npm pack puts files under a single root directory (usually "package/"). // npm pack puts files under a single root directory (usually "package/").
name := strings.TrimPrefix(hdr.Name, "./") name := strings.TrimPrefix(hdr.Name, "./")
@@ -424,14 +436,30 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
switch { switch {
case strings.HasSuffix(name, "/npm-shrinkwrap.json"): case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
hasShrinkwrap = true hasShrinkwrap = true
case strings.HasSuffix(name, ".gyp"):
hasGypFile = true
case strings.HasSuffix(name, "/package.json"): case strings.HasSuffix(name, "/package.json"):
_ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg) hasInstallScript = tarballDeclaresInstallScript(tr)
}
if hasShrinkwrap && hasInstallScript {
return hasShrinkwrap, hasInstallScript
} }
} }
// npm publish adds "install": "node-gyp rebuild" for a root *.gyp file to the manifest, but not to the tarball }
return hasShrinkwrap, strings.TrimSpace(pkg.Scripts["preinstall"]+pkg.Scripts["install"]+pkg.Scripts["postinstall"]) != "" || hasGypFile && pkg.Gypfile != false
// tarballDeclaresInstallScript reports whether a package.json declares any
// of preinstall, install, postinstall.
func tarballDeclaresInstallScript(r io.Reader) bool {
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
return false
}
for _, name := range []string{"preinstall", "install", "postinstall"} {
if strings.TrimSpace(pkg.Scripts[name]) != "" {
return true
}
}
return false
} }
func validateName(name string) bool { func validateName(name string) bool {
+54 -24
View File
@@ -41,20 +41,21 @@ func TestParsePackage(t *testing.T) {
integrity := "sha512-" + base64Sha512(dataBytes) integrity := "sha512-" + base64Sha512(dataBytes)
t.Run("InvalidUpload", func(t *testing.T) { t.Run("InvalidUpload", func(t *testing.T) {
p, _, err := ParseUpload(bytes.NewReader([]byte{0})) p, err := ParsePackage(bytes.NewReader([]byte{0}))
assert.Nil(t, p) assert.Nil(t, p)
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("InvalidUploadNoData", func(t *testing.T) { t.Run("InvalidUploadNoData", func(t *testing.T) {
p, err := parseUploadPackage(&packageUpload{}) b, _ := json.Marshal(packageUpload{})
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackage) assert.ErrorIs(t, err, ErrInvalidPackage)
}) })
t.Run("InvalidPackageName", func(t *testing.T) { t.Run("InvalidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) { test := func(t *testing.T, name string) {
p, err := parseUploadPackage(&packageUpload{ b, _ := json.Marshal(packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -65,6 +66,8 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageName) assert.ErrorIs(t, err, ErrInvalidPackageName)
} }
@@ -91,7 +94,7 @@ func TestParsePackage(t *testing.T) {
t.Run("ValidPackageName", func(t *testing.T) { t.Run("ValidPackageName", func(t *testing.T) {
test := func(t *testing.T, name string) { test := func(t *testing.T, name string) {
p, err := parseUploadPackage(&packageUpload{ b, _ := json.Marshal(packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -102,6 +105,8 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageVersion) assert.ErrorIs(t, err, ErrInvalidPackageVersion)
} }
@@ -120,7 +125,7 @@ func TestParsePackage(t *testing.T) {
t.Run("InvalidPackageVersion", func(t *testing.T) { t.Run("InvalidPackageVersion", func(t *testing.T) {
version := "first-version" version := "first-version"
p, err := parseUploadPackage(&packageUpload{ b, _ := json.Marshal(packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: packageFullName, ID: packageFullName,
Name: packageFullName, Name: packageFullName,
@@ -132,6 +137,8 @@ func TestParsePackage(t *testing.T) {
}, },
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidPackageVersion) assert.ErrorIs(t, err, ErrInvalidPackageVersion)
}) })
@@ -153,7 +160,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, _, err := ParseUpload(bytes.NewReader(b)) p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -178,7 +185,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, _, err := ParseUpload(bytes.NewReader(b)) p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -206,7 +213,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, _, err := ParseUpload(bytes.NewReader(b)) p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -234,7 +241,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, _, err := ParseUpload(bytes.NewReader(b)) p, err := ParsePackage(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -274,13 +281,10 @@ func TestParsePackage(t *testing.T) {
filename: { filename: {
Data: data, Data: data,
}, },
packageFullName + "-" + packageVersion + ".sigstore": {
Data: "{}",
},
}, },
}) })
p, _, err := ParseUpload(bytes.NewReader(b)) p, err := ParsePackage(bytes.NewReader(b))
assert.NotNil(t, p) assert.NotNil(t, p)
assert.NoError(t, err) assert.NoError(t, err)
@@ -325,7 +329,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, _, err := ParseUpload(strings.NewReader(packageJSON)) p, err := ParsePackage(strings.NewReader(packageJSON))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "MIT", string(p.Metadata.License)) require.Equal(t, "MIT", string(p.Metadata.License))
}) })
@@ -350,7 +354,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, _, err := ParseUpload(strings.NewReader(packageJSON)) p, err := ParsePackage(strings.NewReader(packageJSON))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL) require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
// a string bin is named after the package // a string bin is named after the package
@@ -422,15 +426,6 @@ func TestInspectTarball(t *testing.T) {
// npm pack sometimes emits "./package/..." entries. // npm pack sometimes emits "./package/..." entries.
wantShrinkwrap: true, wantShrinkwrap: true,
}, },
{
name: "gyp file implies node-gyp install",
files: map[string]string{"package/binding.gyp": "{}"},
wantInstaller: true,
},
{
name: "gypfile false disables gyp install",
files: map[string]string{"package/binding.gyp": "{}", "package/package.json": `{"gypfile":false}`},
},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -465,6 +460,41 @@ func TestParseUpload(t *testing.T) {
require.NotNil(t, dep) require.NotNil(t, dep)
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions) assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
}) })
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
// Reuse a minimal tarball with a package.json.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
assert.Equal(t, pkg, p.Name)
})
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
// The old fast-path used a substring check for "deprecated"; make sure
// the new dispatch keys off _attachments only.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
})
t.Run("invalid json errors out", func(t *testing.T) {
_, _, err := ParseUpload(strings.NewReader("not json"))
assert.Error(t, err)
})
} }
func base64Sha512(data []byte) string { func base64Sha512(data []byte) string {
-1
View File
@@ -29,7 +29,6 @@ type Metadata struct {
Engines map[string]string `json:"engines,omitempty"` Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"` CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"` OS []string `json:"os,omitempty"`
Libc []string `json:"libc,omitempty"`
Directories map[string]string `json:"directories,omitempty"` Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"` Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"` AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
+9
View File
@@ -102,6 +102,8 @@ var (
AcmeEmail string AcmeEmail string
AcmeURL string AcmeURL string
AcmeProfile string AcmeProfile string
AcmeEABKID string
AcmeEABHMAC string
AcmeCARoot string AcmeCARoot string
SSLMinimumVersion string SSLMinimumVersion string
SSLMaximumVersion string SSLMaximumVersion string
@@ -144,6 +146,11 @@ func loadServerDomainAndURL(sec ConfigSection, protocol string) {
AppDomain = appURL.Hostname() AppDomain = appURL.Hostname()
} }
func loadAcmeEABFrom(sec ConfigSection) {
AcmeEABKID = sec.Key("ACME_EAB_KID").MustString("")
AcmeEABHMAC = sec.Key("ACME_EAB_HMAC").MustString("")
}
func loadServerFrom(rootCfg ConfigProvider) { func loadServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("server") sec := rootCfg.Section("server")
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
@@ -173,6 +180,7 @@ func loadServerFrom(rootCfg ConfigProvider) {
if EnableAcme { if EnableAcme {
AcmeURL = sec.Key("ACME_URL").MustString("") AcmeURL = sec.Key("ACME_URL").MustString("")
AcmeProfile = sec.Key("ACME_PROFILE").MustString("") AcmeProfile = sec.Key("ACME_PROFILE").MustString("")
loadAcmeEABFrom(sec)
AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("") AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("")
if sec.HasKey("ACME_ACCEPTTOS") { if sec.HasKey("ACME_ACCEPTTOS") {
@@ -208,6 +216,7 @@ func loadServerFrom(rootCfg ConfigProvider) {
KeyFile = filepath.Join(CustomPath, KeyFile) KeyFile = filepath.Join(CustomPath, KeyFile)
} }
} }
SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("") SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("")
SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("") SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("")
SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",") SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",")
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadAcmeEABFrom(t *testing.T) {
t.Cleanup(test.MockVariableValue(&AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&AcmeEABHMAC, ""))
cfg, err := NewConfigProviderFromData(`
[server]
ACME_EAB_KID = kid
ACME_EAB_HMAC = hmac
`)
assert.NoError(t, err)
loadAcmeEABFrom(cfg.Section("server"))
assert.Equal(t, "kid", AcmeEABKID)
assert.Equal(t, "hmac", AcmeEABHMAC)
}
+1 -2
View File
@@ -36,7 +36,6 @@ import "strings"
const ( const (
tildePrefix = '~' tildePrefix = '~'
commentPrefix = '#'
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\" needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
needsSingleQuote = "!\n" needsSingleQuote = "!\n"
) )
@@ -75,7 +74,7 @@ func ShellEscape(toEscape string) string {
} }
// Now for simplicity we'll look at the rest of the string // Now for simplicity we'll look at the rest of the string
if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix { if !strings.ContainsAny(toEscape[start:], needsEscape) {
return toEscape return toEscape
} }
-4
View File
@@ -75,10 +75,6 @@ func TestShellEscape(t *testing.T) {
"Double quote and escape `...", "Double quote and escape `...",
"~/gitea`", "~/gitea`",
"~/\"gitea\\`\"", "~/\"gitea\\`\"",
}, {
"Double quote leading #",
"#123",
`"#123"`,
}, { }, {
"Double quotes can handle a number of things without having to escape them but not everything ...", "Double quotes can handle a number of things without having to escape them but not everything ...",
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'", "~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
-1
View File
@@ -433,7 +433,6 @@
"auth.authorize_application_created_by": "This application was created by %s.", "auth.authorize_application_created_by": "This application was created by %s.",
"auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.", "auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.",
"auth.authorize_application_with_scopes": "With scopes: %s", "auth.authorize_application_with_scopes": "With scopes: %s",
"auth.authorize_application_new_scopes": "New scopes: %s",
"auth.authorize_title": "Authorize \"%s\" to access your account?", "auth.authorize_title": "Authorize \"%s\" to access your account?",
"auth.authorization_failed": "Authorization failed", "auth.authorization_failed": "Authorization failed",
"auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.", "auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.",
+1 -4
View File
@@ -406,15 +406,12 @@ func CommonRoutes() *web.Router {
}) })
r.Group("/npm", func() { r.Group("/npm", func() {
r.Get("/-/v1/search", npm.PackageSearch) r.Get("/-/v1/search", npm.PackageSearch)
r.Get("/-/ping", npm.Ping)
r.Get("/-/whoami", npm.Whoami)
r.PathGroup("/*", func(g *web.RouterPathGroup) { r.PathGroup("/*", func(g *web.RouterPathGroup) {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details // HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">" packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
g.UseUnescapedPath() g.UseUnescapedPath()
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFile)
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName) g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage) g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview) g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
+4 -17
View File
@@ -8,7 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"slices" "net/url"
"sort" "sort"
"time" "time"
@@ -25,7 +25,6 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
distTags := make(map[string]string) distTags := make(map[string]string)
times := make(map[string]time.Time) times := make(map[string]time.Time)
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
var latest *packages_model.PackageDescriptor
for _, pd := range pds { for _, pd := range pds {
semVer := pd.SemVer.String() semVer := pd.SemVer.String()
versions[semVer] = createPackageMetadataVersion(registryURL, pd) versions[semVer] = createPackageMetadataVersion(registryURL, pd)
@@ -36,9 +35,6 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
for _, pvp := range pd.VersionProperties { for _, pvp := range pd.VersionProperties {
if pvp.Name == npm_module.TagProperty { if pvp.Name == npm_module.TagProperty {
distTags[pvp.Value] = pd.Version.Version distTags[pvp.Value] = pd.Version.Version
if pvp.Value == "latest" {
latest = pd
}
} }
} }
} }
@@ -47,16 +43,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
times["created"] = firstPublished.AsTimeInLocation(time.UTC) times["created"] = firstPublished.AsTimeInLocation(time.UTC)
times["modified"] = lastPublished.AsTimeInLocation(time.UTC) times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted latest := pds[len(pds)-1]
latest = pds[len(pds)-1]
for _, pd := range slices.Backward(pds) {
if pd.SemVer.Prerelease() == "" {
latest = pd
break
}
}
distTags["latest"] = latest.Version.Version
}
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest) metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest)
@@ -99,13 +86,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
PeerDependencies: metadata.PeerDependencies, PeerDependencies: metadata.PeerDependencies,
PeerDependenciesMeta: metadata.PeerDependenciesMeta, PeerDependenciesMeta: metadata.PeerDependenciesMeta,
OptionalDependencies: metadata.OptionalDependencies, OptionalDependencies: metadata.OptionalDependencies,
Readme: metadata.Readme,
Bin: metadata.Bin, Bin: metadata.Bin,
HasInstallScript: metadata.HasInstallScript, HasInstallScript: metadata.HasInstallScript,
HasShrinkwrap: metadata.HasShrinkwrap, HasShrinkwrap: metadata.HasShrinkwrap,
Engines: metadata.Engines, Engines: metadata.Engines,
CPU: metadata.CPU, CPU: metadata.CPU,
OS: metadata.OS, OS: metadata.OS,
Libc: metadata.Libc,
Directories: metadata.Directories, Directories: metadata.Directories,
Funding: metadata.Funding, Funding: metadata.Funding,
AcceptDependencies: metadata.AcceptDependencies, AcceptDependencies: metadata.AcceptDependencies,
@@ -113,7 +100,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Dist: npm_module.PackageDistribution{ Dist: npm_module.PackageDistribution{
Shasum: pd.Files[0].Blob.HashSHA1, Shasum: pd.Files[0].Blob.HashSHA1,
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes), Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
}, },
} }
} }
+6 -11
View File
@@ -25,7 +25,7 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
Owner: &user_model.User{Name: "alice"}, Owner: &user_model.User{Name: "alice"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)}, Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
SemVer: version.Must(version.NewVersion(v)), SemVer: version.Must(version.NewVersion(v)),
Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo}, Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}, Repository: repo},
Files: []*packages_model.PackageFileDescriptor{{ Files: []*packages_model.PackageFileDescriptor{{
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"}, File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
Blob: &packages_model.PackageBlob{}, Blob: &packages_model.PackageBlob{},
@@ -35,26 +35,21 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{ result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
descriptor("1.1.0", 1000, npm_module.Repository{}), descriptor("1.1.0", 1000, npm_module.Repository{}),
descriptor("2.0.0-rc.1", 1500, repository),
descriptor("1.0.0", 2000, repository), descriptor("1.0.0", 2000, repository),
}) })
assert.Equal(t, map[string]time.Time{ assert.Equal(t, map[string]time.Time{
"1.0.0": time.Unix(2000, 0).UTC(), "1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(), "1.1.0": time.Unix(1000, 0).UTC(),
"2.0.0-rc.1": time.Unix(1500, 0).UTC(), "created": time.Unix(1000, 0).UTC(),
"created": time.Unix(1000, 0).UTC(), "modified": time.Unix(2000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
}, result.Time) }, result.Time)
assert.Equal(t, map[string]string{"latest": "1.1.0"}, result.DistTags)
assert.Equal(t, "1.1.0", result.Readme)
assert.Empty(t, result.Versions["1.1.0"].Readme)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers) assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
assert.Equal(t, []string{"gitea"}, result.Keywords) assert.Equal(t, []string{"gitea"}, result.Keywords)
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords) assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers) assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
assert.Equal(t, assert.Equal(t,
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz", "https://gitea.dev/api/packages/alice/npm/@scope%2Ftest/-/1.0.0/test-1.0.0.tgz",
result.Versions["1.0.0"].Dist.Tarball, result.Versions["1.0.0"].Dist.Tarball,
) )
assert.Equal(t, repository, result.Versions["1.0.0"].Repository) assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
+92 -88
View File
@@ -6,9 +6,7 @@ package npm
import ( import (
"bytes" "bytes"
std_ctx "context" std_ctx "context"
"crypto/sha256"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -57,41 +55,28 @@ func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm" return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
} }
func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata { // PackageMetadata returns the metadata for a single package
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx)) func PackageMetadata(ctx *context.Context) {
packageName := packageNameFromParams(ctx)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return nil return
} }
if len(pvs) == 0 { if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, err) apiError(ctx, http.StatusNotFound, err)
return nil return
} }
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return nil
}
return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
}
// PackageMetadata returns the metadata for a single package
func PackageMetadata(ctx *context.Context) {
if metadata := packageMetadata(ctx); metadata != nil {
serveMetadata(ctx, metadata)
}
}
func serveMetadata(ctx *context.Context, obj any) {
body, err := json.MarshalDeterministic(obj)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
} }
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`W/"%x"`, sha256.Sum256(body)))
ctx.ServeContent(bytes.NewReader(body), context.ServeHeaderOptions{ContentType: "application/json;charset=utf-8"}) resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
ctx.JSON(http.StatusOK, resp)
} }
// PackageVersionMetadata returns the metadata for a single version or dist-tag // PackageVersionMetadata returns the metadata for a single version or dist-tag
@@ -115,11 +100,7 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
if len(pvs) == 0 { if len(pvs) == 0 {
if versionOrTag != "latest" { apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so serve the packument's fallback
serveMetadata(ctx, metadata.Versions[metadata.DistTags["latest"]])
}
return return
} }
@@ -129,40 +110,25 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd)) ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
} }
func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion { // DownloadPackageFile serves the content of a package
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ func DownloadPackageFile(ctx *context.Context) {
OwnerID: ctx.Package.Owner.ID, packageName := packageNameFromParams(ctx)
Type: packages_model.TypeNpm, packageVersion := ctx.PathParam("version")
Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)}, filename := ctx.PathParam("filename")
HasFileWithName: ctx.PathParam("filename"),
IsInternal: optional.Some(false),
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return nil
}
if len(pvs) != 1 {
apiError(ctx, http.StatusNotFound, nil)
return nil
}
return pvs[0]
}
// DownloadPackageFileByName finds the version and serves the contents of a package s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
func DownloadPackageFileByName(ctx *context.Context) {
pv := packageVersionByFilename(ctx)
if pv == nil {
return
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx, ctx,
pv, &packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
&packages_service.PackageFileInfo{ &packages_service.PackageFileInfo{
Filename: ctx.PathParam("filename"), Filename: filename,
}, },
ctx.Req.Method, ctx.Req.Method,
) )
@@ -174,14 +140,54 @@ func DownloadPackageFileByName(ctx *context.Context) {
helper.ServePackageFile(ctx, s, u, pf) helper.ServePackageFile(ctx, s, u, pf)
} }
// DownloadPackageFileByName finds the version and serves the contents of a package
func DownloadPackageFileByName(ctx *context.Context) {
filename := ctx.PathParam("filename")
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeNpm,
Name: packages_model.SearchValue{
ExactMatch: true,
Value: packageNameFromParams(ctx),
},
HasFileWithName: filename,
IsInternal: optional.Some(false),
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if len(pvs) != 1 {
apiError(ctx, http.StatusNotFound, nil)
return
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx,
pvs[0],
&packages_service.PackageFileInfo{
Filename: filename,
},
ctx.Req.Method,
)
if err != nil {
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
helper.ServePackageFile(ctx, s, u, pf)
}
// UploadPackage creates a new package // UploadPackage creates a new package
func UploadPackage(ctx *context.Context) { func UploadPackage(ctx *context.Context) {
// about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
if err != nil { if err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok { if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusRequestEntityTooLarge, err)
} else if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusBadRequest, err) apiError(ctx, http.StatusBadRequest, err)
} else { } else {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
@@ -334,14 +340,26 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
} }
// DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball // DeletePackageVersion deletes the package version
func DeletePackageVersion(ctx *context.Context) { func DeletePackageVersion(ctx *context.Context) {
pv := packageVersionByFilename(ctx) packageName := packageNameFromParams(ctx)
if pv == nil { packageVersion := ctx.PathParam("version")
return
}
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { err := packages_service.RemovePackageVersionByNameAndVersion(
ctx,
ctx.Doer,
&packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
} }
@@ -376,7 +394,9 @@ func DeletePackage(ctx *context.Context) {
// ListPackageTags returns all tags for a package // ListPackageTags returns all tags for a package
func ListPackageTags(ctx *context.Context) { func ListPackageTags(ctx *context.Context) {
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx)) packageName := packageNameFromParams(ctx)
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
@@ -394,11 +414,7 @@ func ListPackageTags(ctx *context.Context) {
} }
} }
if _, ok := tags["latest"]; ok { ctx.JSON(http.StatusOK, tags)
ctx.JSON(http.StatusOK, tags)
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
ctx.JSON(http.StatusOK, metadata.DistTags)
}
} }
// AddPackageTag adds a tag to the package // AddPackageTag adds a tag to the package
@@ -508,18 +524,6 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
}) })
} }
func Ping(ctx *context.Context) {
ctx.JSON(http.StatusOK, map[string]any{})
}
func Whoami(ctx *context.Context) {
if ctx.Doer == nil {
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
return
}
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
}
func PackageSearch(ctx *context.Context) { func PackageSearch(ctx *context.Context) {
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID, OwnerID: ctx.Package.Owner.ID,
+2 -2
View File
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
// parameters: // parameters:
// - name: source_id // - name: source_id
// in: query // in: query
// description: ID of the user's login source to search for // description: ID of the user's login source to search for, 0 means the local users
// type: integer // type: integer
// format: int64 // format: int64
// - name: login_name // - name: login_name
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
Actor: ctx.Doer, Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual}, Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"), LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"), SourceID: ctx.FormOptionalInt64("source_id"),
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
Visible: visible, Visible: visible,
OrderBy: orderBy, OrderBy: orderBy,
+34 -18
View File
@@ -76,7 +76,6 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
@@ -936,22 +935,31 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
} }
} }
// verifyAuthWithOptionsAPI checks authentication according to options // verifyAuthWithOptions checks authentication according to options
func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.APIContext) { func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
// Check prohibit login users. // Check prohibit login users.
if ctx.IsSigned { if ctx.IsSigned {
check := common.CheckSignedInUser(ctx.Doer, nil) if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
if check.NeedActivateAccount { ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is not activated."}) ctx.JSON(http.StatusForbidden, map[string]string{
"message": "This account is not activated.",
})
return return
} else if check.LoginIsProhibited { }
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is prohibited from signing in, please contact your site administrator."}) ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "This account is prohibited from signing in, please contact your site administrator.",
})
return return
} else if check.NeedChangePassword { }
msg := "You must change your password. Change it at: " + httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/user/settings/change_password")
ctx.JSON(http.StatusForbidden, map[string]string{"message": msg}) if ctx.Doer.MustChangePassword {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password",
})
return return
} }
} }
@@ -962,12 +970,20 @@ func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.A
return return
} }
if options.SignInRequired && !ctx.IsSigned { if options.SignInRequired {
// Restrict API calls with error message. if !ctx.IsSigned {
ctx.JSON(http.StatusForbidden, map[string]string{ // Restrict API calls with error message.
"message": "Only signed in user is allowed to call APIs.", ctx.JSON(http.StatusForbidden, map[string]string{
}) "message": "Only signed in user is allowed to call APIs.",
return })
return
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "This account is not activated.",
})
return
}
} }
if options.AdminRequired { if options.AdminRequired {
@@ -1019,7 +1035,7 @@ func Routes() *web.Router {
// Get user from session if logged in. // Get user from session if logged in.
m.AfterRouting(apiAuth(buildAuthGroup())) m.AfterRouting(apiAuth(buildAuthGroup()))
m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{ m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{
SignInRequired: setting.Service.RequireSignInViewStrict, SignInRequired: setting.Service.RequireSignInViewStrict,
})) }))
+1 -1
View File
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
} }
} }
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
-15
View File
@@ -6,8 +6,6 @@ package common
import ( import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/middleware" "gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth" auth_service "gitea.dev/services/auth"
"gitea.dev/services/context" "gitea.dev/services/context"
@@ -58,16 +56,3 @@ type VerifyOptions struct {
AdminRequired bool AdminRequired bool
DisableCrossOriginProtection bool DisableCrossOriginProtection bool
} }
func CheckSignedInUser(doer *user_model.User, sess session.Store) (ret struct {
NeedActivateAccount bool
LoginIsProhibited bool
NeedChangePassword bool
},
) {
ret.NeedActivateAccount = !doer.IsActive && setting.Service.RegisterEmailConfirm
ret.LoginIsProhibited = !doer.IsActive || doer.ProhibitLogin
isImpersonated := sess != nil && context.IsDoerSessionImpersonated(sess)
ret.NeedChangePassword = doer.MustChangePassword && !isImpersonated && !doer.IsTypeBot()
return ret
}
-53
View File
@@ -1,53 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestCheckSignedInUser(t *testing.T) {
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm)()
sessNormal := session.NewMockMemStore("session-a")
sessImpersonated := session.NewMockMemStore("session-b")
_ = sessImpersonated.Set(session.KeyImpersonatorData, "any-value")
setting.Service.RegisterEmailConfirm = false
ret := CheckSignedInUser(&user_model.User{IsActive: false}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
setting.Service.RegisterEmailConfirm = true
ret = CheckSignedInUser(&user_model.User{IsActive: false}, nil)
assert.True(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
ret = CheckSignedInUser(&user_model.User{IsActive: true}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.False(t, ret.LoginIsProhibited)
assert.False(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{IsActive: true, ProhibitLogin: true}, nil)
assert.False(t, ret.NeedActivateAccount)
assert.True(t, ret.LoginIsProhibited)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, nil)
assert.True(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessNormal)
assert.True(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true, Type: user_model.UserTypeBot}, sessNormal)
assert.False(t, ret.NeedChangePassword)
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessImpersonated)
assert.False(t, ret.NeedChangePassword)
}
+2 -2
View File
@@ -229,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
} else { } else {
if isForcePush { if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission) canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
} else { } else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission) canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
} }
} }
-51
View File
@@ -6,67 +6,16 @@ package private
import ( import (
"testing" "testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/private"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPreReceiveActionsProtectedBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tc := range []struct {
name string
protection git_model.ProtectedBranch
forcePush bool
allowed bool
}{
{name: "push", protection: git_model.ProtectedBranch{CanPush: true}, allowed: true},
{name: "push allowlist", protection: git_model.ProtectedBranch{CanPush: true, EnableWhitelist: true}},
{name: "force push", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
{name: "force push allowlist", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
} {
t.Run(tc.name, func(t *testing.T) {
mockCtx, resp := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{PrivateContext: mockCtx, opts: &private.HookOptions{UserID: user_model.ActionsUserID}}
ctx.SetPathParam("owner", "user2")
ctx.SetPathParam("repo", "repo2")
RepoAssignment(ctx.PrivateContext)
require.False(t, ctx.Written())
defer ctx.Repo.GitRepo.Close()
doer := user_model.NewActionsUserWithTaskID(53)
loadContextDoerPermission(ctx.PrivateContext, doer.ID, doer.ExtDoerData.EncodeToString())
protection := tc.protection
protection.RepoID = ctx.Repo.Repository.ID
protection.RuleName = "probe"
require.NoError(t, db.Insert(t.Context(), &protection))
defer func() {
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
}()
oldCommitID, newCommitID := "205ac761f3326a7ebe416e8673760016450b5cec", "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
if tc.forcePush {
oldCommitID, newCommitID = newCommitID, oldCommitID
}
preReceiveBranch(ctx, oldCommitID, newCommitID, git.RefNameFromBranch("probe"))
if tc.allowed {
assert.False(t, ctx.Written(), resp.Body.String())
} else {
assert.Contains(t, resp.Body.String(), "Not allowed to")
}
})
}
}
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against // 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. // 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 // Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
+32
View File
@@ -48,6 +48,13 @@ const (
// UserSearchDefaultAdminSort is the default sort type for admin view // UserSearchDefaultAdminSort is the default sort type for admin view
const UserSearchDefaultAdminSort = "alphabetically" const UserSearchDefaultAdminSort = "alphabetically"
// authSourceFilterOption is one radio item of the authentication source filter dropdown
type authSourceFilterOption struct {
Value string
Label string
Selected bool
}
// Users show all the users // Users show all the users
func Users(ctx *context.Context) { func Users(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users") ctx.Data["Title"] = ctx.Tr("admin.users")
@@ -76,6 +83,30 @@ func Users(ctx *context.Context) {
"SortType": sortType, "SortType": sortType,
} }
// inactive sources are listed too, users stay attached to a source after it is deactivated
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
sourceIDFilter := ctx.FormOptionalInt64("source_id")
sourceNames := make(map[int64]string, len(sources))
authSourceFilterOptions := []*authSourceFilterOption{
{Value: "", Label: ctx.Locale.TrString("all"), Selected: !sourceIDFilter.Has()},
{Value: "0", Label: ctx.Locale.TrString("admin.users.local"), Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == 0},
}
for _, source := range sources {
sourceNames[source.ID] = source.Name
authSourceFilterOptions = append(authSourceFilterOptions, &authSourceFilterOption{
Value: strconv.FormatInt(source.ID, 10),
Label: source.Name,
Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == source.ID,
})
}
ctx.Data["HasAuthSources"] = len(sources) > 0
ctx.Data["SourceNames"] = sourceNames
ctx.Data["AuthSourceFilterOptions"] = authSourceFilterOptions
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Types: types, Types: types,
@@ -88,6 +119,7 @@ func Users(ctx *context.Context) {
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
SourceID: sourceIDFilter,
OrderBy: db.SearchOrderBy(sortType), OrderBy: db.SearchOrderBy(sortType),
}, tplUsers) }, tplUsers)
} }
+1 -1
View File
@@ -374,7 +374,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant), // Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row // which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
// An admin-disabled user has no such signature, so we leave IsActive alone // An admin-disabled user has no such signature, so we leave IsActive alone
// and let verifyAuthWithOptionsWeb route them through the prohibit-login / activate page. // and let verifyAuthWithOptions route them through the prohibit-login / activate page.
if !u.IsActive { if !u.IsActive {
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID) extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil { if err != nil {
+7 -17
View File
@@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
audit_model "gitea.dev/models/audit" audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth" "gitea.dev/models/auth"
@@ -21,7 +20,6 @@ import (
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/services/audit" "gitea.dev/services/audit"
auth_service "gitea.dev/services/auth" auth_service "gitea.dev/services/auth"
@@ -323,18 +321,9 @@ func AuthorizeOAuth(ctx *context.Context) {
return return
} }
var addedScopes, removedScopes []string
if grant != nil {
if form.Scope == "" {
form.Scope = grant.Scope
}
addedScopes, removedScopes = util.DiffSlice(strings.Fields(grant.Scope), strings.Fields(form.Scope))
}
scopeChanged := len(addedScopes) > 0 || len(removedScopes) > 0
// Redirect if user already granted access and the application is confidential or trusted otherwise // Redirect if user already granted access and the application is confidential or trusted otherwise
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2 // I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged { if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil { if err != nil {
handleServerError(ctx, form.State, form.RedirectURI) handleServerError(ctx, form.State, form.RedirectURI)
@@ -358,7 +347,6 @@ func AuthorizeOAuth(ctx *context.Context) {
// check if additional scopes // check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
ctx.Data["AddedScopes"] = addedScopes
// show authorize page to grant access // show authorize page to grant access
ctx.Data["Application"] = app ctx.Data["Application"] = app
@@ -444,10 +432,12 @@ func GrantApplicationOAuth(ctx *context.Context) {
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope) audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
} else if grant.Scope != form.Scope { } else if grant.Scope != form.Scope {
if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil { handleAuthorizeError(ctx, AuthorizeError{
handleServerError(ctx, form.State, form.RedirectURI) State: form.State,
return ErrorDescription: "a grant exists with different scope",
} ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
} }
if len(form.Nonce) > 0 { if len(form.Nonce) > 0 {
-28
View File
@@ -13,10 +13,6 @@ import (
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/egress/policy" "gitea.dev/modules/egress/policy"
"gitea.dev/modules/session"
"gitea.dev/modules/web"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"gitea.dev/services/oauth2_provider" "gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -109,27 +105,3 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
assert.ErrorIs(t, err, policy.ErrDenied, assert.ErrorIs(t, err, policy.ErrDenied,
"avatar client must refuse a link-local cloud-metadata address") "avatar client must refuse a link-local cloud-metadata address")
} }
func TestOAuth2ScopeChange(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("oauth2-scope-change")}
authorize := func(scope string) int {
ctx, resp := contexttest.MockContext(t, "/login/oauth/authorize", mockOpt)
ctx.Doer = doer
web.SetForm(ctx, &forms.AuthorizationForm{ResponseType: "code", ClientID: app.ClientID, RedirectURI: app.RedirectURIs[0], State: "state", Scope: scope})
AuthorizeOAuth(ctx)
return resp.Code
}
assert.Equal(t, http.StatusSeeOther, authorize(""))
assert.Equal(t, http.StatusSeeOther, authorize("profile openid"))
assert.Equal(t, http.StatusOK, authorize("openid profile email"))
ctx, resp := contexttest.MockContext(t, "/login/oauth/grant", mockOpt)
ctx.Doer = doer
web.SetForm(ctx, &forms.GrantApplicationForm{ClientID: app.ClientID, Granted: true, RedirectURI: app.RedirectURIs[0], State: "state", Scope: "openid profile email"})
GrantApplicationOAuth(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Grant{ID: 1, Scope: "openid profile email"})
}
+4 -4
View File
@@ -17,7 +17,6 @@ import (
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/routers/common"
"gitea.dev/services/audit" "gitea.dev/services/audit"
"gitea.dev/services/context" "gitea.dev/services/context"
"gitea.dev/services/forms" "gitea.dev/services/forms"
@@ -283,9 +282,10 @@ func MustChangePasswordPost(ctx *context.Context) {
return return
} }
if !common.CheckSignedInUser(ctx.Doer, ctx.Session).NeedChangePassword { // Make sure only requests for users who are eligible to change their password via
log.Debug("User %s attempted to access the must change password page, but they are not required to change their password", ctx.Doer.Name) // this method passes through
ctx.NotFound(nil) if !ctx.Doer.MustChangePassword {
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
return return
} }
+23 -3
View File
@@ -17,18 +17,38 @@ import (
"gitea.dev/modules/sitemap" "gitea.dev/modules/sitemap"
"gitea.dev/modules/structs" "gitea.dev/modules/structs"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/routers/web/auth"
"gitea.dev/routers/web/user" "gitea.dev/routers/web/user"
"gitea.dev/services/context" "gitea.dev/services/context"
) )
const tplHome templates.TplName = "home" const (
// tplHome home page template
tplHome templates.TplName = "home"
)
// Home render home page
func Home(ctx *context.Context) { func Home(ctx *context.Context) {
if ctx.IsSigned { if ctx.IsSigned {
user.Dashboard(ctx) if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, auth.TplActivate)
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if doerMustChangePassword(ctx) {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
} else {
user.Dashboard(ctx)
}
return return
// Check non-logged users landing page.
} else if setting.LandingPageURL != setting.LandingPageHome { } else if setting.LandingPageURL != setting.LandingPageHome {
// Check non-logged users landing page
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL)) ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return return
} }
+1 -1
View File
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
} }
} }
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
if pull_service.IsErrInvalidMergeStyle(err) { if pull_service.IsErrInvalidMergeStyle(err) {
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok { } else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
+32 -18
View File
@@ -174,29 +174,38 @@ func newWebAuthMiddleware() *AuthMiddleware {
return webAuth return webAuth
} }
// verifyAuthWithOptionsWeb checks authentication according to options func doerMustChangePassword(ctx *context.Context) bool {
func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) { // an impersonating admin must not be forced to set the impersonated user's password
return ctx.Doer != nil && ctx.Doer.MustChangePassword && !ctx.DoerIsImpersonated()
}
// verifyAuthWithOptions checks authentication according to options
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) {
crossOriginProtection := http.NewCrossOriginProtection() crossOriginProtection := http.NewCrossOriginProtection()
return func(ctx *context.Context) { return func(ctx *context.Context) {
// Check prohibit login users. // Check prohibit login users.
if ctx.IsSigned { if ctx.IsSigned {
check := common.CheckSignedInUser(ctx.Doer, ctx.Session) if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
if check.NeedActivateAccount {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account") ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, "user/auth/activate") ctx.HTML(http.StatusOK, "user/auth/activate")
return return
} else if check.LoginIsProhibited { }
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
return return
} else if check.NeedChangePassword { }
if doerMustChangePassword(ctx) {
if ctx.Req.URL.Path != "/user/settings/change_password" { if ctx.Req.URL.Path != "/user/settings/change_password" {
if strings.HasPrefix(ctx.Req.UserAgent(), "git") { if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return return
} }
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return return
@@ -221,9 +230,15 @@ func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.C
} }
} }
if options.SignInRequired && !ctx.IsSigned { if options.SignInRequired {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req)) if !ctx.IsSigned {
return ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
return
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, "user/auth/activate")
return
}
} }
// Redirect to log in page if auto-signin info is provided and has not signed in. // Redirect to log in page if auto-signin info is provided and has not signed in.
@@ -321,7 +336,7 @@ func Routes() *web.Router {
// The CORS mechanism already protects cross-origin requests, and the CrossOriginProtection has no "allowed origin" list, so disable CrossOriginProtection. // The CORS mechanism already protects cross-origin requests, and the CrossOriginProtection has no "allowed origin" list, so disable CrossOriginProtection.
// - For non-browser client requests: git clone via http, no Sec-Fetch-Site header. // - For non-browser client requests: git clone via http, no Sec-Fetch-Site header.
// Such requests are not cross-origin requests, so disable CrossOriginProtection. // Such requests are not cross-origin requests, so disable CrossOriginProtection.
var optSignInFromAnyOrigin = verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: true}) var optSignInFromAnyOrigin = verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true})
// addProjectBoardRoutes registers a board's column and card routes, shared by the // addProjectBoardRoutes registers a board's column and card routes, shared by the
// repository and owner mount points. // repository and owner mount points.
@@ -340,14 +355,13 @@ func addProjectBoardRoutes(m *web.Router) {
// registerWebRoutes register routes // registerWebRoutes register routes
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// middleware: required to be signed in or signed out // middleware: required to be signed in or signed out
reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true}) reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true}) reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
// middleware: optional sign in (if signed in, use the user as doer, if not, no doer) // middleware: optional sign in (if signed in, use the user as doer, if not, no doer)
optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false}) // site home doesn't need "require sign-in" protection optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict}) optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
optExploreSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
// middleware: only apply CrossOriginProtection // middleware: only apply CrossOriginProtection
crossOriginProtect := verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: false}) crossOriginProtect := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: false})
openIDSignInEnabled := func(ctx *context.Context) { openIDSignInEnabled := func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn { if !setting.Service.EnableOpenIDSignIn {
@@ -519,7 +533,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// FIXME: not all routes need go through same middleware. // FIXME: not all routes need go through same middleware.
// Especially some AJAX requests, we can reduce middleware number to improve performance. // Especially some AJAX requests, we can reduce middleware number to improve performance.
m.Get("/", optSignInHome, Home) m.Get("/", Home)
m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap) m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap)
m.Group("/.well-known", func() { m.Group("/.well-known", func() {
m.Get("/openid-configuration", auth.OIDCWellKnown) m.Get("/openid-configuration", auth.OIDCWellKnown)
@@ -766,7 +780,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/avatar/{hash}", user.AvatarByEmailHash) m.Get("/avatar/{hash}", user.AvatarByEmailHash)
adminReq := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true, AdminRequired: true}) adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
// ***** START: Admin ***** // ***** START: Admin *****
m.Group("/-/admin", func() { m.Group("/-/admin", func() {
+1 -1
View File
@@ -224,7 +224,7 @@ func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullReques
// although expectedHeadCommitID is checked before, we should pass it to the Merge function to // although expectedHeadCommitID is checked before, we should pass it to the Merge function to
// make it be checked again in case the head commit id changed after the previous check. // make it be checked again in case the head commit id changed after the previous check.
if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil { if err := pull_service.Merge(pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
if pull_service.IsErrSHADoesNotMatch(err) { if pull_service.IsErrSHADoesNotMatch(err) {
return errors.Join(errSkipAutoMerge, err) return errors.Join(errSkipAutoMerge, err)
} }
+5
View File
@@ -211,6 +211,11 @@ func (ctx *Context) DoerNeedTwoFactorAuth() bool {
return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false
} }
// DoerIsImpersonated returns true if the current session is an admin impersonating the doer
func (ctx *Context) DoerIsImpersonated() bool {
return ctx.Session.Get(session.KeyImpersonatorData) != nil
}
// HasError returns true if error occurs in form validation. // HasError returns true if error occurs in form validation.
// Attention: this function changes ctx.Data and ctx.Flash // Attention: this function changes ctx.Data and ctx.Flash
// If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again. // If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again.
+1 -1
View File
@@ -69,7 +69,7 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
func (c TemplateContext) ImpersonatedUser() *user_model.User { func (c TemplateContext) ImpersonatedUser() *user_model.User {
webCtx := GetWebContext(c) webCtx := GetWebContext(c)
if webCtx == nil || webCtx.Doer == nil || !IsDoerSessionImpersonated(webCtx.Session) { if webCtx == nil || webCtx.Doer == nil || !webCtx.DoerIsImpersonated() {
return nil return nil
} }
return webCtx.Doer return webCtx.Doer
+1 -1
View File
@@ -190,7 +190,7 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r
protectionRequireSigned := false protectionRequireSigned := false
if protectedBranch != nil { if protectedBranch != nil {
protectedBranch.Repo = targetRepo protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer, doerRepoPerm) canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
protectionRequireSigned = protectedBranch.RequireSignedCommits protectionRequireSigned = protectedBranch.RequireSignedCommits
// If branch-wide push is restricted, allow direct commit when the // If branch-wide push is restricted, allow direct commit when the
// URL-derived tree path matches an unprotected file pattern. The // URL-derived tree path matches an unprotected file pattern. The
-5
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
) )
// UserAssignmentWeb returns a middleware to handle context-user assignment for web routes // UserAssignmentWeb returns a middleware to handle context-user assignment for web routes
@@ -59,7 +58,3 @@ func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, string)) (
} }
return contextUser return contextUser
} }
func IsDoerSessionImpersonated(sess session.Store) bool {
return sess.Get(session.KeyImpersonatorData) != nil
}
+1 -1
View File
@@ -113,7 +113,7 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin
return nil, err return nil, err
} }
bp.Repo = repo bp.Repo = repo
branch.UserCanPush = bp.CanUserPush(ctx, user, permission) branch.UserCanPush = bp.CanUserPush(ctx, user)
branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission) branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission)
} }
+1 -16
View File
@@ -14,7 +14,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
"uuid"
"gitea.dev/models/db" "gitea.dev/models/db"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
@@ -28,7 +27,6 @@ import (
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/globallock" "gitea.dev/modules/globallock"
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
"gitea.dev/modules/gtprof"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/references" "gitea.dev/modules/references"
@@ -291,22 +289,9 @@ func hasPullRequestCommitBeenMerged(ctx context.Context, pr *issues_model.PullRe
// Merge merges pull request to base repository. // Merge merges pull request to base repository.
// Caller should check PR is ready to be merged (review and status checks) // Caller should check PR is ready to be merged (review and status checks)
func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error { func Merge(prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
outerCtxId := uuid.NewV4().String()
_, outerSpan := gtprof.GetTracer().Start(outerCtx, gtprof.TraceSpanContext)
outerSpan.SetAttributeString("context.trace-id", outerCtxId) // this attribute is only used internally for debugging purpose
defer outerSpan.End()
// TODO: in the future, the contexts from graceful.GetManager() should be wrapped with gtprof tracing, refactor the code to framework-level support
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanContext)
span.SetAttributeString(gtprof.TraceAttrGeneralName, "merge-pull-request")
span.SetAttributeString(gtprof.TraceAttrGeneralDesc, fmt.Sprintf("merge pull request %d with merge style %s", prID, mergeStyle))
span.SetAttributeString("context.trace-id-outer", outerCtxId) // this attribute is only used internally for debugging purpose
defer span.End()
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error { err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
pr, err := issues_model.GetPullRequestByID(ctx, prID) pr, err := issues_model.GetPullRequestByID(ctx, prID)
if err != nil { if err != nil {
+2 -2
View File
@@ -123,8 +123,8 @@ func isUserAllowedToPushOrForcePushInRepoBranch(ctx context.Context, user *user_
} }
if pb != nil { // override previous results if there is a branch protection rule if pb != nil { // override previous results if there is a branch protection rule
pb.Repo = repo pb.Repo = repo
pushAllowed = pb.CanUserPush(ctx, user, repoPerm) pushAllowed = pb.CanUserPush(ctx, user)
forcePushAllowed = pb.CanUserForcePush(ctx, user, repoPerm) forcePushAllowed = pb.CanUserForcePush(ctx, user)
} }
return pushAllowed, forcePushAllowed, nil return pushAllowed, forcePushAllowed, nil
} }
+1 -1
View File
@@ -447,7 +447,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
if err != nil { if err != nil {
return "", err return "", err
} }
if rule != nil && !rule.CanUserPush(ctx, doer, perm) { if rule != nil && !rule.CanUserPush(ctx, doer) {
return "", git_model.ErrBranchIsProtected return "", git_model.ErrBranchIsProtected
} }
+1 -6
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
@@ -89,11 +88,7 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
} }
if protectedBranch != nil { if protectedBranch != nil {
protectedBranch.Repo = repo protectedBranch.Repo = repo
perm, err := access.GetDoerRepoPermission(ctx, repo, doer) if !protectedBranch.CanUserPush(ctx, doer) {
if err != nil {
return err
}
if !protectedBranch.CanUserPush(ctx, doer, perm) {
return ErrUserCannotCommit{ return ErrUserCannotCommit{
UserName: doer.LowerName, UserName: doer.LowerName,
} }
+1 -6
View File
@@ -13,7 +13,6 @@ import (
"time" "time"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
@@ -668,11 +667,7 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, gi
protectedBranch.Repo = repo protectedBranch.Repo = repo
globUnprotected := protectedBranch.GetUnprotectedFilePatterns() globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
globProtected := protectedBranch.GetProtectedFilePatterns() globProtected := protectedBranch.GetProtectedFilePatterns()
perm, err := access.GetDoerRepoPermission(ctx, repo, doer) canUserPush := protectedBranch.CanUserPush(ctx, doer)
if err != nil {
return err
}
canUserPush := protectedBranch.CanUserPush(ctx, doer, perm)
for _, treePath := range treePaths { for _, treePath := range treePaths {
isUnprotectedFile := false isUnprotectedFile := false
if len(globUnprotected) != 0 { if len(globUnprotected) != 0 {
+6 -12
View File
@@ -7,26 +7,20 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"strings"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
) )
const gitLogGraphFormatSep = "^" // disallowed char in git ref names
// GetCommitGraph return a list of commit (GraphItems) from all branches // GetCommitGraph return a list of commit (GraphItems) from all branches
func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllowedColors int, hidePRRefs bool, refs, files []string) (*Graph, error) { func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllowedColors int, hidePRRefs bool, refs, files []string) (*Graph, error) {
format := "DATA:" + strings.Join([]string{ format := "DATA:%D|%H|%ad|%h|%s"
"%D", // ref names without the " (", ")" wrapping.
"%H", // commit hash if page == 0 {
"%ad", // author date (format respects --date= option) page = 1
"%h", // abbreviated commit hash }
"%s", // subject
}, gitLogGraphFormatSep)
page = max(page, 1)
graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full") graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full")
if hidePRRefs { if hidePRRefs {
@@ -37,7 +31,7 @@ func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllow
graphCmd.AddArguments("--tags", "--branches") graphCmd.AddArguments("--tags", "--branches")
} }
graphCmd.AddArguments("--find-copies", "--find-renames", "--date=iso-strict"). graphCmd.AddArguments("-C", "-M", "--date=iso-strict").
AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page). AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
AddOptionFormat("--pretty=format:%s", format) AddOptionFormat("--pretty=format:%s", format)
+1 -1
View File
@@ -216,7 +216,7 @@ func parseGitTime(timeStr string) time.Time {
// NewCommit creates a new commit from a provided line // NewCommit creates a new commit from a provided line
func NewCommit(row, column int, line []byte) (*Commit, error) { func NewCommit(row, column int, line []byte) (*Commit, error) {
data := bytes.SplitN(line, []byte(gitLogGraphFormatSep), 5) data := bytes.SplitN(line, []byte("|"), 5)
if len(data) < 5 { if len(data) < 5 {
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line)) return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
} }
+3 -3
View File
@@ -35,7 +35,7 @@ func BenchmarkGetCommitGraph(b *testing.B) {
} }
func BenchmarkParseCommitString(b *testing.B) { func BenchmarkParseCommitString(b *testing.B) {
testString := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^Add route for graph" testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
parser := &Parser{} parser := &Parser{}
parser.Reset() parser.Reset()
@@ -224,14 +224,14 @@ func TestParseGlyphs(t *testing.T) {
} }
func TestCommitStringParsing(t *testing.T) { func TestCommitStringParsing(t *testing.T) {
dataFirstPart := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^" dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
tests := []struct { tests := []struct {
shouldPass bool shouldPass bool
testName string testName string
commitMessage string commitMessage string
}{ }{
{true, "normal", "not a fancy message"}, {true, "normal", "not a fancy message"},
{true, "extra sep", "An extra sep"}, {true, "extra pipe", "An extra pipe: |"},
{true, "extra 'Data:'", "DATA: might be trouble"}, {true, "extra 'Data:'", "DATA: might be trouble"},
} }
+17 -1
View File
@@ -47,6 +47,20 @@
</div> </div>
</div> </div>
<!-- Authentication Source Filter Menu Item -->
{{if .HasAuthSources}}
<div class="ui dropdown type jump item">
<span class="text">{{ctx.Locale.Tr "admin.users.auth_source"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu flex-items-menu">
{{range $index, $option := .AuthSourceFilterOptions}}
{{if eq $index 1}}<div class="divider"></div>{{end}}
<label class="item"><input type="radio" name="source_id" value="{{$option.Value}}" {{if $option.Selected}}checked{{end}}> {{$option.Label}}</label>
{{end}}
</div>
</div>
{{end}}
<!-- Sort Menu Item --> <!-- Sort Menu Item -->
<div class="ui dropdown type jump item"> <div class="ui dropdown type jump item">
<span class="text"> <span class="text">
@@ -75,6 +89,7 @@
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}} {{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
</th> </th>
<th>{{ctx.Locale.Tr "email"}}</th> <th>{{ctx.Locale.Tr "email"}}</th>
<th>{{ctx.Locale.Tr "admin.users.auth_source"}}</th>
<th>{{ctx.Locale.Tr "admin.users.activated"}}</th> <th>{{ctx.Locale.Tr "admin.users.activated"}}</th>
<th>{{ctx.Locale.Tr "admin.users.restricted"}}</th> <th>{{ctx.Locale.Tr "admin.users.restricted"}}</th>
<th>{{ctx.Locale.Tr "admin.users.2fa"}}</th> <th>{{ctx.Locale.Tr "admin.users.2fa"}}</th>
@@ -102,6 +117,7 @@
{{template "shared/user/user_type_label" .}} {{template "shared/user/user_type_label" .}}
</td> </td>
<td class="gt-ellipsis tw-max-w-48">{{.Email}}</td> <td class="gt-ellipsis tw-max-w-48">{{.Email}}</td>
<td class="gt-ellipsis tw-max-w-32">{{if .LoginSource}}{{index $.SourceNames .LoginSource}}{{else}}{{ctx.Locale.Tr "admin.users.local"}}{{end}}</td>
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
@@ -119,7 +135,7 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr class="no-results-row"><td class="tw-text-center" colspan="9">{{ctx.Locale.Tr "no_results_found"}}</td></tr> <tr class="no-results-row"><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
+1 -1
View File
@@ -4,7 +4,7 @@
{{- $activeStopwatch := call $data.GetActiveStopwatch -}} {{- $activeStopwatch := call $data.GetActiveStopwatch -}}
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}} {{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
{{/* always rendered so a real-time push can reveal it without a reload */}} {{/* always rendered so a real-time push can reveal it without a reload */}}
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}"> <a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}href="{{$activeStopwatch.IssueLink}}" data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
<div class="tw-relative flex-text-block"> <div class="tw-relative flex-text-block">
{{svg "octicon-stopwatch"}} {{svg "octicon-stopwatch"}}
<span class="header-stopwatch-dot"></span> <span class="header-stopwatch-dot"></span>
@@ -9,15 +9,18 @@
<h3 class="tw-m-0">{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}</h3> <h3 class="tw-m-0">{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}</h3>
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}} {{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}}
</div> </div>
{{$args := $pull.GetInstructionsCliArgs}} {{$localBranch := $pull.HeadBranch}}
{{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}
{{$localBranch = print $pull.HeadRepo.OwnerName "-" $pull.HeadBranch}}
{{end}}
<div class="ui secondary segment tw-font-mono"> <div class="ui secondary segment tw-font-mono">
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}} {{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}}
{{if eq $pull.Flow 0}} {{if eq $pull.Flow 0}}
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$args.HeadBranchArg}}:{{$args.LocalBranchArg}}</div> <div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
{{else}} {{else}}
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$args.LocalBranchArg}}</div> <div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div>
{{end}} {{end}}
<div>git checkout {{$args.LocalBranchArg}}</div> <div>git checkout {{$localBranch}}</div>
</div> </div>
{{if $data.ShowMergeInstructions}} {{if $data.ShowMergeInstructions}}
<div> <div>
@@ -29,32 +32,32 @@
</div> </div>
<div class="ui secondary segment tw-font-mono"> <div class="ui secondary segment tw-font-mono">
<div data-pull-merge-style="merge"> <div data-pull-merge-style="merge">
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --no-ff {{$args.LocalBranchArg}}</div> <div>git merge --no-ff {{$localBranch}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="rebase"> <div class="tw-hidden" data-pull-merge-style="rebase">
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --ff-only {{$args.LocalBranchArg}}</div> <div>git merge --ff-only {{$localBranch}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="rebase-merge"> <div class="tw-hidden" data-pull-merge-style="rebase-merge">
<div>git checkout {{$args.LocalBranchArg}}</div> <div>git checkout {{$localBranch}}</div>
<div>git rebase {{$args.BaseBranchArg}}</div> <div>git rebase {{$pull.BaseBranch}}</div>
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --no-ff {{$args.LocalBranchArg}}</div> <div>git merge --no-ff {{$localBranch}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="squash"> <div class="tw-hidden" data-pull-merge-style="squash">
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --squash {{$args.LocalBranchArg}}</div> <div>git merge --squash {{$localBranch}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="fast-forward-only"> <div class="tw-hidden" data-pull-merge-style="fast-forward-only">
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge --ff-only {{$args.LocalBranchArg}}</div> <div>git merge --ff-only {{$localBranch}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="manually-merged"> <div class="tw-hidden" data-pull-merge-style="manually-merged">
<div>git checkout {{$args.BaseBranchArg}}</div> <div>git checkout {{$pull.BaseBranch}}</div>
<div>git merge {{$args.LocalBranchArg}}</div> <div>git merge {{$localBranch}}</div>
</div> </div>
<div>git push {{$gitRemoteName}} {{$args.BaseBranchArg}}</div> <div>git push {{$gitRemoteName}} {{$pull.BaseBranch}}</div>
</div> </div>
{{end}} {{end}}
</div> </div>
+1 -1
View File
@@ -11939,7 +11939,7 @@
"operationId": "adminSearchUsers", "operationId": "adminSearchUsers",
"parameters": [ "parameters": [
{ {
"description": "ID of the user's login source to search for", "description": "ID of the user's login source to search for, 0 means the local users",
"in": "query", "in": "query",
"name": "source_id", "name": "source_id",
"schema": { "schema": {
+1 -1
View File
@@ -825,7 +825,7 @@
{ {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"description": "ID of the user's login source to search for", "description": "ID of the user's login source to search for, 0 means the local users",
"name": "source_id", "name": "source_id",
"in": "query" "in": "query"
}, },
+1 -1
View File
@@ -1,5 +1,5 @@
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}} {{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
<div class="inline field tw-text-center" data-global-init="initImageCaptcha"> <div class="inline field tw-text-center">
{{.Captcha.CreateHTML}} {{.Captcha.CreateHTML}}
</div> </div>
<div class="required field {{if .Err_Captcha}}error{{end}}"> <div class="required field {{if .Err_Captcha}}error{{end}}">
-1
View File
@@ -12,7 +12,6 @@
{{end}} {{end}}
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br> {{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}} {{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
{{if .AddedScopes}}<br>{{ctx.Locale.Tr "auth.authorize_application_new_scopes" (HTMLFormat "<b>%s</b>" (StringUtils.Join .AddedScopes " "))}}{{end}}
</p> </p>
</div> </div>
<div class="ui attached segment"> <div class="ui attached segment">
+12
View File
@@ -39,6 +39,18 @@ test('pdf file', async ({page, request}) => {
await assertFlushWithParent(container, page.locator('.file-view')); await assertFlushWithParent(container, page.locator('.file-view'));
}); });
test('code line anchors', async ({page, request}) => {
const repoName = `e2e-line-anchor-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateFiles(request, owner, repoName, [{path: 'test.txt', content: 'a\n'}]);
const url = `/${owner}/${repoName}/src/branch/main/test.txt`;
await page.goto(`${url}#L0`);
await page.goto(`${url}#L1`);
await expect(page.locator('.code-view tr.active')).toHaveCount(1);
await assertNoJsError(page);
});
test('asciicast file', async ({page, request}) => { test('asciicast file', async ({page, request}) => {
const repoName = `e2e-asciicast-render-${randomString(8)}`; const repoName = `e2e-asciicast-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER; const owner = env.GITEA_TEST_E2E_USER;
@@ -4,20 +4,16 @@
package integration package integration
import ( import (
"context"
"sync" "sync"
"testing" "testing"
"time"
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"xorm.io/builder"
) )
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps. // minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
@@ -120,42 +116,3 @@ func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
assert.NotZero(t, updated.TaskID) assert.NotZero(t, updated.TaskID)
} }
} }
func prepareWaitingRunJob(t *testing.T) *actions_model.ActionRunJob {
if setting.Database.Type.IsSQLite3() {
t.Skip("SQLite serializes write transactions")
}
job := &actions_model.ActionRunJob{RepoID: 1, Status: actions_model.StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
func TestCreateTaskForRunnerDuringOpenClaimDoesNotWait(t *testing.T) {
job := prepareWaitingRunJob(t)
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
_, err := db.GetEngine(ctx).ID(job.ID).Cols("name").Update(&actions_model.ActionRunJob{Name: "claiming"})
require.NoError(t, err)
pickupCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, _, err = actions_model.CreateTaskForRunner(pickupCtx, &actions_model.ActionRunner{})
return err
}))
}
func TestClaimRunJobAfterConcurrentCancelUpdatesNothing(t *testing.T) {
job := prepareWaitingRunJob(t)
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
claimed, err := actions_model.GetRunJobByRepoAndID(ctx, job.RepoID, job.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("status").Update(&actions_model.ActionRunJob{Status: actions_model.StatusCancelled})
require.NoError(t, err)
claimed.TaskID, claimed.Status = 1, actions_model.StatusRunning
affected, err := actions_model.UpdateRunJob(ctx, claimed, builder.Eq{"task_id": 0, "status": actions_model.StatusWaiting}, "task_id", "status")
require.NoError(t, err)
assert.Zero(t, affected)
return nil
}))
}
+45 -11
View File
@@ -16,8 +16,10 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/services/auth/source/ldap"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -34,6 +36,49 @@ func TestAdminViewUsers(t *testing.T) {
session.MakeRequest(t, req, http.StatusForbidden) session.MakeRequest(t, req, http.StatusForbidden)
} }
func TestAdminViewUsersFilterAuthSource(t *testing.T) {
defer tests.PrepareTestEnv(t)()
source := &auth_model.Source{Type: auth_model.LDAP, Name: "test-user-list-filter", IsActive: false, Cfg: &ldap.Source{}} // users stay attached to a deactivated source
require.NoError(t, auth_model.CreateSource(t.Context(), source))
user2 := &user_model.User{ID: 2, LoginType: auth_model.LDAP, LoginSource: source.ID}
require.NoError(t, user_model.UpdateUserCols(t.Context(), user2, "login_type", "login_source"))
session := loginUser(t, "user1")
listUsers := func(query string) (*HTMLDoc, []string) {
req := NewRequest(t, "GET", "/-/admin/users?"+query)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
return doc, doc.Find("table tbody tr td:nth-child(2) a").Map(func(_ int, s *goquery.Selection) string {
return s.Text()
})
}
doc, users := listUsers("source_id=") // the "All" option submits an empty value
AssertHTMLElement(t, doc, `input[name="source_id"][value=""][checked]`, true)
assert.Subset(t, users, []string{"user1", "user2"})
doc, users = listUsers(fmt.Sprintf("source_id=%d", source.ID)) // the "test-user-list-filter" LDAP source
AssertHTMLElement(t, doc, fmt.Sprintf(`input[name="source_id"][value="%d"][checked]`, source.ID), true)
assert.Equal(t, []string{"user2"}, users)
assert.Equal(t, source.Name, doc.Find("table tbody tr td:nth-child(4)").Text())
_, users = listUsers("source_id=0") // 0 means the "Local" source
assert.Contains(t, users, "user1")
assert.NotContains(t, users, "user2")
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadAdmin)
req := NewRequest(t, "GET", "/api/v1/admin/users?source_id=0").AddTokenAuth(token) // the API also treats 0 as local users
apiUsers := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), []api.User{})
apiUserNames := make([]string, 0, len(apiUsers))
for _, u := range apiUsers {
apiUserNames = append(apiUserNames, u.UserName)
}
assert.Contains(t, apiUserNames, "user1")
assert.NotContains(t, apiUserNames, "user2")
}
func TestAdminViewUser(t *testing.T) { func TestAdminViewUser(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
@@ -235,17 +280,6 @@ func TestAdminBotUser(t *testing.T) {
} }
}) })
t.Run("TokenIgnoresMustChangePassword", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
bot.IsActive, bot.MustChangePassword = true, true
require.NoError(t, user_model.UpdateUserCols(t.Context(), bot, "is_active", "must_change_password"))
token := &auth_model.AccessToken{UID: bot.ID, Name: "git", Scope: auth_model.AccessTokenScopeAll}
require.NoError(t, auth_model.NewAccessToken(t.Context(), token))
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token.Token), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", "/user2/repo1.git/info/refs?service=git-upload-pack").AddBasicAuth(bot.Name, token.Token), http.StatusOK)
})
t.Run("APIRejectsAuthSource", func(t *testing.T) { t.Run("APIRejectsAuthSource", func(t *testing.T) {
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"}) bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/"+bot.Name, map[string]any{"source_id": 1}).AddBasicAuth("user1") req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/"+bot.Name, map[string]any{"source_id": 1}).AddBasicAuth("user1")
+18 -23
View File
@@ -104,7 +104,6 @@ func TestPackageNpm(t *testing.T) {
}, },
"cpu": ["x64", "arm64"], "cpu": ["x64", "arm64"],
"os": ["linux", "darwin"], "os": ["linux", "darwin"],
"libc": ["glibc"],
"directories": { "directories": {
"doc": "./doc", "doc": "./doc",
"man": "./man" "man": "./man"
@@ -219,7 +218,7 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName]) assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
assert.Equal(t, integrity, pmv.Dist.Integrity) assert.Equal(t, integrity, pmv.Dist.Integrity)
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum) assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
assert.Equal(t, fmt.Sprintf("%sapi/packages/%s/npm/%s/-/%s", setting.AppURL, user.Name, packageName, filename), pmv.Dist.Tarball) assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
assert.Equal(t, repoType, result.Repository.Type) assert.Equal(t, repoType, result.Repository.Type)
assert.Equal(t, repoURL, result.Repository.URL) assert.Equal(t, repoURL, result.Repository.URL)
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies) assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
@@ -229,24 +228,10 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines) assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU) assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS) assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
assert.Equal(t, []string{"glibc"}, pmv.Libc)
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories) assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
assert.Equal(t, "https://example.com/fund", pmv.Funding) assert.Equal(t, "https://example.com/fund", pmv.Funding)
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies) assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
assert.Empty(t, pmv.Deprecated) assert.Empty(t, pmv.Deprecated)
req = NewRequest(t, "GET", root).AddTokenAuth(token).SetHeader("If-None-Match", resp.Header().Get("ETag"))
MakeRequest(t, req, http.StatusNotModified)
})
t.Run("PingWhoami", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
registry := fmt.Sprintf("/api/packages/%s/npm/-/", user.Name)
MakeRequest(t, NewRequest(t, "GET", registry+"ping"), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", registry+"whoami"), http.StatusUnauthorized)
resp := MakeRequest(t, NewRequest(t, "GET", registry+"whoami").AddTokenAuth(token), http.StatusOK)
assert.JSONEq(t, `{"username":"`+user.Name+`"}`, resp.Body.String())
}) })
t.Run("PackageVersionMetadata", func(t *testing.T) { t.Run("PackageVersionMetadata", func(t *testing.T) {
@@ -305,6 +290,22 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, packageVersion, result[packageTag2]) assert.Equal(t, packageVersion, result[packageTag2])
}) })
t.Run("PackageMetadataDistTags", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", root).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
assert.Len(t, result.DistTags, 2)
assert.Contains(t, result.DistTags, packageTag)
assert.Equal(t, packageVersion, result.DistTags[packageTag])
assert.Contains(t, result.DistTags, packageTag2)
assert.Equal(t, packageVersion, result.DistTags[packageTag2])
})
t.Run("DeleteTag", func(t *testing.T) { t.Run("DeleteTag", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
@@ -318,12 +319,6 @@ func TestPackageNpm(t *testing.T) {
test(t, http.StatusBadRequest, "1.0") test(t, http.StatusBadRequest, "1.0")
test(t, http.StatusOK, "dummy") test(t, http.StatusOK, "dummy")
test(t, http.StatusOK, packageTag2) test(t, http.StatusOK, packageTag2)
test(t, http.StatusOK, packageTag)
resp := MakeRequest(t, NewRequest(t, "GET", tagsRoot).AddTokenAuth(token), http.StatusOK)
assert.Equal(t, map[string]string{packageTag: packageVersion}, DecodeJSON(t, resp, map[string]string{}))
resp = MakeRequest(t, NewRequest(t, "GET", root+"/"+packageTag).AddTokenAuth(token), http.StatusOK)
assert.Equal(t, packageVersion, DecodeJSON(t, resp, &npm.PackageMetadataVersion{}).Version)
}) })
t.Run("Search", func(t *testing.T) { t.Run("Search", func(t *testing.T) {
@@ -528,7 +523,7 @@ func TestPackageNpm(t *testing.T) {
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)) req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename))
MakeRequest(t, req, http.StatusUnauthorized) MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/-rev/dummy", root, filename)). req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)).
AddTokenAuth(token) AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
+6 -6
View File
@@ -378,11 +378,11 @@ func TestCantMergeConflict(t *testing.T) {
BaseBranch: "base", BaseBranch: "base",
}) })
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false) err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict") assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error") assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error")
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false) err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
assert.Error(t, err, "Merge should return an error due to conflict") assert.Error(t, err, "Merge should return an error due to conflict")
assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error") assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
}) })
@@ -473,7 +473,7 @@ func TestCantMergeUnrelated(t *testing.T) {
BaseBranch: "base", BaseBranch: "base",
}) })
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false) err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
assert.Error(t, err, "Merge should return an error due to unrelated") assert.Error(t, err, "Merge should return an error due to unrelated")
assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error") assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
}) })
@@ -509,7 +509,7 @@ func TestFastForwardOnlyMerge(t *testing.T) {
BaseBranch: "master", BaseBranch: "master",
}) })
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false) err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
@@ -596,7 +596,7 @@ func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) {
pb.RequireSignedCommits = false pb.RequireSignedCommits = false
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{})) require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{}))
require.NoError(t, pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)) require.NoError(t, pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
}) })
} }
@@ -631,7 +631,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
BaseBranch: "master", BaseBranch: "master",
}) })
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false) err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
assert.Error(t, err, "Merge should return an error due to being for a diverging branch") assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error") assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
}) })
-7
View File
@@ -2,8 +2,6 @@
/* fonts */ /* fonts */
--fonts-proportional: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial; --fonts-proportional: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial;
--fonts-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, var(--fonts-emoji); --fonts-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, var(--fonts-emoji);
/* Chromium's "math" is a font Linux and ChromeOS don't ship, so fall back to the math fonts they do, https://issues.chromium.org/issues/40069293 */
--fonts-math: math, "STIX Two Math", "DejaVu Math TeX Gyre", "Noto Sans Math";
/* GitHub explicitly sets font names like: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla"; /* GitHub explicitly sets font names like: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla";
Actually "Twemoji Mozilla" emoji font is widely used by browsers like Firefox, Pale Moon, and it is more likely up-to-dated than the system emoji font. Actually "Twemoji Mozilla" emoji font is widely used by browsers like Firefox, Pale Moon, and it is more likely up-to-dated than the system emoji font.
So not setting emoji font seems to be the best choice, here we just use a non-existing dummy font name and let browsers choose. */ So not setting emoji font seems to be the best choice, here we just use a non-existing dummy font name and let browsers choose. */
@@ -109,11 +107,6 @@ samp,
font-size: 0.95em; /* compensate for monospace fonts being usually slightly larger */ font-size: 0.95em; /* compensate for monospace fonts being usually slightly larger */
} }
math {
font-family: var(--fonts-math);
font-size-adjust: ex-height 0.52; /* match KaTeX's x-height (0.431 × 1.21em) regardless of math font, https://github.com/w3c/mathml-core/issues/41 */
}
/* there are many <code> blocks in non-markup(.markup code) / non-code-diff(code.code-inner) containers, for example: translation strings, etc, /* there are many <code> blocks in non-markup(.markup code) / non-code-diff(code.code-inner) containers, for example: translation strings, etc,
so we need to make <code> have default global styles, ".markup code" has its own styles and these styles sometimes conflict. so we need to make <code> have default global styles, ".markup code" has its own styles and these styles sometimes conflict.
TODO: in the future, we should use `div` instead of `code` for `.code-inner` because it is a container for highlighted code line, then drop this ":not" patch */ TODO: in the future, we should use `div` instead of `code` for `.code-inner` because it is a container for highlighted code line, then drop this ":not" patch */
-13
View File
@@ -1,19 +1,6 @@
import {isDarkTheme} from '../utils.ts'; import {isDarkTheme} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initCaptcha() { export async function initCaptcha() {
registerGlobalInitFunc('initImageCaptcha', (el: HTMLElement) => {
const a = el.querySelector('a')!;
a.removeAttribute('href'); // remove generated href="javascript:"
const img = el.querySelector('img')!;
img.removeAttribute('onclick'); // remove generated onclick="...." and use our own event listener
img.addEventListener('click', () => {
const url = new URL(img.src);
url.searchParams.set('reload', String(Date.now()));
img.src = url.href;
});
});
const captchaEl = document.querySelector('#captcha'); const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return; if (!captchaEl) return;
+1
View File
@@ -58,6 +58,7 @@ function selectRange(range: string): Element | null {
stopLineNum = tmp; stopLineNum = tmp;
range = `${stop}-${start}`; range = `${stop}-${start}`;
} }
if (startLineNum < 1) return null;
const first = elLineNums[startLineNum - 1] ?? null; const first = elLineNums[startLineNum - 1] ?? null;
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) { for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
+1
View File
@@ -93,6 +93,7 @@ function updateStopwatchData(data: Array<StopwatchData>) {
} else { } else {
const {repo_owner_name, repo_name, issue_index, seconds} = watch; const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
for (const btnEl of btnEls) btnEl.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`); document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
+4
View File
@@ -17,7 +17,11 @@ test('isGiteaError', () => {
expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false); expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true); expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true);
expect(isGiteaError('', `Error\n at ${origin}/web_src/js/index.ts:1:1`)).toBe(false);
expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
vi.spyOn(window.config, 'runModeIsProd', 'get').mockReturnValue(false);
expect(isGiteaError('', `Error\n at ${origin}/web_src/js/index.ts:1:1`)).toBe(true);
vi.restoreAllMocks();
}); });
test('showGlobalErrorMessage', () => { test('showGlobalErrorMessage', () => {
+1
View File
@@ -58,6 +58,7 @@ export function isGiteaError(filename: string, stack: string): boolean {
if (extensionRe.test(filename) || extensionRe.test(stack)) return false; if (extensionRe.test(filename) || extensionRe.test(stack)) return false;
const assetBaseUrl = new URL(`${windowConfig()?.assetUrlPrefix}/`, window.location.origin).href; const assetBaseUrl = new URL(`${windowConfig()?.assetUrlPrefix}/`, window.location.origin).href;
if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false; if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false;
if (!windowConfig()?.runModeIsProd && stack.includes(`${window.location.origin}/web_src/`)) return true;
return !stack || stack.includes(assetBaseUrl); return !stack || stack.includes(assetBaseUrl);
} }