Compare commits

..

20 Commits

Author SHA1 Message Date
silverwind 2f5cdbd5c1 fix(git): reindex go-git storage when a concurrent repack removes packs (#39510)
Improve the go-git workaround to fix these flakes:

- https://github.com/go-gitea/gitea/actions/runs/36721877142/job/109908823684
- https://github.com/go-gitea/gitea/actions/runs/36799665163/job/110170983591
2026-10-01 11:04:41 +00:00
Jon Fuller aae0a218c3 fix(api): add index tiebreaker to commit status ordering (#39508)
Commit status list orders only by `created_unix`/`updated_unix`, which
have 1-second resolution while CI often posts many statuses per second.
With LIMIT/OFFSET paging, databases (e.g. PostgreSQL using a Sort plan)
may order tied rows differently per page, so `GET
/repos/{owner}/{repo}/commits/{ref}/statuses` returns some statuses
twice and never returns others.

This became visible after https://github.com/go-gitea/gitea/pull/36521
made requests without `page` paginated. Clients like Renovate that page
until `X-Total-Count` can miss a context's newest status and see a stale
`pending`, blocking automerge.

Fix: add `index` (unique per commit) as a tiebreaker to the
timestamp-based orders.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 10:43:01 +00:00
wxiaoguang 9b5c87a6b6 fix: trace git command correctly (#39520)
Help  #39410
2026-10-01 10:01:45 +00:00
silverwind 51b93d1d27 enhance(packages/npm): improve npm client compatibility (#39434)
Aligns the npm registry with what npm, pnpm and yarn expect:

1. Raise the publish body cap from
https://github.com/go-gitea/gitea/pull/37890 to 256 MiB like npmjs,
larger bodies get 413
2. Pick the tarball attachment by name, `npm publish --provenance`
failed at random
3. Store and serve `libc`, so mismatched glibc/musl optional binaries
are skipped
4. Treat root `*.gyp` files as an install script, like npm does
5. Always serve a `latest` dist-tag, yarn and pnpm fail without it
6. Take top-level metadata from `latest` and drop the per-version readme
7. Serve tarballs at the npmjs path `/<name>/-/<file>`, former URLs keep
working
8. Add ETag revalidation for metadata, `npm ping` and `npm whoami`

Tested with npm 12.1, pnpm 12.4, yarn 1.22 and yarn 4.18.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-01 07:50:47 +00:00
Roshan Ramani f3aed8b81d docs: fix the default REPOSITORY_AVATAR_FALLBACK_IMAGE path in app.example.ini (#39514)
The example shows `REPOSITORY_AVATAR_FALLBACK_IMAGE =
/img/repo_default.png`, but public files moved under `/assets` in
https://github.com/go-gitea/gitea/pull/15219 and nothing serves `/img/`
anymore. The value is also used as-is without the sub-path, so even
`/assets/img/repo_default.png` 404s when `ROOT_URL` has one. Leave the
key empty and document the real default
`{AppSubURL}/assets/img/repo_default.png` from
`modules/setting/picture.go`.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-01 09:31:10 +02:00
Harsh Sharma fc68608603 fix(oauth2): allow users to approve scope changes (#38942)
Lets users approve an OAuth2 scope change on an existing grant instead
of failing with `a grant exists with different scope`.

- Approving a different scope updates the existing grant. Issued tokens
follow immediately, since their scope is read from the grant.
- Confidential and trusted apps show the consent page when the scope set
changes, instead of silently reusing the old grant.
- An omitted `scope` reuses the existing grant's scope, like GitHub.
- The consent page lists newly added scopes.

Fixes: https://github.com/go-gitea/gitea/issues/38940
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Giteabot <teabot@gitea.io>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 09:08:33 +02:00
wxiaoguang fe31237fd8 fix: handle git branch name with special chars correctly (#39483)
Fix the bugs:
* Commit graph page doesn't show
* PR command line instructions are wrong

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 04:01:16 +00:00
Zettat123 a71c5c94c5 fix(actions): reject jobs without runs-on (#39480)
Align job and `runs-on` validation with github.com, as implemented by
the parser in https://github.com/actions/runner. A job without `runs-on`
could be claimed by any runner, so a job meant for a container could run
on the host.

- Jobs without `runs-on` fail with `Required property is missing:
runs-on`, called workflows included
- Unknown job keys and callers (`uses:`) mixed with steps-only keys like
`runs-on` are rejected
- Empty, null and nested `runs-on` values are rejected
- A `runs-on` evaluating to such a value fails only that job
- Called workflows are validated at run creation, an invalid one fails
the run as an invalid workflow file
- Zero labels (`runs-on: []` or `{}`) never match a runner, including
jobs queued before upgrading

<img width="960" alt="image"
src="https://github.com/user-attachments/assets/e746ce5a-b711-4e8b-aab8-81336ff53d86"
/>

**Behavior Change:** workflows that omit `runs-on`, use unknown job keys
or mix `uses` with `runs-on` stop running until fixed.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 21:42:07 -06:00
GiteaBot f81a2ab69a [skip ci] Updated translations via Crowdin 2026-10-01 01:08:18 +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
98 changed files with 1216 additions and 525 deletions
+18 -19
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)
@@ -836,7 +836,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 +968,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 +1669,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 +1683,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 +1751,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 +2013,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
@@ -2052,7 +2050,8 @@ LEVEL = Info
;; How Gitea deals with missing repository avatars ;; How Gitea deals with missing repository avatars
;; none = no avatar will be displayed; random = random avatar will be displayed; image = default image will be used ;; none = no avatar will be displayed; random = random avatar will be displayed; image = default image will be used
;REPOSITORY_AVATAR_FALLBACK = none ;REPOSITORY_AVATAR_FALLBACK = none
;REPOSITORY_AVATAR_FALLBACK_IMAGE = /img/repo_default.png ;; Image URL for the "image" fallback, used as-is, defaults to Gitea's builtin repository avatar
;REPOSITORY_AVATAR_FALLBACK_IMAGE =
;; ;;
;; Max Width and Height of uploaded avatars. ;; Max Width and Height of uploaded avatars.
;; This is to limit the amount of RAM used when resizing the image. ;; This is to limit the amount of RAM used when resizing the image.
+2 -3
View File
@@ -295,9 +295,8 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
return &run, nil return &run, nil
} }
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) { func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) (runs []*ActionRun, err error) {
var runs []*ActionRun err = db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).OrderBy("id").Find(&runs)
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
return runs, err return runs, err
} }
+1 -1
View File
@@ -200,7 +200,7 @@ func (r *ActionRunner) GenerateAndFillToken() {
// CanMatchLabels checks whether the runner's labels can match a job's "runs-on" // CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on // See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on
func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool { func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool {
return !slices.ContainsFunc(jobRunsOn, func(label string) bool { return !util.SliceContainsString(r.AgentLabels, label, true) }) return len(jobRunsOn) > 0 && !slices.ContainsFunc(jobRunsOn, func(label string) bool { return !util.SliceContainsString(r.AgentLabels, label, true) })
} }
func init() { func init() {
+1
View File
@@ -86,4 +86,5 @@ func TestCanMatchLabelsCaseInsensitive(t *testing.T) {
runner := &ActionRunner{AgentLabels: []string{"self-hosted", "Linux", "X64"}} runner := &ActionRunner{AgentLabels: []string{"self-hosted", "Linux", "X64"}}
assert.True(t, runner.CanMatchLabels([]string{"SELF-HOSTED", "linux"})) assert.True(t, runner.CanMatchLabels([]string{"SELF-HOSTED", "linux"}))
assert.False(t, runner.CanMatchLabels([]string{"linux", "arm64"})) assert.False(t, runner.CanMatchLabels([]string{"linux", "arm64"}))
assert.False(t, runner.CanMatchLabels(nil))
} }
+6
View File
@@ -564,6 +564,12 @@ 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)
+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" return "created_unix ASC, `index` ASC"
case "recentupdate": case "recentupdate":
return "updated_unix DESC" return "updated_unix DESC, `index` DESC"
case "leastupdate": case "leastupdate":
return "updated_unix ASC" return "updated_unix ASC, `index` 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" return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
} }
} }
+7 -20
View File
@@ -32,28 +32,15 @@ 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))
assert.Len(t, statuses, 5) var indexes []int64
for _, status := range statuses {
assert.Equal(t, "ci/awesomeness", statuses[0].Context) indexes = append(indexes, status.Index)
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,
+15
View File
@@ -407,6 +407,21 @@ 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})
+8 -8
View File
@@ -225,7 +225,6 @@ func replaceScalars(node *yaml.Node, replace func(string) string) {
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the // buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
// strategy and interpolating the name, runs-on and continue-on-error with it. // strategy and interpolating the name, runs-on and continue-on-error with it.
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) { func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
srcRunsOn := model.RunsOnFromNode(src.RawRunsOn)
order, names := make([]int, len(matrixes)), make([]string, len(matrixes)) order, names := make([]int, len(matrixes)), make([]string, len(matrixes))
for index, matrix := range matrixes { for index, matrix := range matrixes {
order[index], names[index] = index, matrixName(matrix) order[index], names[index] = index, matrixName(matrix)
@@ -259,14 +258,15 @@ func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx
if err := evaluator.EvaluateYamlNode(&rawRunsOn); err != nil { if err := evaluator.EvaluateYamlNode(&rawRunsOn); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err) return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err)
} }
runsOn := model.RunsOnFromNode(rawRunsOn) if rawRunsOn.Kind != 0 && runsOnProblem(&rawRunsOn) != "" {
if len(runsOn) == 0 && len(srcRunsOn) > 0 { // match no runner rather than every runner combo.RawRunsOn = rawRunsOn
runsOn = []string{""} } else {
runsOn := model.RunsOnFromNode(rawRunsOn)
for i := range runsOn {
runsOn[i] = escapeExpressions(runsOn[i])
}
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
} }
for i := range runsOn {
runsOn[i] = escapeExpressions(runsOn[i])
}
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
} }
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil { if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err) return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
+42 -2
View File
@@ -290,17 +290,26 @@ func TestParseInterpolatesRunName(t *testing.T) {
assert.Empty(t, result[0].RunName) assert.Empty(t, result[0].RunName)
} }
func TestParseRunsOnFromJSONArray(t *testing.T) { func TestParseRunsOnFromJSONKeepsWhatGitHubRejectsForTheJobToFail(t *testing.T) {
content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n") content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n")
_, err := Parse(content) _, err := Parse(content)
require.NoError(t, err) require.NoError(t, err)
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {""}} { for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {}, "{}": {}} {
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner})) result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, result, 1) require.Len(t, result, 1)
_, job := result[0].Job() _, job := result[0].Job()
assert.Equal(t, want, job.RunsOn(), runner) assert.Equal(t, want, job.RunsOn(), runner)
} }
for runner, problem := range map[string]string{`["a"]`: "", `""`: "Unexpected value ''", `[["a"]]`: "A sequence was not expected"} {
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
require.NoError(t, err)
payload, err := result[0].Marshal()
require.NoError(t, err)
_, job, err := ParseRawSingleWorkflow(payload)
require.NoError(t, err)
assert.Equal(t, problem, job.RunsOnProblem(), runner)
}
} }
func TestJobFieldsWithoutMatrix(t *testing.T) { func TestJobFieldsWithoutMatrix(t *testing.T) {
@@ -458,6 +467,37 @@ func TestReadWorkflowJobConditionContexts(t *testing.T) {
} }
} }
func TestValidateWorkflowStaticJobKindAndRunsOnLikeGitHub(t *testing.T) {
for job, want := range map[string]string{
"{runs-on: x, steps: [{run: echo}]}": "",
"{uses: o/r/.gitea/workflows/c.yml@main}": "",
"{runs-on: []}": "",
"{runs-on: {}}": "",
"{runs-on: {group: org/g, labels: [a, 1]}}": "",
"{runs-on: {group: '${{ vars.G }}'}}": "",
"{steps: [{run: echo}]}": "Required property is missing: runs-on",
"{with: {}}": "Required property is missing: uses",
"{runs-on: x, uses: o/r/.gitea/workflows/c.yml@main}": "Unexpected value 'uses'",
"{Runs-On: x}": "Unexpected value 'Runs-On'",
"{runs-on: ~}": "runs-on: Unexpected value ''",
"{runs-on: ['']}": "runs-on: Unexpected value ''",
"{runs-on: [[a]]}": "runs-on: A sequence was not expected",
"{runs-on: {labels: {a: b}}}": "runs-on: A mapping was not expected",
"{runs-on: {foo: x}}": "runs-on: Unexpected value 'foo'",
"{runs-on: {group: org/}}": "runs-on: Invalid runs-on group name 'org/'.",
"{runs-on: {group: a/b/c}}": "runs-on: Invalid runs-on group name 'a/b/c'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.",
"{if: true}": "There's not enough info to determine what you meant. Add one of these properties: " +
"cancel-timeout-minutes, container, continue-on-error, defaults, env, environment, outputs, runs-on, secrets, services, snapshot, steps, timeout-minutes, uses, with",
} {
_, err := ValidateWorkflowStatic([]byte("on: push\njobs:\n build: " + job + "\n"))
if want == "" {
assert.NoError(t, err, job)
} else {
assert.EqualError(t, err, "job build: "+want, job)
}
}
}
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) { func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
for _, filter := range []string{"include", "exclude"} { for _, filter := range []string{"include", "exclude"} {
t.Run(filter, func(t *testing.T) { t.Run(filter, func(t *testing.T) {
+8
View File
@@ -174,6 +174,14 @@ func (j *Job) EraseNeeds() *Job {
return j return j
} }
// RunsOnProblem returns github.com's error for the job's runs-on, "" if valid.
func (j *Job) RunsOnProblem() string {
if j.RawRunsOn.Kind == 0 {
return ""
}
return runsOnProblem(&j.RawRunsOn)
}
// RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName. // RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName.
func (j *Job) RunsOn() []string { func (j *Job) RunsOn() []string {
runsOn := model.RunsOnFromNode(j.RawRunsOn) runsOn := model.RunsOnFromNode(j.RawRunsOn)
+120 -2
View File
@@ -7,10 +7,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
"strings"
"gitea.dev/actionslib/pkg/expreval" "gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"go.yaml.in/yaml/v4"
) )
// jobConditionContexts are what github.com gives `jobs.<job_id>.if`, which it decides before the matrix, plus the `gitea` alias. // jobConditionContexts are what github.com gives `jobs.<job_id>.if`, which it decides before the matrix, plus the `gitea` alias.
@@ -21,7 +24,7 @@ func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Keep unknown and case-distinct keys accepted for existing Gitea workflows. // Keep unknown and case-distinct keys outside of jobs accepted for existing Gitea workflows.
workflow, err := readWorkflowDoc(doc) workflow, err := readWorkflowDoc(doc)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -33,6 +36,9 @@ func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
if err := validateWorkflowStructure(workflow); err != nil { if err := validateWorkflowStructure(workflow); err != nil {
return nil, err return nil, err
} }
if err := validateJobKinds(doc); err != nil {
return nil, err
}
var header struct { var header struct {
RunName string `yaml:"run-name"` RunName string `yaml:"run-name"`
} }
@@ -76,7 +82,6 @@ func validateWorkflowStructure(workflow *model.Workflow) error {
if job == nil { if job == nil {
return fmt.Errorf("job %q has no configuration", id) return fmt.Errorf("job %q has no configuration", id)
} }
// a job without runs-on is accepted and runs on any runner, github.com rejects it
for _, dependency := range job.Needs() { for _, dependency := range job.Needs() {
if _, ok := workflow.Jobs[dependency]; !ok { if _, ok := workflow.Jobs[dependency]; !ok {
return fmt.Errorf("job %q needs unknown job %q", id, dependency) return fmt.Errorf("job %q needs unknown job %q", id, dependency)
@@ -110,3 +115,116 @@ func validateWorkflowStructure(workflow *model.Workflow) error {
} }
return nil return nil
} }
// job keys of github.com's workflow schema, by the kind of job allowing them
var (
stepsJobKeys = []string{"cancel-timeout-minutes", "container", "continue-on-error", "defaults", "env", "environment", "outputs", "runs-on", "services", "snapshot", "steps", "timeout-minutes"}
callerJobKeys = []string{"secrets", "uses", "with"}
sharedJobKeys = []string{"concurrency", "if", "name", "needs", "permissions", "strategy"}
)
// validateJobKinds applies github.com's job kinds, decided by the first kind-specific key.
func validateJobKinds(doc *yaml.Node) error {
jobs := mappingValue(doc.Content[0], "jobs")
for i := 0; i+1 < len(jobs.Content); i += 2 {
id, job := jobs.Content[i].Value, jobs.Content[i+1]
var required string
for j := 0; j+1 < len(job.Content); j += 2 {
key := job.Content[j].Value
isStepsKey, isCallerKey := slices.Contains(stepsJobKeys, key), slices.Contains(callerJobKeys, key)
switch {
case required == "runs-on" && isCallerKey, required == "uses" && isStepsKey, !isStepsKey && !isCallerKey && !slices.Contains(sharedJobKeys, key):
return fmt.Errorf("job %s: Unexpected value '%s'", id, key)
case required == "" && isStepsKey:
required = "runs-on"
case required == "" && isCallerKey:
required = "uses"
}
}
if required == "" {
keys := slices.Concat(stepsJobKeys, callerJobKeys)
slices.Sort(keys)
return fmt.Errorf("job %s: There's not enough info to determine what you meant. Add one of these properties: %s", id, strings.Join(keys, ", "))
}
value := mappingValue(job, required)
if value == nil {
return fmt.Errorf("job %s: Required property is missing: %s", id, required)
}
if required != "runs-on" {
continue
}
if problem := runsOnProblem(value); problem != "" {
return fmt.Errorf("job %s: runs-on: %s", id, problem)
}
}
return nil
}
// runsOnProblem returns github.com's schema error for a runs-on, "" if valid.
func runsOnProblem(node *yaml.Node) string {
if node.Kind != yaml.MappingNode {
return runsOnLabelsProblem(node)
}
for i := 0; i+1 < len(node.Content); i += 2 {
var problem string
switch key := node.Content[i].Value; key {
case "labels":
problem = runsOnLabelsProblem(node.Content[i+1])
case "group":
problem = runsOnGroupProblem(node.Content[i+1])
default:
problem = fmt.Sprintf("Unexpected value '%s'", key)
}
if problem != "" {
return problem
}
}
return ""
}
func runsOnLabelsProblem(node *yaml.Node) string {
if node.Kind != yaml.SequenceNode {
return nonEmptyStringProblem(node)
}
for _, label := range node.Content {
if problem := nonEmptyStringProblem(label); problem != "" {
return problem
}
}
return ""
}
func runsOnGroupProblem(node *yaml.Node) string {
if problem := nonEmptyStringProblem(node); problem != "" || hasExpression(node.Value) {
return problem
}
switch prefix, name, found := strings.Cut(node.Value, "/"); {
case found && name == "":
return fmt.Sprintf("Invalid runs-on group name '%s'.", node.Value)
case found && (strings.Contains(name, "/") || !slices.Contains([]string{"org", "organization", "ent", "enterprise"}, prefix)):
return fmt.Sprintf("Invalid runs-on group name '%s'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.", node.Value)
}
return ""
}
// nonEmptyStringProblem mirrors github.com's non-empty-string, which also accepts non-string scalars.
func nonEmptyStringProblem(node *yaml.Node) string {
switch {
case node.Kind == yaml.SequenceNode:
return "A sequence was not expected"
case node.Kind == yaml.MappingNode:
return "A mapping was not expected"
case node.Value == "" || node.ShortTag() == "!!null":
return "Unexpected value ''"
}
return ""
}
func mappingValue(node *yaml.Node, key string) *yaml.Node {
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == key {
return node.Content[i+1]
}
}
return nil
}
+12 -7
View File
@@ -30,18 +30,23 @@ jobs:
func TestReadWorkflowEventsStaticErrors(t *testing.T) { func TestReadWorkflowEventsStaticErrors(t *testing.T) {
for content, static := range map[string]bool{ for content, static := range map[string]bool{
"on: push\njobs: {}": true, "on: push\njobs: {}": true,
"on: push\njobs: {test: {needs: absent}}": true, "on: push\njobs: {test: {runs-on: x, needs: absent}}": true,
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true, "on: push\njobs: {one: {runs-on: x, needs: two}, two: {runs-on: x, needs: one}}": true,
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true, "on: push\njobs: {test: {runs-on: x, strategy: {matrix: {os: []}}}}": true,
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true, "on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {runs-on: x}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false, "on: push\njobs: {test: {steps: [{run: echo}]}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {runs-on: x, steps: [{run: echo}]}}": false,
} { } {
_, gotStatic, err := readWorkflowEvents([]byte(content)) _, gotStatic, err := readWorkflowEvents([]byte(content))
require.Error(t, err, content) require.Error(t, err, content)
assert.Equal(t, static, gotStatic, content) assert.Equal(t, static, gotStatic, content)
} }
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} { for _, content := range []string{
"on: push\njobs: {test: {runs-on: x, steps: [{run: echo}]}}",
"on: push\nrun-name: ${{ github.ref }}\njobs: {test: {runs-on: x}}",
"on: push\njobs: {call: {uses: ./.gitea/workflows/called.yml}}",
} {
_, _, err := readWorkflowEvents([]byte(content)) _, _, err := readWorkflowEvents([]byte(content))
assert.NoError(t, err, content) assert.NoError(t, err, content)
} }
+40 -1
View File
@@ -8,11 +8,13 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"strings"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/translation" "gitea.dev/modules/translation"
"gitea.dev/modules/util"
) )
type htmlChunkReader struct { type htmlChunkReader struct {
@@ -30,6 +32,10 @@ type escapeStreamer struct {
ambiguousTables []*AmbiguousTable ambiguousTables []*AmbiguousTable
allowed map[rune]bool allowed map[rune]bool
tagPartial []byte // partial tag content, used to detect if we are in some tags
inTagMath bool // MathML operators like U+2212 are intended and wrapping them breaks the math layout
out io.Writer out io.Writer
} }
@@ -62,6 +68,7 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
for i, part := range parts { for i, part := range parts {
if partInTag[i] { if partInTag[i] {
lastIsTag = true lastIsTag = true
es.trackHtmlTag(part)
if _, err := out.Write(part); err != nil { if _, err := out.Write(part); err != nil {
return nil, err return nil, err
} }
@@ -75,7 +82,11 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
return nil, err return nil, err
} }
} }
if err = es.detectAndWriteRunes(part); err != nil { if es.inTagMath {
if _, err := out.Write(part); err != nil {
return nil, err
}
} else if err = es.detectAndWriteRunes(part); err != nil {
return nil, err return nil, err
} }
} }
@@ -83,6 +94,34 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
} }
} }
// trackHtmlTag receives tag parts, a tag might be split into multiple parts
func (e *escapeStreamer) trackHtmlTag(part []byte) {
const maxHeadLen = 100 // only read the first N bytes of the tag for detection purpose
if part[0] == '<' {
// start a new tag
e.tagPartial = e.tagPartial[:0]
}
if len(e.tagPartial) >= maxHeadLen {
return
}
e.tagPartial = append(e.tagPartial, part[:min(len(part), maxHeadLen-len(e.tagPartial))]...)
isTag := func(prefix string) bool {
if len(e.tagPartial) < len(prefix)+1 {
return false
}
if !util.AsciiEqualFold(e.tagPartial[:len(prefix)], []byte(prefix)) {
return false
}
return strings.IndexByte(" \t\n\r\f>", e.tagPartial[len(prefix)]) != -1
}
if isTag("<math") {
e.inTagMath = true
} else if isTag("</math") {
e.inTagMath = false
}
}
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) { func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom) remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
if ok { if ok {
+24
View File
@@ -141,6 +141,12 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`, result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true}, status: EscapeStatus{Escaped: true, HasAmbiguous: true},
}, },
{
name: "ambiguous in math",
text: "<math><mo>−</mo><mi>b</mi></math> −",
result: `<math><mo>−</mo><mi>b</mi></math> <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:− [U+2212],- [U+002D]"><span class="char">−</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
},
} }
func TestEscapeControlReader(t *testing.T) { func TestEscapeControlReader(t *testing.T) {
@@ -156,6 +162,24 @@ func TestEscapeControlReader(t *testing.T) {
} }
} }
func TestTrackHtmlTag(t *testing.T) {
e := &escapeStreamer{}
for _, tt := range []struct {
parts []string
inMath bool
}{
{[]string{"<ma", `TH display="block">`}, true},
{[]string{"<mo>"}, true},
{[]string{"</MA", "th>"}, false},
{[]string{"<mathx>"}, false},
} {
for _, part := range tt.parts {
e.trackHtmlTag([]byte(part))
}
assert.Equal(t, tt.inMath, e.inTagMath, "%v", tt.parts)
}
}
func TestSettingAmbiguousUnicodeDetection(t *testing.T) { func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)() defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
_, out := EscapeControlHTML("a test", &translation.MockLocale{}) _, out := EscapeControlHTML("a test", &translation.MockLocale{})
+10 -5
View File
@@ -49,8 +49,8 @@ type Command struct {
cmd *process.Cmd cmd *process.Cmd
cmdCtx context.Context cmdCtx context.Context
cmdCancel process.CancelCauseFunc cmdCtxCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc cmdFinished func()
cmdStartTime time.Time cmdStartTime time.Time
pipelineFunc func(Context) error pipelineFunc func(Context) error
@@ -428,19 +428,24 @@ 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.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc) c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
} else { } else {
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc) c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = 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.cmdCancel(pipelineError{err}) c.cmd.cmdCtxCancel(pipelineError{err})
return err return err
} }
+4 -3
View File
@@ -20,6 +20,7 @@ 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
@@ -31,8 +32,8 @@ type Repository struct {
gogitStorage *reindexingStorage gogitStorage *reindexingStorage
} }
// reindexingStorage picks up packs that git wrote after go-git loaded its index // reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it
// https://github.com/go-git/go-git/issues/2439 // https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623
type reindexingStorage struct { type reindexingStorage struct {
*filesystem.Storage *filesystem.Storage
packs []plumbing.Hash packs []plumbing.Hash
@@ -40,7 +41,7 @@ type reindexingStorage struct {
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) { func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
obj, err := s.Storage.EncodedObject(t, h) obj, err := s.Storage.EncodedObject(t, h)
if !errors.Is(err, plumbing.ErrObjectNotFound) { if !errors.Is(err, plumbing.ErrObjectNotFound) && !errors.Is(err, dotgit.ErrPackfileNotFound) {
return obj, err return obj, err
} }
packs, _ := s.ObjectPacks() packs, _ := s.ObjectPacks()
+22
View File
@@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -39,6 +41,26 @@ func TestRepository_GetBranches(t *testing.T) {
assert.ElementsMatch(t, []string{}, branches) assert.ElementsMatch(t, []string{}, branches)
} }
func TestGetBranchNamesAfterRepack(t *testing.T) {
repoDir := t.TempDir()
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
for _, from := range []string{"", "from refs/heads/main^0\n"} {
stdin := "commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n" + from
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"))
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
require.NoError(t, err)
assert.Equal(t, []string{"main"}, branches)
}
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,6 +122,8 @@ 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
+7 -4
View File
@@ -6,14 +6,17 @@ 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 (
TraceAttrFuncCaller = "func.caller" TraceAttrGeneralName = "general.name"
TraceAttrDbSQL = "db.sql" TraceAttrGeneralDesc = "general.desc"
TraceAttrGitCommand = "git.command" TraceAttrFuncCaller = "func.caller"
TraceAttrHTTPRoute = "http.route" TraceAttrDbSQL = "db.sql"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
) )
+4
View File
@@ -45,6 +45,10 @@ 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)
} }
+2 -2
View File
@@ -349,8 +349,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span" // TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
// if we don't stop it, it will go into the TextNode again and create an infinite recursion // if we don't stop it, it will go into the TextNode again and create an infinite recursion
return node.NextSibling return node.NextSibling
} else if node.Data == "code" || node.Data == "pre" { } else if node.Data == "code" || node.Data == "pre" || node.Data == "math" {
return node.NextSibling // ignore code and pre nodes return node.NextSibling // ignore code, pre and math nodes
} else if node.Data == "img" { } else if node.Data == "img" {
return visitNodeImg(ctx, node) return visitNodeImg(ctx, node)
} else if node.Data == "video" { } else if node.Data == "video" {
+3
View File
@@ -543,6 +543,9 @@ func TestPostProcess(t *testing.T) {
`Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`) `Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`)
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234", test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`) `<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
test(
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>",
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>")
// special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible // special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible
test("<script>a", `&lt;script&gt;a`) test("<script>a", `&lt;script&gt;a`)
+23 -51
View File
@@ -121,6 +121,7 @@ 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"`
@@ -129,12 +130,9 @@ 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 {
@@ -226,7 +224,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 ParsePackage). // in which case the command is named after the package (resolved in parseUploadPackage).
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.
@@ -264,7 +262,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(io.LimitReader(r, 10*1024*1024)) body, err := io.ReadAll(r)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -280,16 +278,6 @@ 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 {
@@ -343,6 +331,7 @@ 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,
@@ -356,12 +345,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 := func() *PackageAttachment { attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
if attachment == nil && len(upload.Attachments) == 1 {
for _, a := range upload.Attachments { for _, a := range upload.Attachments {
return a attachment = a
} }
return nil }
}()
if attachment == nil || len(attachment.Data) == 0 { if attachment == nil || len(attachment.Data) == 0 {
return nil, ErrInvalidAttachment return nil, ErrInvalidAttachment
} }
@@ -393,8 +382,6 @@ 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
@@ -410,11 +397,7 @@ 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 reports hasShrinkwrap (presence of package/npm-shrinkwrap.json) // inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
// 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 {
@@ -422,11 +405,16 @@ 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 {
return hasShrinkwrap, hasInstallScript break
} }
// 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, "./")
@@ -436,30 +424,14 @@ 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"):
hasInstallScript = tarballDeclaresInstallScript(tr) _ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
}
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 {
+24 -54
View File
@@ -41,21 +41,20 @@ 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 := ParsePackage(bytes.NewReader([]byte{0})) p, _, err := ParseUpload(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) {
b, _ := json.Marshal(packageUpload{}) p, err := parseUploadPackage(&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) {
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -66,8 +65,6 @@ 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)
} }
@@ -94,7 +91,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) {
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: name, ID: name,
Name: name, Name: name,
@@ -105,8 +102,6 @@ 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)
} }
@@ -125,7 +120,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"
b, _ := json.Marshal(packageUpload{ p, err := parseUploadPackage(&packageUpload{
PackageMetadata: PackageMetadata{ PackageMetadata: PackageMetadata{
ID: packageFullName, ID: packageFullName,
Name: packageFullName, Name: packageFullName,
@@ -137,8 +132,6 @@ 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)
}) })
@@ -160,7 +153,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -185,7 +178,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidAttachment) assert.ErrorIs(t, err, ErrInvalidAttachment)
}) })
@@ -213,7 +206,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -241,7 +234,7 @@ func TestParsePackage(t *testing.T) {
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.Nil(t, p) assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidIntegrity) assert.ErrorIs(t, err, ErrInvalidIntegrity)
}) })
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
filename: { filename: {
Data: data, Data: data,
}, },
packageFullName + "-" + packageVersion + ".sigstore": {
Data: "{}",
},
}, },
}) })
p, err := ParsePackage(bytes.NewReader(b)) p, _, err := ParseUpload(bytes.NewReader(b))
assert.NotNil(t, p) assert.NotNil(t, p)
assert.NoError(t, err) assert.NoError(t, err)
@@ -329,7 +325,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, err := ParsePackage(strings.NewReader(packageJSON)) p, _, err := ParseUpload(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))
}) })
@@ -354,7 +350,7 @@ func TestParsePackage(t *testing.T) {
} }
} }
}` }`
p, err := ParsePackage(strings.NewReader(packageJSON)) p, _, err := ParseUpload(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
@@ -426,6 +422,15 @@ 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) {
@@ -460,41 +465,6 @@ 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,6 +29,7 @@ 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"`
+2 -1
View File
@@ -36,6 +36,7 @@ import "strings"
const ( const (
tildePrefix = '~' tildePrefix = '~'
commentPrefix = '#'
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\" needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
needsSingleQuote = "!\n" needsSingleQuote = "!\n"
) )
@@ -74,7 +75,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) { if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix {
return toEscape return toEscape
} }
+4
View File
@@ -75,6 +75,10 @@ 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 -1
View File
@@ -121,7 +121,7 @@ func asciiLower(b byte) byte {
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go // AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold] // ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
func AsciiEqualFold(s, t string) bool { func AsciiEqualFold[T string | []byte](s, t T) bool {
if len(s) != len(t) { if len(s) != len(t) {
return false return false
} }
+14 -2
View File
@@ -5,6 +5,7 @@ package web
import ( import (
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -19,13 +20,17 @@ type RouterPathGroup struct {
r *Router r *Router
pathParam string pathParam string
matchers []*routerPathMatcher matchers []*routerPathMatcher
unescape bool
} }
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) { func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
chiCtx := chi.RouteContext(req.Context()) chiCtx := chi.RouteContext(req.Context())
path := chiCtx.URLParam(g.pathParam) path := chiCtx.URLParam(g.pathParam)
if g.unescape {
path, _ = url.PathUnescape(path)
}
for _, m := range g.matchers { for _, m := range g.matchers {
if m.matchPath(chiCtx, path) { if m.matchPath(chiCtx, path, g.unescape) {
chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern) chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern)
executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc) executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc)
return return
@@ -53,6 +58,10 @@ func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupP
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...)) g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
} }
func (g *RouterPathGroup) UseUnescapedPath() {
g.unescape = true
}
type routerPathParam struct { type routerPathParam struct {
name string name string
pathSepEnd bool pathSepEnd bool
@@ -68,7 +77,7 @@ type routerPathMatcher struct {
handlerFunc http.HandlerFunc handlerFunc http.HandlerFunc
} }
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool { func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string, unescaped bool) bool {
if !p.methods.Contains(chiCtx.RouteMethod) { if !p.methods.Contains(chiCtx.RouteMethod) {
return false return false
} }
@@ -102,6 +111,9 @@ func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
if p.params[i].pathSepEnd { if p.params[i].pathSepEnd {
val = strings.TrimSuffix(val, "/") val = strings.TrimSuffix(val, "/")
} }
if unescaped {
val = url.PathEscape(val)
}
chiCtx.URLParams.Add(p.params[i].name, val) chiCtx.URLParams.Add(p.params[i].name, val)
} }
return true return true
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -97,12 +98,16 @@ func (r *testRecorder) test(t *testing.T, rt *Router, methodPath string, expecte
} }
func TestPathProcessor(t *testing.T) { func TestPathProcessor(t *testing.T) {
unescape := false
testProcess := func(pattern, uri string, expectedPathParams map[string]string) { testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext() chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET" chiCtx.RouteMethod = "GET"
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound) p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
shouldProcess := expectedPathParams != nil shouldProcess := expectedPathParams != nil
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) if unescape {
uri, _ = url.PathUnescape(uri)
}
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri, unescape), "use pattern %s to process uri %s", pattern, uri)
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
} }
@@ -119,6 +124,9 @@ func TestPathProcessor(t *testing.T) {
testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"}) testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"})
testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil) testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil)
testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"}) testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"})
unescape = true
testProcess("/<p1:@/x>", "/%40%2fx", map[string]string{"p1": "@%2Fx"})
} }
func TestRouter(t *testing.T) { func TestRouter(t *testing.T) {
+1
View File
@@ -433,6 +433,7 @@
"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.",
+13
View File
@@ -277,7 +277,10 @@
"install.domain_helper": "服务器的域名或主机地址。", "install.domain_helper": "服务器的域名或主机地址。",
"install.ssh_port": "SSH 服务端口", "install.ssh_port": "SSH 服务端口",
"install.ssh_port_helper": "SSH 服务器的端口号,为空则禁用它。", "install.ssh_port_helper": "SSH 服务器的端口号,为空则禁用它。",
"install.http_port": "HTTP 服务端口",
"install.http_port_helper": "Gitea Web 服务器将侦听的端口号。", "install.http_port_helper": "Gitea Web 服务器将侦听的端口号。",
"install.app_url": "Gitea 网站 URL",
"install.app_url_helper": "Gitea Web 应用程序用于 HTTP(S) 访问、克隆 URL 及电子邮件通知的公共 URL。",
"install.optional_title": "可选设置", "install.optional_title": "可选设置",
"install.email_title": "电子邮箱设置", "install.email_title": "电子邮箱设置",
"install.smtp_addr": "SMTP 主机地址", "install.smtp_addr": "SMTP 主机地址",
@@ -290,8 +293,11 @@
"install.register_confirm": "需要邮件确认注册", "install.register_confirm": "需要邮件确认注册",
"install.mail_notify": "启用邮件通知提醒", "install.mail_notify": "启用邮件通知提醒",
"install.server_service_title": "服务器和第三方服务设置", "install.server_service_title": "服务器和第三方服务设置",
"install.disable_registration": "只有管理员可以创建用户帐户(禁止自助注册)",
"install.enable_captcha": "启用注册验证码", "install.enable_captcha": "启用注册验证码",
"install.enable_captcha_popup": "要求在用户注册时输入预验证码", "install.enable_captcha_popup": "要求在用户注册时输入预验证码",
"install.require_sign_in_view": "需要登录才能查看页面(推荐用于私有实例)",
"install.require_sign_in_view_popup": "仅允许已登录用户访问页面。访客只能看到注册和登录页。",
"install.admin_setting_desc": "创建管理员帐户是可选的。第一个注册用户将自动成为管理员。", "install.admin_setting_desc": "创建管理员帐户是可选的。第一个注册用户将自动成为管理员。",
"install.admin_title": "管理员帐号设置", "install.admin_title": "管理员帐号设置",
"install.admin_name": "管理员用户名", "install.admin_name": "管理员用户名",
@@ -314,6 +320,7 @@
"install.default_allow_create_organization_popup": "默认情况下,允许新用户帐户创建组织。", "install.default_allow_create_organization_popup": "默认情况下,允许新用户帐户创建组织。",
"install.no_reply_address": "隐藏邮件域", "install.no_reply_address": "隐藏邮件域",
"install.no_reply_address_helper": "具有隐藏邮箱地址的用户的域名。例如,如果隐藏邮箱域名设置为「noreply.example.org」,那么用户名「joe」在 Git 中将显示为「joe@noreply.example.org」。", "install.no_reply_address_helper": "具有隐藏邮箱地址的用户的域名。例如,如果隐藏邮箱域名设置为「noreply.example.org」,那么用户名「joe」在 Git 中将显示为「joe@noreply.example.org」。",
"install.enable_update_checker": "启用更新检查",
"install.env_config_keys": "环境配置", "install.env_config_keys": "环境配置",
"install.env_config_keys_prompt": "以下环境变量也将应用于您的配置文件:", "install.env_config_keys_prompt": "以下环境变量也将应用于您的配置文件:",
"install.config_write_file_prompt": "这些配置选项将写入以下位置: %s", "install.config_write_file_prompt": "这些配置选项将写入以下位置: %s",
@@ -1091,6 +1098,7 @@
"repo.migrate.github_token_desc": "您可以在此处输入一个或多个令牌(以逗号分隔),以绕过 GitHub API 速率限制来加快迁移速度。警告:滥用此功能可能会违反服务提供商的政策并导致帐户被封禁。", "repo.migrate.github_token_desc": "您可以在此处输入一个或多个令牌(以逗号分隔),以绕过 GitHub API 速率限制来加快迁移速度。警告:滥用此功能可能会违反服务提供商的政策并导致帐户被封禁。",
"repo.migrate.clone_local_path": "或服务器本地路径", "repo.migrate.clone_local_path": "或服务器本地路径",
"repo.migrate.permission_denied": "您没有获得导入本地仓库的权限。", "repo.migrate.permission_denied": "您没有获得导入本地仓库的权限。",
"repo.migrate.permission_denied_blocked": "您不能从不允许的主机导入。请询问管理员以检查 [migrations] 部分中的ALLOWED_HOST_LIST/BLOCKED_HOST_LIST 设置。",
"repo.migrate.invalid_local_path": "本地路径无效。它不存在或不是一个目录。", "repo.migrate.invalid_local_path": "本地路径无效。它不存在或不是一个目录。",
"repo.migrate.invalid_lfs_endpoint": "LFS 网址无效。", "repo.migrate.invalid_lfs_endpoint": "LFS 网址无效。",
"repo.migrate.failed": "迁移失败:%v", "repo.migrate.failed": "迁移失败:%v",
@@ -3896,6 +3904,11 @@
"actions.workflow.has_no_workflow_dispatch": "工作流「%s」没有 workflow_dispatch 事件触发器。", "actions.workflow.has_no_workflow_dispatch": "工作流「%s」没有 workflow_dispatch 事件触发器。",
"actions.need_approval_desc": "该工作流由派生仓库的合并请求所触发,需要批准方可运行。", "actions.need_approval_desc": "该工作流由派生仓库的合并请求所触发,需要批准方可运行。",
"actions.approve_all_success": "所有工作流运行已成功批准。", "actions.approve_all_success": "所有工作流运行已成功批准。",
"actions.job_queue.title": "任务队列",
"actions.job_queue.runs_on": "运行于",
"actions.job_queue.waiting_or_started": "等待 / 已开始",
"actions.job_queue.no_jobs": "没有正在运行或等待处理的任务。",
"actions.job_queue.filter_owner_no_select": "所有所有者",
"actions.job_queue.filter_repo_no_select": "所有仓库", "actions.job_queue.filter_repo_no_select": "所有仓库",
"actions.variables": "变量", "actions.variables": "变量",
"actions.variables.management": "变量管理", "actions.variables.management": "变量管理",
+20 -30
View File
@@ -405,37 +405,27 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
}) })
r.Group("/npm", func() { r.Group("/npm", func() {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details r.Get("/-/v1/search", npm.PackageSearch)
scopeRegexp := `^@` + npm_module.RegexpNamePart + `$` r.Get("/-/ping", npm.Ping)
idRegexp := `^(@` + npm_module.RegexpNamePart + `%2[fF])?` + npm_module.RegexpNamePart + `$` r.Get("/-/whoami", npm.Whoami)
addPackageHandlers := func() { r.PathGroup("/*", func(g *web.RouterPathGroup) {
r.Get("", npm.PackageMetadata) // HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage) packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
r.Get("/{version}", npm.PackageVersionMetadata) g.UseUnescapedPath()
r.Group("/-/{version}/{filename}", func() { g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
r.Get("", npm.DownloadPackageFile) g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
}) g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
r.Get("/-/{filename}", npm.DownloadPackageFileByName) g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
r.Group("/-rev/{revision}", func() { g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
r.Delete("", npm.DeletePackage) g.MatchPath("GET", packageId+"/<version>", npm.PackageVersionMetadata)
r.Put("", npm.DeletePreview) g.MatchPath("GET", packageId, npm.PackageMetadata)
}, reqPackageAccess(perm.AccessModeWrite)) g.MatchPath("PUT", packageId, reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
}
r.Group("/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}", addPackageHandlers)
r.Group("/{id:"+idRegexp+"}", addPackageHandlers)
addPackageDistTagsHandlers := func() { packageDistTags := "/-/package" + packageId + "/dist-tags"
r.Get("", npm.ListPackageTags) g.MatchPath("GET", packageDistTags, npm.ListPackageTags)
r.Group("/{tag}", func() { g.MatchPath("PUT", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.AddPackageTag)
r.Put("", npm.AddPackageTag) g.MatchPath("DELETE", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageTag)
r.Delete("", npm.DeletePackageTag)
}, reqPackageAccess(perm.AccessModeWrite))
}
r.Group("/-/package/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/package/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/v1/search", func() {
r.Get("", npm.PackageSearch)
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/pub", func() { r.Group("/pub", func() {
+17 -4
View File
@@ -8,7 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url" "slices"
"sort" "sort"
"time" "time"
@@ -25,6 +25,7 @@ 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)
@@ -35,6 +36,9 @@ 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
}
} }
} }
} }
@@ -43,7 +47,16 @@ 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)
latest := pds[len(pds)-1] if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted
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)
@@ -86,13 +99,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,
@@ -100,7 +113,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/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)), 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
}, },
} }
} }
+11 -6
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{Keywords: []string{"gitea"}, Repository: repo}, Metadata: &npm_module.Metadata{Readme: v, 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,21 +35,26 @@ 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(),
"created": time.Unix(1000, 0).UTC(), "2.0.0-rc.1": time.Unix(1500, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(), "created": time.Unix(1000, 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%2Ftest/-/1.0.0/test-1.0.0.tgz", "https://gitea.dev/api/packages/alice/npm/@scope/test/-/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)
+90 -104
View File
@@ -6,6 +6,7 @@ package npm
import ( import (
"bytes" "bytes"
std_ctx "context" std_ctx "context"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -44,49 +45,53 @@ func apiError(ctx *context.Context, status int, obj any) {
// packageNameFromParams gets the package name from the url parameters // packageNameFromParams gets the package name from the url parameters
func packageNameFromParams(ctx *context.Context) string { func packageNameFromParams(ctx *context.Context) string {
// Real examples: these 2 both should work: // HINT: NPM-ROUTE-PATH-PATTERN: real examples: these cases all should work:
// * "https://registry.npmjs.org/@angular/core" // * "https://registry.npmjs.org/@angular/core"
// * "https://registry.npmjs.org/@angular%2Fcore" // * "https://registry.npmjs.org/@angular%2Fcore"
// * "https://registry.npmjs.org/%40angular%2Fcore"
// //
// HINT: NPM-ROUTE-PATH-PATTERN: The cases for the path parameters: return ctx.PathParam("id") // id is the full package name, e.g.: "@angular/core" or "lodash"
// * ".../TheName/...": id="TheName"
// * ".../@TheScope/TheName/...": scope="@TheScope", id="TheName"
// * ".../@TheScope%2FTheName/...": id="@TheScope/TheName"
scope := ctx.PathParam("scope")
fullOrSub := ctx.PathParam("id") // may be a full name or a subpath of the full package name
if scope != "" {
// now id is the subpath of the full package name, e.g. "core" in "@angular/core"
return fmt.Sprintf("%s/%s", scope, fullOrSub)
}
return fullOrSub // id is the full package name, e.g.: "@angular/core" or "lodash"
} }
func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string { 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"
} }
// PackageMetadata returns the metadata for a single package func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
func PackageMetadata(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 nil
} }
if len(pvs) == 0 { if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, err) apiError(ctx, http.StatusNotFound, err)
return return nil
} }
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return nil
} }
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds) return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
ctx.JSON(http.StatusOK, resp) }
// 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 {
apiError(ctx, http.StatusInternalServerError, err)
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"})
} }
// PackageVersionMetadata returns the metadata for a single version or dist-tag // PackageVersionMetadata returns the metadata for a single version or dist-tag
@@ -110,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
if len(pvs) == 0 { if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag) if versionOrTag != "latest" {
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
} }
@@ -120,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
return return
} }
ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd)) serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
} }
// DownloadPackageFile serves the content of a package func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
func DownloadPackageFile(ctx *context.Context) { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
packageName := packageNameFromParams(ctx) OwnerID: ctx.Package.Owner.ID,
packageVersion := ctx.PathParam("version") Type: packages_model.TypeNpm,
filename := ctx.PathParam("filename") Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)},
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]
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( // DownloadPackageFileByName finds the version and serves the contents of a package
func DownloadPackageFileByName(ctx *context.Context) {
pv := packageVersionByFilename(ctx)
if pv == nil {
return
}
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx, ctx,
&packages_service.PackageInfo{ pv,
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: packageName,
Version: packageVersion,
},
&packages_service.PackageFileInfo{ &packages_service.PackageFileInfo{
Filename: filename, Filename: ctx.PathParam("filename"),
}, },
ctx.Req.Method, ctx.Req.Method,
) )
@@ -150,54 +174,14 @@ func DownloadPackageFile(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) {
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body) // about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
if err != nil { if err != nil {
if errors.Is(err, util.ErrInvalidArgument) { if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
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)
@@ -350,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
} }
// DeletePackageVersion deletes the package version // DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball
func DeletePackageVersion(ctx *context.Context) { func DeletePackageVersion(ctx *context.Context) {
packageName := packageNameFromParams(ctx) pv := packageVersionByFilename(ctx)
packageVersion := ctx.PathParam("version") if pv == nil {
return
}
err := packages_service.RemovePackageVersionByNameAndVersion( if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
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
} }
@@ -404,9 +376,7 @@ 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) {
packageName := packageNameFromParams(ctx) pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, 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
@@ -424,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
} }
} }
ctx.JSON(http.StatusOK, tags) if _, ok := tags["latest"]; ok {
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
@@ -534,6 +508,18 @@ 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,
+3 -3
View File
@@ -162,7 +162,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// if it's not a pointer, just serve the data directly // if it's not a pointer, just serve the data directly
if !pointer.IsValid() { if !pointer.IsValid() {
_, _ = ctx.Resp.Write(lfsPointerBuf) httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return return
} }
@@ -171,7 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// If there isn't one, just serve the data directly // If there isn't one, just serve the data directly
if errors.Is(err, git_model.ErrLFSObjectNotExist) { if errors.Is(err, git_model.ErrLFSObjectNotExist) {
_, _ = ctx.Resp.Write(lfsPointerBuf) httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return return
} else if err != nil { } else if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -198,7 +198,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
return return
} }
defer lfsDataFile.Close() defer lfsDataFile.Close()
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath}) httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: blob.Name()})
} }
func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) { func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
+2 -2
View File
@@ -386,8 +386,8 @@ func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_mo
ctx.APIErrorNotFound("no such attachment in repo") ctx.APIErrorNotFound("no such attachment in repo")
return false return false
} }
if attachment.IssueID == 0 { if attachment.IssueID == 0 || attachment.CommentID != 0 {
log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID) log.Debug("Requested attachment[%d] is not an issue attachment.", attachment.ID)
ctx.APIErrorNotFound("no such attachment in issue") ctx.APIErrorNotFound("no such attachment in issue")
return false return false
} else if issue != nil && attachment.IssueID != issue.ID { } else if issue != nil && attachment.IssueID != issue.ID {
+6 -1
View File
@@ -291,6 +291,11 @@ func AddPushMirror(ctx *context.APIContext) {
return return
} }
if setting.Mirror.DisableNewPush {
ctx.APIError(http.StatusForbidden, "the site administrator has disabled the creation of new push mirrors")
return
}
pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx) pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx)
CreatePushMirror(ctx, pushMirror) CreatePushMirror(ctx, pushMirror)
} }
@@ -356,7 +361,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
if err == nil { if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
} }
if err != nil { if err != nil {
HandleRemoteAddressError(ctx, err) HandleRemoteAddressError(ctx, err)
+23
View File
@@ -10,13 +10,36 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
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/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCreatePushMirrorUsesCallerPermission(t *testing.T) {
defer test.MockVariableValue(&setting.ImportLocalPaths, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
ctx.Doer = &user_model.User{}
ctx.ContextUser = &user_model.User{AllowImportLocal: true}
CreatePushMirror(ctx, &api.CreatePushMirrorOption{RemoteAddress: "local-mirror", Interval: "0"})
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
func TestAddPushMirrorDisabled(t *testing.T) {
defer test.MockVariableValue(&setting.Mirror.DisableNewPush, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
AddPushMirror(ctx)
assert.Equal(t, http.StatusForbidden, resp.Code)
assert.Contains(t, resp.Body.String(), "the site administrator has disabled the creation of new push mirrors")
}
// TestPushMirrorSync verifies the endpoint attempts every push mirror instead // TestPushMirrorSync verifies the endpoint attempts every push mirror instead
// of aborting on the first failure, reporting all failed remotes with a 422. // of aborting on the first failure, reporting all failed remotes with a 422.
// Each remote name is not a configured git remote, so SyncPushMirror fails fast // Each remote name is not a configured git remote, so SyncPushMirror fails fast
+1 -1
View File
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
} }
} }
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(ctx, 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 {
+2 -1
View File
@@ -139,7 +139,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
// The repo is empty and being initialized by this push, so there is no // The repo is empty and being initialized by this push, so there is no
// dependent state (webhooks, notifications, visibility fan-out) to reconcile // dependent state (webhooks, notifications, visibility fan-out) to reconcile
// yet; setting the flags directly is sufficient in this push-to-create case. // yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() { if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() &&
(isPrivate.Value() || !setting.Repository.ForcePrivate || ctx.Doer.IsAdmin) {
repo.IsPrivate = isPrivate.Value() repo.IsPrivate = isPrivate.Value()
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err) log.Error("failed to update repo is_private: %v", err)
+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)
} }
+18 -8
View File
@@ -11,6 +11,7 @@ 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"
@@ -20,6 +21,7 @@ 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"
@@ -321,9 +323,18 @@ 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 { if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged {
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)
@@ -347,6 +358,7 @@ 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
@@ -432,12 +444,10 @@ 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 {
handleAuthorizeError(ctx, AuthorizeError{ if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
State: form.State, handleServerError(ctx, form.State, form.RedirectURI)
ErrorDescription: "a grant exists with different scope", return
ErrorCode: ErrorCodeServerError, }
}, form.RedirectURI)
return
} }
if len(form.Nonce) > 0 { if len(form.Nonce) > 0 {
@@ -576,7 +586,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server
} }
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey) token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil { if err != nil || token.Kind != oauth2_provider.KindRefreshToken {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient, ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token", ErrorDescription: "unable to parse refresh token",
+28
View File
@@ -13,6 +13,10 @@ 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"
@@ -105,3 +109,27 @@ 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"})
}
+10 -1
View File
@@ -9,7 +9,9 @@ import (
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
"gitea.dev/models/organization" "gitea.dev/models/organization"
"gitea.dev/models/renderhelper" "gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/services/context" "gitea.dev/services/context"
feed_service "gitea.dev/services/feed" feed_service "gitea.dev/services/feed"
@@ -28,8 +30,15 @@ func ShowUserFeedAtom(ctx *context.Context) {
// showUserFeed show user activity as RSS / Atom feed // showUserFeed show user activity as RSS / Atom feed
func showUserFeed(ctx *context.Context, formatType string) { func showUserFeed(ctx *context.Context, formatType string) {
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
isOrganisation := ctx.ContextUser.IsOrganization() isOrganisation := ctx.ContextUser.IsOrganization()
if !setting.Other.EnableFeed ||
isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if ctx.IsSigned && isOrganisation && !includePrivate { if ctx.IsSigned && isOrganisation && !includePrivate {
// When feed is requested by a member of the organization, // When feed is requested by a member of the organization,
// include the private repo's the member has access to. // include the private repo's the member has access to.
+10 -2
View File
@@ -569,7 +569,7 @@ func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
break break
} }
} }
if job.Status.IsWaiting() { if job.Status.IsWaiting() && !job.IsReusableCaller {
hasOnlineRunner := false hasOnlineRunner := false
for _, runner := range runners { for _, runner := range runners {
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
@@ -642,7 +642,15 @@ func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) b
ctx.ServerError("GetRunsByRepoAndID", err) ctx.ServerError("GetRunsByRepoAndID", err)
return false return false
} }
data.ActionRuns = runs runsMap := make(map[int64]*actions_model.ActionRun, len(runs))
for _, run := range runs {
runsMap[run.ID] = run
}
for _, id := range data.refreshRunIDs {
if run, ok := runsMap[id]; ok {
data.ActionRuns = append(data.ActionRuns, run)
}
}
return true return true
} }
+15
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
web_context "gitea.dev/services/context" web_context "gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -74,3 +75,17 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
} }
return ctx return ctx
} }
func TestActionRunListData(t *testing.T) {
unittest.PrepareTestEnv(t)
t.Run("preparePartialRefreshRuns", func(t *testing.T) {
ctx, _ := contexttest.MockContext(t, "user5/repo4/actions")
contexttest.LoadRepo(t, ctx, 4)
d := &actionRunListData{refreshRunIDs: []int64{791, 792}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{791, 792}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
d = &actionRunListData{refreshRunIDs: []int64{792, 791}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{792, 791}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
})
}
+1 -1
View File
@@ -773,7 +773,7 @@ func describePendingJobDetail(ctx *context_module.Context, current *actions_mode
if pending := pendingNeeds(current, jobs); len(pending) > 0 { if pending := pendingNeeds(current, jobs); len(pending) > 0 {
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", ")) return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
} }
case current.Status.IsWaiting(): case current.Status.IsWaiting() && !current.IsReusableCaller: // a caller waits on its called jobs, never on a runner
// A waiting job has no runner to pick it up yet. A busy runner is still // A waiting job has no runner to pick it up yet. A busy runner is still
// "online", so distinguish three cases: no runner online at all, online // "online", so distinguish three cases: no runner online at all, online
// runners but none match the labels, and a matching runner that is busy. // runners but none match the labels, and a matching runner that is busy.
+1 -1
View File
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
} }
} }
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if err := pull_service.Merge(ctx, 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 {
+5
View File
@@ -660,6 +660,11 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
return return
} }
if isDelTag && !rel.IsTag {
ctx.HTTPError(http.StatusConflict, "a tag attached to a release cannot be deleted directly")
return
}
if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil {
if release_service.IsErrProtectedTagName(err) { if release_service.IsErrProtectedTagName(err) {
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
+16
View File
@@ -4,6 +4,7 @@
package repo package repo
import ( import (
"net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -21,6 +22,21 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDeleteTagRetainsReleaseAndAttachments(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, resp := contexttest.MockContext(t, "POST user2/repo1/tags/delete?id=1")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9, ReleaseID: 1})
DeleteTag(ctx)
assert.Equal(t, http.StatusConflict, resp.Code)
assert.Equal(t, release, unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}))
assert.Equal(t, attachment, unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9}))
}
func TestNewReleasePost(t *testing.T) { func TestNewReleasePost(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
-8
View File
@@ -734,18 +734,10 @@ func UsernameSubRoute(ctx *context.Context) {
ShowGPGKeys(ctx) ShowGPGKeys(ctx)
} }
case strings.HasSuffix(username, ".rss"): case strings.HasSuffix(username, ".rss"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".rss") { if reloadParam(".rss") {
feed.ShowUserFeedRSS(ctx) feed.ShowUserFeedRSS(ctx)
} }
case strings.HasSuffix(username, ".atom"): case strings.HasSuffix(username, ".atom"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".atom") { if reloadParam(".atom") {
feed.ShowUserFeedAtom(ctx) feed.ShowUserFeedAtom(ctx)
} }
+7
View File
@@ -322,6 +322,13 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
// ActionUserFollow is for follow/unfollow user request // ActionUserFollow is for follow/unfollow user request
func ActionUserFollow(ctx *context.Context) { func ActionUserFollow(ctx *context.Context) {
isOrg := ctx.ContextUser.IsOrganization()
if isOrg && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrg && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
var err error var err error
switch ctx.FormString("action") { switch ctx.FormString("action") {
case "follow": case "follow":
+15
View File
@@ -15,6 +15,7 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/container" "gitea.dev/modules/container"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"xorm.io/builder" "xorm.io/builder"
@@ -99,6 +100,20 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
if !slots.available(job) { if !slots.available(job) {
continue continue
} }
if invalid := invalidRunsOn(job); invalid != nil {
job.Status, job.Stopped = actions_model.StatusFailure, timeutil.TimeStampNow()
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped")
if err != nil {
return err
}
if n > 0 {
updatedJobs = append(updatedJobs, job)
}
if err := upsertJobErrorSummary(ctx, job, "runs-on", invalid); err != nil {
return err
}
continue
}
var jobsToCancel []*actions_model.ActionRunJob var jobsToCancel []*actions_model.ActionRunJob
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job) job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil { if err != nil {
+11 -4
View File
@@ -98,7 +98,7 @@ jobs:
assert.NotEmpty(t, persisted.RawConcurrency) assert.NotEmpty(t, persisted.RawConcurrency)
} }
func TestPrepareRunAndInsert_JobIf(t *testing.T) { func TestPrepareRunAndInsert_JobIfAndRunsOn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })() defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
@@ -123,6 +123,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: echo - run: echo
unset-runs-on:
runs-on: ${{ vars.UNSET }}
steps:
- run: echo
`, false) `, false)
jobs := map[string]*actions_model.ActionRunJob{} jobs := map[string]*actions_model.ActionRunJob{}
@@ -134,9 +138,12 @@ jobs:
assert.False(t, jobs["skip"].IsConcurrencyEvaluated) assert.False(t, jobs["skip"].IsConcurrencyEvaluated)
assert.Equal(t, actions_model.StatusSkipped, jobs["skip-caller"].Status) assert.Equal(t, actions_model.StatusSkipped, jobs["skip-caller"].Status)
assert.Equal(t, actions_model.StatusSkipped, jobs["invalid"].Status) assert.Equal(t, actions_model.StatusSkipped, jobs["invalid"].Status)
summary, err := actions_model.GetActionRunJobSummary(t.Context(), run.RepoID, run.ID, run.LatestAttemptID, jobs["invalid"].ID, 0) assert.Equal(t, actions_model.StatusFailure, jobs["unset-runs-on"].Status)
require.NoError(t, err) for id, key := range map[string]string{"invalid": "if", "unset-runs-on": "runs-on"} {
assert.Contains(t, summary.Content, "Error when evaluating `if` for job `invalid`") summary, err := actions_model.GetActionRunJobSummary(t.Context(), run.RepoID, run.ID, run.LatestAttemptID, jobs[id].ID, 0)
require.NoError(t, err)
assert.Contains(t, summary.Content, "Error when evaluating `"+key+"` for job `"+id+"`")
}
} }
func TestComputeReusableCallerOutputs(t *testing.T) { func TestComputeReusableCallerOutputs(t *testing.T) {
+12
View File
@@ -183,6 +183,18 @@ func upsertJobErrorSummary(ctx context.Context, job *actions_model.ActionRunJob,
return actions_model.UpsertActionRunJobSummary(ctx, job.RepoID, job.RunID, job.RunAttemptID, job.ID, 0, actions_model.JobSummaryContentTypeMarkdown, []byte(content)) return actions_model.UpsertActionRunJobSummary(ctx, job.RepoID, job.RunID, job.RunAttemptID, job.ID, 0, actions_model.JobSummaryContentTypeMarkdown, []byte(content))
} }
// invalidRunsOn returns github.com's error for the job's evaluated runs-on.
func invalidRunsOn(job *actions_model.ActionRunJob) error {
parsed, err := job.ParseJob()
if err != nil {
return err
}
if problem := parsed.RunsOnProblem(); problem != "" {
return errors.New(problem)
}
return nil
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) { func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
taskNeeds, jobsByID, err := FindTaskNeeds(ctx, job) taskNeeds, jobsByID, err := FindTaskNeeds(ctx, job)
if err != nil { if err != nil {
+39 -33
View File
@@ -32,40 +32,46 @@ func handleInvalidWorkflows(ctx context.Context, input *notifyInput, ref git.Ref
if actionsConfig.IsWorkflowDisabled(entryName) { if actionsConfig.IsWorkflowDisabled(entryName) {
continue continue
} }
now := timeutil.TimeStampNow() insertInvalidWorkflowRun(ctx, &actions_model.ActionRun{
run := &actions_model.ActionRun{ Title: commit.MessageTitle(), RepoID: input.Repo.ID, Repo: input.Repo, OwnerID: input.Repo.OwnerID,
Title: util.EllipsisDisplayString(commit.MessageTitle(), 255), RepoID: input.Repo.ID, Repo: input.Repo, OwnerID: input.Repo.OwnerID,
WorkflowID: entryName, TriggerUserID: input.Doer.ID, TriggerUser: input.Doer, Ref: ref.String(), WorkflowID: entryName, TriggerUserID: input.Doer.ID, TriggerUser: input.Doer, Ref: ref.String(),
CommitSHA: commit.ID.String(), Event: input.Event, TriggerEvent: string(input.Event), EventPayload: string(payload), CommitSHA: commit.ID.String(), Event: input.Event, TriggerEvent: string(input.Event), EventPayload: string(payload),
WorkflowRepoID: input.Repo.ID, WorkflowCommitSHA: commit.ID.String(), Status: actions_model.StatusFailure, Started: now, Stopped: now, WorkflowRepoID: input.Repo.ID, WorkflowCommitSHA: commit.ID.String(),
} }, parseErr)
if err := db.WithTx(ctx, func(ctx context.Context) error {
if run.Index, err = db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID); err != nil {
return err
}
if err := db.Insert(ctx, run); err != nil {
return err
}
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: run.Status, Started: now, Stopped: now}
if err := db.Insert(ctx, attempt); err != nil {
return err
}
run.LatestAttemptID = attempt.ID
if err := actions_model.UpdateRun(ctx, run, "latest_attempt_id"); err != nil {
return err
}
content := fmt.Sprintf("**Invalid workflow file: %s**\n\n```\n%v\n```\n", entryName, parseErr)
return db.Insert(ctx, &actions_model.ActionRunJobSummary{
RepoID: run.RepoID, RunID: run.ID, RunAttemptID: attempt.ID, Content: content, ContentSize: int64(len(content)), ContentType: actions_model.JobSummaryContentTypeMarkdown,
})
}); err != nil {
log.Error("insert run for invalid workflow %q: %v", entryName, err)
continue
}
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, entryName+" ("+run.TriggerEvent+")", run.WorkflowID,
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file", false); err != nil {
log.Error("create commit status for invalid workflow %q: %v", entryName, err)
}
NotifyWorkflowRunStatusUpdate(ctx, run)
} }
} }
// insertInvalidWorkflowRun records run as failed with parseErr as its summary.
func insertInvalidWorkflowRun(ctx context.Context, run *actions_model.ActionRun, parseErr error) {
now := timeutil.TimeStampNow()
run.Title = util.EllipsisDisplayString(run.Title, 255)
run.Status, run.Started, run.Stopped = actions_model.StatusFailure, now, now
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
if run.Index, err = db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID); err != nil {
return err
}
if err := db.Insert(ctx, run); err != nil {
return err
}
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: run.Status, Started: now, Stopped: now}
if err := db.Insert(ctx, attempt); err != nil {
return err
}
run.LatestAttemptID = attempt.ID
if err := actions_model.UpdateRun(ctx, run, "latest_attempt_id"); err != nil {
return err
}
content := fmt.Sprintf("**Invalid workflow file: %s**\n\n```\n%v\n```\n", run.WorkflowID, parseErr)
return db.Insert(ctx, &actions_model.ActionRunJobSummary{
RepoID: run.RepoID, RunID: run.ID, RunAttemptID: attempt.ID, Content: content, ContentSize: int64(len(content)), ContentType: actions_model.JobSummaryContentTypeMarkdown,
})
}); err != nil {
log.Error("insert run for invalid workflow %q: %v", run.WorkflowID, err)
return
}
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, run.WorkflowID+" ("+run.TriggerEvent+")", run.WorkflowID,
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file", false); err != nil {
log.Error("create commit status for invalid workflow %q: %v", run.WorkflowID, err)
}
NotifyWorkflowRunStatusUpdate(ctx, run)
}
+8
View File
@@ -590,6 +590,14 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
continue continue
} }
if err := invalidRunsOn(actionRunJob); err != nil {
if err := upsertJobErrorSummary(ctx, actionRunJob, "runs-on", err); err != nil {
return nil, err
}
ret[id] = actions_model.StatusFailure
continue
}
// update concurrency and check whether the job can run now // update concurrency and check whether the job can run now
if err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars); errors.Is(err, util.ErrInvalidArgument) { if err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars); errors.Is(err, util.ErrInvalidArgument) {
if err := upsertJobErrorSummary(ctx, actionRunJob, "concurrency", err); err != nil { if err := upsertJobErrorSummary(ctx, actionRunJob, "concurrency", err); err != nil {
+9
View File
@@ -173,6 +173,15 @@ jobs:
want: map[int64]actions_model.Status{2: actions_model.StatusFailure}, want: map[int64]actions_model.Status{2: actions_model.StatusFailure},
note: "Error when evaluating `concurrency` for job `job2`.", note: "Error when evaluating `concurrency` for job `job2`.",
}, },
{
name: "invalid evaluated `runs-on` fails the job with an annotation",
jobs: actions_model.ActionJobList{
{ID: 1, RepoID: 1, JobID: "job1", Status: actions_model.StatusSuccess},
{ID: 2, RepoID: 1, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte("jobs: {job2: {runs-on: ''}}")},
},
want: map[int64]actions_model.Status{2: actions_model.StatusFailure},
note: "Error when evaluating `runs-on` for job `job2`.",
},
{ {
name: "max-parallel: a freed slot promotes the lowest blocked job id", name: "max-parallel: a freed slot promotes the lowest blocked job id",
jobs: actions_model.ActionJobList{ jobs: actions_model.ActionJobList{
+8
View File
@@ -394,6 +394,14 @@ func buildApproveAndInsertRun(
IsScopedRun: isScopedRun, IsScopedRun: isScopedRun,
} }
if err := validateCalledWorkflows(ctx, run, dwf.Content); err != nil {
if isScopedRun {
return err
}
insertInvalidWorkflowRun(ctx, run, err)
return nil
}
approvalUsers, err := getApprovalUsers(ctx, input, isForkPullRequest) approvalUsers, err := getApprovalUsers(ctx, input, isForkPullRequest)
if err != nil { if err != nil {
return err return err
+50 -1
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
@@ -97,6 +99,50 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
} }
} }
// validateCalledWorkflows validates all workflows content calls, recursively.
func validateCalledWorkflows(ctx context.Context, run *actions_model.ActionRun, content []byte) error {
validated := make(container.Set[string])
var validate func(content []byte, source *actions_model.ActionRunJob, level int) error
validate = func(content []byte, source *actions_model.ActionRunJob, level int) error {
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return err
}
for _, id := range slices.Sorted(maps.Keys(workflow.Jobs)) {
uses := workflow.Jobs[id].Uses
if uses == "" {
continue
}
if level > MaxReusableCallLevels {
return errCallLevelExceeded(uses)
}
if !validated.Add(fmt.Sprintf("%d@%s:%s", source.WorkflowSourceRepoID, source.WorkflowSourceCommitSHA, uses)) {
continue
}
ref, err := ResolveUses(ctx, uses)
if err != nil {
return fmt.Errorf("job %s: %w", id, err)
}
called, repoID, commitSHA, err := loadReusableWorkflowSource(ctx, run, source, ref)
if err != nil {
return fmt.Errorf("job %s: %w", id, err)
}
if _, err = jobparser.ValidateWorkflowStatic(called); err == nil {
err = validate(called, &actions_model.ActionRunJob{WorkflowSourceRepoID: repoID, WorkflowSourceCommitSHA: commitSHA}, level+1)
}
if err != nil {
return fmt.Errorf("job %s: Error from called workflow %s: %w", id, uses, err)
}
}
return nil
}
return validate(content, &actions_model.ActionRunJob{WorkflowSourceRepoID: run.WorkflowRepoID, WorkflowSourceCommitSHA: run.WorkflowCommitSHA}, 0)
}
func errCallLevelExceeded(uses string) error {
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, uses)
}
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from. // resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA. // pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string { func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
@@ -149,7 +195,7 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
current = next current = next
depth++ depth++
if depth > MaxReusableCallLevels { if depth > MaxReusableCallLevels {
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses) return errCallLevelExceeded(caller.CallUses)
} }
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current)) { if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current)) {
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses) return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
@@ -225,6 +271,9 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil { if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
return err return err
} }
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
return fmt.Errorf("invalid called workflow: %w", err)
}
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation). // 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
wcSpec, err := jobparser.ParseWorkflowCallConfig(content) wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
+13 -5
View File
@@ -5,6 +5,7 @@ package actions
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
act_model "gitea.dev/actionslib/pkg/model" act_model "gitea.dev/actionslib/pkg/model"
@@ -12,6 +13,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser" "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
@@ -185,6 +187,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
id, job := workflowJob.Job() id, job := workflowJob.Job()
needs := job.Needs() needs := job.Needs()
isMatrixDeferred := jobparser.HasDeferredMatrix(job) isMatrixDeferred := jobparser.HasDeferredMatrix(job)
runsOnProblem := job.RunsOnProblem() // SetJob's encoding drops the node's null tag
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil { if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
@@ -238,10 +241,15 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
} }
// a skipped job must neither cancel its group peers nor take a slot // a skipped job must neither cancel its group peers nor take a slot
invalidIf, err := decideJobIf(ctx, run, runAttempt, runJob, vars) invalidErr, err := decideJobIf(ctx, run, runAttempt, runJob, vars)
if err != nil { if err != nil {
return nil, nil, false, fmt.Errorf("evaluate job if: %w", err) return nil, nil, false, fmt.Errorf("evaluate job if: %w", err)
} }
invalidKey := "if"
if runsOnProblem != "" && runJob.Status.IsWaiting() && slots.available(runJob) {
invalidKey, invalidErr = "runs-on", errors.New(runsOnProblem)
runJob.Status, runJob.Stopped = actions_model.StatusFailure, timeutil.TimeStampNow()
}
var cancelledConcurrencyJobs []*actions_model.ActionRunJob var cancelledConcurrencyJobs []*actions_model.ActionRunJob
// check job concurrency // check job concurrency
@@ -275,8 +283,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
if err := db.Insert(ctx, runJob); err != nil { if err := db.Insert(ctx, runJob); err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
if invalidIf != nil { if invalidErr != nil {
if err := upsertJobErrorSummary(ctx, runJob, "if", invalidIf); err != nil { if err := upsertJobErrorSummary(ctx, runJob, invalidKey, invalidErr); err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
} }
@@ -287,8 +295,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
} }
} }
// the emitter resolves an expanded caller's children and a skipped job's dependents // the emitter resolves an expanded caller's children and a skipped or failed job's dependents
return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status == actions_model.StatusSkipped, nil return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status.In(actions_model.StatusSkipped, actions_model.StatusFailure), nil
} }
func expandInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error { func expandInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
+9
View File
@@ -17,6 +17,7 @@ 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/actions/jobparser"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -144,6 +145,14 @@ func CreateScheduleTaskBySpec(ctx context.Context, spec *actions_model.ActionSch
WorkflowCommitSHA: cron.CommitSHA, WorkflowCommitSHA: cron.CommitSHA,
} }
_, err := jobparser.ValidateWorkflowStatic(cron.Content)
if err == nil {
err = validateCalledWorkflows(ctx, run, cron.Content)
}
if err != nil {
return fmt.Errorf("invalid workflow: %w", err)
}
// FIXME cron.Content might be outdated if the workflow file has been changed. // FIXME cron.Content might be outdated if the workflow file has been changed.
// Load the latest sha from default branch // Load the latest sha from default branch
// Insert the action run and its associated jobs into the database // Insert the action run and its associated jobs into the database
+1 -1
View File
@@ -103,7 +103,7 @@ func TestStartTasks(t *testing.T) {
} }
due := timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix()) due := timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix())
validWorkflow := "jobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n" validWorkflow := "on:\n schedule:\n - cron: '0 0 * * *'\njobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"
// specs are processed by ascending id, so the broken one runs first and used to abort the whole pass // specs are processed by ascending id, so the broken one runs first and used to abort the whole pass
broken := insertSchedule(1, 2, "broken.yml", "@every 1m", "this: [is: not: a: workflow", due) broken := insertSchedule(1, 2, "broken.yml", "@every 1m", "this: [is: not: a: workflow", due)
+4 -1
View File
@@ -140,7 +140,10 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
return 0, err return 0, err
} }
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil { if _, err = jobparser.ValidateWorkflowStatic(content); err == nil {
err = validateCalledWorkflows(ctx, run, content)
}
if err != nil {
return 0, util.ErrorWrapTranslatable(util.NewInvalidArgumentErrorf("invalid workflow %q: %v", workflowID, err), "actions.runs.invalid_workflow_helper", err.Error()) return 0, util.ErrorWrapTranslatable(util.NewInvalidArgumentErrorf("invalid workflow %q: %v", workflowID, err), "actions.runs.invalid_workflow_helper", err.Error())
} }
workflow, err := jobparser.ReadWorkflow(content) workflow, err := jobparser.ReadWorkflow(content)
+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(pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil { if err := pull_service.Merge(ctx, 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)
} }
+2 -3
View File
@@ -107,9 +107,8 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
if err != nil { if err != nil {
log.Info("Unable to get global API settings. Ignoring these.") log.Info("Unable to get global API settings. Ignoring these.")
log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err) log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err)
} } else if apiConf != nil && apiConf.MaxResponseItems > 0 {
if apiConf != nil { maxPerPage = min(apiConf.MaxResponseItems, 100)
maxPerPage = apiConf.MaxResponseItems
} }
return &GiteaDownloader{ return &GiteaDownloader{
+10 -7
View File
@@ -5,6 +5,7 @@ package migrations
import ( import (
"fmt" "fmt"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -317,15 +318,16 @@ func TestGiteaDownloadRepo(t *testing.T) {
func TestGiteaDownloadCommentsPaging(t *testing.T) { func TestGiteaDownloadCommentsPaging(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
maxResponseItems, commentCount, requests int maxResponseItems, pageSize, commentCount, requests int
paginated bool paginated bool
}{ }{
{maxResponseItems: 2, commentCount: 2, requests: 2}, {maxResponseItems: 2, pageSize: 2, commentCount: 2, requests: 2},
{maxResponseItems: 2, commentCount: 3, requests: 1}, {maxResponseItems: 2, pageSize: 2, commentCount: 3, requests: 1},
{maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true}, {maxResponseItems: 2, pageSize: 2, commentCount: 4, requests: 3, paginated: true},
{maxResponseItems: 0, commentCount: 0, requests: 1}, {maxResponseItems: 0, pageSize: 10, commentCount: 0, requests: 1},
{maxResponseItems: math.MaxInt, pageSize: 100, commentCount: 0, requests: 1},
} { } {
t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) { t.Run(fmt.Sprintf("maxResponseItems=%d/comments=%d", tc.maxResponseItems, tc.commentCount), func(t *testing.T) {
commentRequests := 0 commentRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@@ -352,6 +354,7 @@ func TestGiteaDownloadCommentsPaging(t *testing.T) {
downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "") downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.pageSize, downloader.maxPerPage)
comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1}) comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1})
require.NoError(t, err) require.NoError(t, err)
+16 -1
View File
@@ -14,6 +14,7 @@ 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"
@@ -27,6 +28,7 @@ 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"
@@ -289,9 +291,22 @@ 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(prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error { func Merge(outerCtx context.Context, 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 {
+12 -6
View File
@@ -7,20 +7,26 @@ 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:%D|%H|%ad|%h|%s" format := "DATA:" + strings.Join([]string{
"%D", // ref names without the " (", ")" wrapping.
if page == 0 { "%H", // commit hash
page = 1 "%ad", // author date (format respects --date= option)
} "%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 {
@@ -31,7 +37,7 @@ func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllow
graphCmd.AddArguments("--tags", "--branches") graphCmd.AddArguments("--tags", "--branches")
} }
graphCmd.AddArguments("-C", "-M", "--date=iso-strict"). graphCmd.AddArguments("--find-copies", "--find-renames", "--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("|"), 5) data := bytes.SplitN(line, []byte(gitLogGraphFormatSep), 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 pipe", "An extra pipe: |"}, {true, "extra sep", "An extra sep"},
{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>
@@ -9,18 +9,15 @@
<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>
{{$localBranch := $pull.HeadBranch}} {{$args := $pull.GetInstructionsCliArgs}}
{{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}} {{$pull.HeadBranch}}:{{$localBranch}}</div> <div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$args.HeadBranchArg}}:{{$args.LocalBranchArg}}</div>
{{else}} {{else}}
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div> <div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$args.LocalBranchArg}}</div>
{{end}} {{end}}
<div>git checkout {{$localBranch}}</div> <div>git checkout {{$args.LocalBranchArg}}</div>
</div> </div>
{{if $data.ShowMergeInstructions}} {{if $data.ShowMergeInstructions}}
<div> <div>
@@ -32,32 +29,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 {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --no-ff {{$localBranch}}</div> <div>git merge --no-ff {{$args.LocalBranchArg}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="rebase"> <div class="tw-hidden" data-pull-merge-style="rebase">
<div>git checkout {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --ff-only {{$localBranch}}</div> <div>git merge --ff-only {{$args.LocalBranchArg}}</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 {{$localBranch}}</div> <div>git checkout {{$args.LocalBranchArg}}</div>
<div>git rebase {{$pull.BaseBranch}}</div> <div>git rebase {{$args.BaseBranchArg}}</div>
<div>git checkout {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --no-ff {{$localBranch}}</div> <div>git merge --no-ff {{$args.LocalBranchArg}}</div>
</div> </div>
<div class="tw-hidden" data-pull-merge-style="squash"> <div class="tw-hidden" data-pull-merge-style="squash">
<div>git checkout {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --squash {{$localBranch}}</div> <div>git merge --squash {{$args.LocalBranchArg}}</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 {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge --ff-only {{$localBranch}}</div> <div>git merge --ff-only {{$args.LocalBranchArg}}</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 {{$pull.BaseBranch}}</div> <div>git checkout {{$args.BaseBranchArg}}</div>
<div>git merge {{$localBranch}}</div> <div>git merge {{$args.LocalBranchArg}}</div>
</div> </div>
<div>git push {{$gitRemoteName}} {{$pull.BaseBranch}}</div> <div>git push {{$gitRemoteName}} {{$args.BaseBranchArg}}</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
View File
@@ -12,6 +12,7 @@
{{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">
+2
View File
@@ -17,6 +17,8 @@ test('create a bot and manage its access token', async ({page, request}) => {
await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check(); await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check();
await page.getByRole('button', {name: 'Generate Token'}).click(); await page.getByRole('button', {name: 'Generate Token'}).click();
const token = await page.getByRole('code').textContent(); const token = await page.getByRole('code').textContent();
await page.getByRole('button', {name: 'Copy', exact: true}).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(token);
const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}}); const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}});
expect(await response.json()).toMatchObject({login: botName, type: 'Bot'}); expect(await response.json()).toMatchObject({login: botName, type: 'Bot'});
+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;
@@ -33,7 +33,7 @@ func TestActionsInvalidWorkflowPush(t *testing.T) {
content string content string
wantErrors []string wantErrors []string
}{ }{
{"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {if: unknown.x}}\n", []string{"Unrecognized named-value: &#39;unknown&#39;", "unclosed expression"}}, {"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {runs-on: ubuntu-latest, if: unknown.x}}\n", []string{"Unrecognized named-value: &#39;unknown&#39;", "unclosed expression"}},
{"trigger", "on:\njobs: {check: {runs-on: ubuntu-latest, steps: [{run: echo hello}]}}\n", []string{"invalid event"}}, {"trigger", "on:\njobs: {check: {runs-on: ubuntu-latest, steps: [{run: echo hello}]}}\n", []string{"invalid event"}},
} { } {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
@@ -405,8 +405,8 @@ jobs:
from: 'consumer' from: 'consumer'
`) `)
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted. // Phase 1: no grant.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})) assertInvalidWorkflowRun(t, consumerRepo.ID, "cross-caller.yaml", "reusable workflow repository user2/reusable-lib-private does not exist or is not readable")
runner.fetchNoTask(t) runner.fetchNoTask(t)
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo. // Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
@@ -418,7 +418,7 @@ jobs:
// Phase 3: trigger the workflow again // Phase 3: trigger the workflow again
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant") createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID, Index: 2})
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"}) crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
assert.True(t, crossJob.IsReusableCaller) assert.True(t, crossJob.IsReusableCaller)
assert.True(t, crossJob.IsExpanded) assert.True(t, crossJob.IsExpanded)
@@ -484,8 +484,7 @@ jobs:
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
`) `)
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched. assertInvalidWorkflowRun(t, consumerRepo.ID, "cross-caller.yaml", "reusable workflow repository user2/reusable-lib-public-denied does not exist or is not readable")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
runner.fetchNoTask(t) runner.fetchNoTask(t)
}) })
@@ -563,35 +562,32 @@ jobs:
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"}) unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
}) })
t.Run("Missing callee file", func(t *testing.T) { t.Run("Missing or invalid callee fails the run as an invalid workflow file", func(t *testing.T) {
// A caller workflow references a callee path that does not exist in the repo. for name, testCase := range map[string]struct{ callee, want string }{
"missing": {"", "job call: read user2/caller-missing-callee@"},
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false) "no-runs-on": {"on: workflow_call\njobs:\n inner:\n steps:\n - run: echo\n", "job call: Error from called workflow ./.gitea/workflows/callee.yml: job inner: Required property is missing: runs-on"},
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) } {
apiRepo := createActionsTestRepo(t, user2Token, "caller-"+name+"-callee", false)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml", repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
`name: Caller if testCase.callee != "" {
on: push createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/callee.yml", testCase.callee)
jobs: }
plain_job: createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
runs-on: ubuntu-latest "on: push\njobs:\n plain_job:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n call:\n needs: plain_job\n uses: ./.gitea/workflows/callee.yml\n")
steps: assertInvalidWorkflowRun(t, repo.ID, "caller.yaml", testCase.want)
- run: echo 'job' }
call_missing:
uses: ./.gitea/workflows/does-not-exist.yml
`)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
}) })
t.Run("Nested caller with missing callee fails with the error as summary instead of blocking", func(t *testing.T) { t.Run("Nested caller failing to expand fails with the error as summary instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever. // When the expansion hits a terminal error, the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false) apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-bad-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner() runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false) runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yml",
"on:\n workflow_call:\n secrets:\n token:\n required: true\njobs:\n inner:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n")
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml", createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller `name: Caller
on: push on: push
@@ -602,7 +598,7 @@ jobs:
- run: echo 'job' - run: echo 'job'
bad_caller: bad_caller:
needs: plain_job needs: plain_job
uses: ./.gitea/workflows/does-not-exist.yml uses: ./.gitea/workflows/lib.yml
`) `)
plainTask := runner.fetchTask(t) plainTask := runner.fetchTask(t)
@@ -614,7 +610,7 @@ jobs:
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller. // The emitter now tries to expand bad_caller, misses the required secret, and fails the caller.
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID}) badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
assert.Equal(t, actions_model.StatusFailure, badCaller.Status) assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
// No children were inserted (the terminal error precedes the child inserts). // No children were inserted (the terminal error precedes the child inserts).
@@ -626,7 +622,7 @@ jobs:
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repo.ID, run.ID, badCaller.RunAttemptID, badCaller.ID, 0) summary, err := actions_model.GetActionRunJobSummary(t.Context(), repo.ID, run.ID, badCaller.RunAttemptID, badCaller.ID, 0)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, summary.Content, "does-not-exist.yml") assert.Contains(t, summary.Content, "secret token is required, but not provided while calling")
}) })
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) { t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
@@ -989,6 +985,16 @@ jobs:
}) })
} }
func assertInvalidWorkflowRun(t *testing.T, repoID int64, workflowID, want string) {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repoID, WorkflowID: workflowID})
assert.Equal(t, actions_model.StatusFailure, run.Status)
assert.Zero(t, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: run.ID}))
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repoID, run.ID, run.LatestAttemptID, 0, 0)
require.NoError(t, err)
assert.Contains(t, summary.Content, want)
}
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's // token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
// existing token rather than logging in per call, which would re-run bcrypt password verification each time. // existing token rather than logging in per call, which would re-run bcrypt password verification each time.
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) { func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
+45
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)()
@@ -38,6 +38,11 @@ func TestAPIGetIssueAttachment(t *testing.T) {
apiAttachment := DecodeJSON(t, resp, &api.Attachment{}) apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
commentAttachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 3, RepoID: repo.ID})
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: commentAttachment.IssueID}).Index, commentAttachment.ID)).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusNotFound)
} }
func TestAPIListIssueAttachments(t *testing.T) { func TestAPIListIssueAttachments(t *testing.T) {
+27 -21
View File
@@ -104,6 +104,7 @@ 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"
@@ -170,8 +171,9 @@ func TestPackageNpm(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
rootPaths := []string{ rootPaths := []string{
fmt.Sprintf("/api/packages/%s/npm/@scope/test-package", user.Name), "/api/packages/user2/npm/@scope/test-package",
fmt.Sprintf("/api/packages/%s/npm/@scope%%2ftest-package", user.Name), "/api/packages/user2/npm/@scope%2Ftest-package",
"/api/packages/user2/npm/%40scope%2ftest-package",
} }
for _, root := range rootPaths { for _, root := range rootPaths {
req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).AddTokenAuth(token) req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).AddTokenAuth(token)
@@ -186,7 +188,7 @@ func TestPackageNpm(t *testing.T) {
pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm) pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, pvs, 1) assert.Len(t, pvs, 1)
assert.Equal(t, int64(4), pvs[0].DownloadCount) assert.EqualValues(t, 6, pvs[0].DownloadCount)
}) })
t.Run("PackageMetadata", func(t *testing.T) { t.Run("PackageMetadata", func(t *testing.T) {
@@ -217,7 +219,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("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball) assert.Equal(t, fmt.Sprintf("%sapi/packages/%s/npm/%s/-/%s", setting.AppURL, user.Name, packageName, 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)
@@ -227,10 +229,24 @@ 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) {
@@ -289,22 +305,6 @@ 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,6 +318,12 @@ 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) {
@@ -522,7 +528,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/%s/-rev/dummy", root, packageVersion, filename)). req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/-rev/dummy", root, filename)).
AddTokenAuth(token) AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
@@ -22,6 +22,10 @@ func TestAPIGetRawFileOrLFS(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo2/media/test.xml").AddTokenAuth(getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository))
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
// Test with LFS // Test with LFS
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
createLFSTestRepository(t, "repo-lfs-test") createLFSTestRepository(t, "repo-lfs-test")
+10
View File
@@ -16,6 +16,8 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"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/test"
repo_service "gitea.dev/services/repository" repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -175,6 +177,14 @@ func TestGitPushVisibilityOption(t *testing.T) {
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t) doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID}) repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository") assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
defer test.MockVariableValue(&setting.Repository.ForcePrivate, true)()
forcedRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{Name: "repo-visibility-forced", DefaultBranch: "master", IsPrivate: true})
require.NoError(t, err)
u.Path = forcedRepo.FullName() + ".git"
doGitAddRemote(gitPath, "forced", u)(t)
doGitPushTestRepository(gitPath, "forced", "master", "-o", "repo.private=false")(t)
assert.True(t, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: forcedRepo.ID}).IsPrivate)
}) })
} }
+9
View File
@@ -571,6 +571,15 @@ func testRefreshTokenInvalidation(t *testing.T) {
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription) assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
"redirect_uri": "https://example.com",
"refresh_token": parsed.AccessToken,
})
MakeRequest(t, req, http.StatusBadRequest)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token", "grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+6 -6
View File
@@ -378,11 +378,11 @@ func TestCantMergeConflict(t *testing.T) {
BaseBranch: "base", BaseBranch: "base",
}) })
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false) err := pull_service.Merge(t.Context(), 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(pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false) err = pull_service.Merge(t.Context(), 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(pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false) err = pull_service.Merge(t.Context(), 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(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false) err := pull_service.Merge(t.Context(), 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(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)) require.NoError(t, pull_service.Merge(t.Context(), 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(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false) err := pull_service.Merge(t.Context(), 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")
}) })
+9
View File
@@ -84,6 +84,7 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
req := NewRequest(t, "GET", "/"+org22.Name) req := NewRequest(t, "GET", "/"+org22.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+org22.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
session := loginUser(t, "user1") session := loginUser(t, "user1")
oldName := org22.Name oldName := org22.Name
@@ -106,6 +107,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
req = NewRequest(t, "GET", "/"+org23.Name) req = NewRequest(t, "GET", "/"+org23.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
strangerSession := loginUser(t, "user4")
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+org23.Name+"?action=follow"), http.StatusNotFound)
oldName = org23.Name oldName = org23.Name
newName = "org23_renamed" newName = "org23_renamed"
@@ -127,6 +130,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
req = NewRequest(t, "GET", "/"+user31.Name) req = NewRequest(t, "GET", "/"+user31.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+user31.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+user31.Name+"?action=follow"), http.StatusNotFound)
oldName = user31.Name oldName = user31.Name
newName = "user31_renamed" newName = "user31_renamed"
@@ -330,6 +335,10 @@ func testGetUserRss(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
req = NewRequestf(t, "GET", "/non-existent-user.rss") req = NewRequestf(t, "GET", "/non-existent-user.rss")
session.MakeRequest(t, req, http.StatusNotFound) session.MakeRequest(t, req, http.StatusNotFound)
defer test.MockVariableValue(&setting.Other.EnableFeed, false)()
MakeRequest(t, NewRequestf(t, "GET", "/%s.rss", user34), http.StatusNotFound)
MakeRequest(t, NewRequestf(t, "GET", "/%s", user34).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
} }
func testUserListStopWatches(t *testing.T) { func testUserListStopWatches(t *testing.T) {
+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++) {
+2 -4
View File
@@ -64,7 +64,7 @@ function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => vo
// Enable clipboard copy from HTML attributes. These properties are supported: // Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy // - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied // - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of other elements will be copied
export function initGlobalCopyToClipboardListener() { export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]'); const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
@@ -78,10 +78,8 @@ export function initGlobalCopyToClipboardListener() {
const textTarget = document.querySelector(textSelector)!; const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') { if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value; text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else { } else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`); text = textTarget.textContent;
} }
} }
// now, text can not be null // now, text can not be null
+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);
} }