Compare commits

..
25 Commits
Author SHA1 Message Date
Lunny Xiaoandsilverwind b140311d54 perf(actions): cache successful runner token verification (#39934) (#39959)
Backport of https://github.com/go-gitea/gitea/pull/39934 to release/v28.

Runner token verification (PBKDF2) dominated CPU on instances with many
runners. Successful verifications are now cached in a dedicated LRU.
Conflict resolved by keeping v28's connect v1 interceptor and adding
only the cache.

_Backport prepared with Codet._

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 02:36:43 -07:00
Giteabotandsilverwind 2b0aa6cfd2 docs: clarify that the team packages permission covers all org packages (#39953) (#39956)
The Packages row in the team settings read "Manage repository
packages.", but packages belong to the owner, so a team's Packages
permission applies to every package of the organization, regardless of
the team's repositories. Reword it to say so. The docs get the same
clarification in https://gitea.com/gitea/docs/pulls/605.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 00:13:33 +00:00
silverwindandbircni 9d1d5ceddb fix(actions): bound requested artifact retention (#39894) (#39946)
`actions/upload-artifact` v4 and later send an absolute `expires_at`
computed with the runner's clock, which Gitea stored unchecked. A runner
clock running behind made artifacts expire at the next cleanup, and
workflows could keep artifacts longer than `ARTIFACT_RETENTION_DAYS`.

- Requested expiries are bounded by Gitea's clock, at least 1 day and at
most `ARTIFACT_RETENTION_DAYS` when it is not `0`. Longer
`retention-days` requests now get the instance limit.
- `github.retention_days` now carries `ARTIFACT_RETENTION_DAYS`, so
upload actions cap `retention-days` themselves and log a warning, like
on GitHub.

Co-authored-by: bircni <bircni@icloud.com>
2026-10-22 23:21:37 +00:00
silverwind 9141a6386f ci: update giteabot to v1.3.1 (#39947) (#39948)
Backport https://github.com/go-gitea/gitea/pull/39947

PRs targeting `release/v28` run this branch's `giteabot.yml`, and
`v1.3.1` runs the merge queue when such a PR merges, so the branch needs
the same pin. `giteabot-backport.yml` gets it too to keep the branches
in sync.
2026-10-22 22:56:30 +00:00
a814cf7567 fix(api): adding issue labels by name attaches both the repo and the org label (#39873) (#39951)
Backport #39873 by @nazem-droubi

Fixes #39651

When an org-owned repo has a repo label and an org label with the same
name, `POST`/`PUT /repos/{owner}/{repo}/issues/{index}/labels` with
label names attached both of them.

With this change the repo label is used when the names collide, and org
labels are only looked up for the names that didn't match a repo label.
Names that only exist at the org level still resolve to the org label as
before. I documented the behavior on `IssueLabelsOption.Labels` and
regenerated the swagger files.

Picking the repo label seemed the least surprising choice since it's the
more specific one, but I'm happy to change it (for example to return a
422 on ambiguous names) if you'd prefer that.

Tests: added `TestAPIAddIssueLabelsWithSharedRepoAndOrgLabelName`, which
covers both POST and PUT. It fails on `main` (3 labels attached instead
of 2) and passes with this change. I ran the existing add/replace label
integration tests locally on sqlite, plus `go vet` and gofmt.

AI disclosure: the code and test in this PR were written primarily by an
AI coding agent working on my behalf. I'm happy to walk through it or
revise anything during review.

Signed-off-by: Nazem <214302554+nazem-droubi@users.noreply.github.com>
Co-authored-by: Nazem <nazem98@gmail.com>
Co-authored-by: Nazem <214302554+nazem-droubi@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 21:40:40 +00:00
GiteabotandBasavaraj S m 2fd12d5f76 fix(feed): hide release notes in tags feed without releases access (#39944) (#39949)
Backport #39944 by @basavaraj-sm05

The tags feed (`tags.rss` / `tags.atom`) lives in the tags route group,
which only requires code read access, yet it lists every published
release next to the plain tags and includes each release's title and
rendered note. The releases page and `releases.rss` require releases
read access, and the tags page only shows tag names to users without it,
so the feed is the one place that leaks. A team member granted Code but
not Releases on a private repo, or anyone on a public repo whose
Releases unit is disabled, can read all release notes this way. I
noticed it while comparing the feed routes with the tags template. When
the doer cannot read releases, the feed now lists releases as plain
tags, linking to `/src/tag/` like the tags page does.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
2026-10-22 14:06:27 -07:00
640b528580 fix(migrations): use cursor pagination for GitHub issue migration too (#39888) (#39941)
Backport #39888 by @yuhongwei380

For repos with many issues, the GitHub API only allows cursor-based
pagination instead of page-based pagination after a certain page.
A full migration of such repos failed with a 422 response.
Now, we dynamically switch how we migrate based on the response from the
GitHub API.

Fixes #39882
Assisted-by: Codex:GPT-6

Signed-off-by: Phoenix <44333391+yuhongwei380@users.noreply.github.com>
Co-authored-by: Phoenix <44333391+yuhongwei380@users.noreply.github.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 13:33:39 +00:00
3e5256b6ca fix(api): check unit read permission in issue sub-resource endpoints (#39885) (#39938)
Backport #39885 by @basavaraj-sm05

Several `/repos/{owner}/{repo}/issues/{index}/...` API endpoints load
the issue by index without checking
`CanReadIssuesOrPulls(issue.IsPull)`, unlike `GetIssue`,
`ListIssueComments` and `GetIssueReactions`. The route group's
`mustEnableIssuesOrPulls` passes when the caller can read either unit,
so someone who can read issues but not pull requests (a team with only
the Issues unit, or per-unit anonymous access on a public repo) can read
a pull request's whole conversation through `/timeline`, plus its
labels, subscribers, attachments and tracked times, and can subscribe to
it. The reverse holds for issues.

These handlers now return 404 in that case, like their siblings.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-22 05:27:46 -07:00
3996c2c520 fix(issues): keep one row per issue when sorting by exclusive label order (#39364) (#39928)
Backport #39364 by @shoemoney

Sorting by an exclusive label scope joined `issue_label`, returning one
row per label. Issues with several labels were listed repeatedly and
pushed others off the page, and `IssueIDs` returned inflated totals.

The sort key now comes from a correlated subquery, so each issue yields
one row.

Fixes https://github.com/go-gitea/gitea/issues/35711

This base-branch update was prepared with AI assistance.

Co-authored-by: Jeremy Schoemaker <jeremy@shoemoney.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 00:22:16 -07:00
716295015b fix(oauth2): decode form-encoded client credentials in HTTP Basic auth (#39926) (#39931)
Backport #39926 by @fixfon

Fixes https://github.com/go-gitea/gitea/issues/39925

RFC 6749 §2.3.1 has clients form-urlencode the client id and secret
before building the HTTP Basic header. `openid-client` with
`client_secret_basic` does this and sends `-` as `%2D` and `_` as `%5F`.
The token and introspection endpoints used the raw header values, so
these clients failed with `invalid_client`. Both endpoints now decode
the values with `url.QueryUnescape` before the client lookup, the secret
check and the body consistency check, and reject a value that does not
decode. Plain HTTP Basic parsing in `httpauth` is unchanged, since the
encoding only applies to OAuth client authentication.

Clients that don't encode are unaffected, since no client id or secret
Gitea has ever issued contains `+` or `%`.

Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-22 06:22:15 +00:00
Giteabotandsilverwind f50a5d0dab test(e2e): enable async clipboard testing pref in Firefox (#39838) (#39927)
Backport #39838 by @silverwind

When `navigator.clipboard.readText()` runs before the page's copy has
landed, Firefox shows its "Paste" prompt, which cannot be interacted
with when headless. Avoid that and and use
`dom.events.testing.asyncClipboard` which is a pref meant for testing
that disabes this prompt.

Fixes: https://github.com/go-gitea/gitea/actions/runs/39113819632/job/117635434106
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 22:14:01 -07:00
Shudhanshu Singh bc3e228e78 fix(api): keep milestone due date when it is omitted in edit request (#39869) (#39921)
Backport https://github.com/go-gitea/gitea/pull/39869

`PATCH /repos/{owner}/{repo}/milestones/{id}` always overwrote the due
date with `due_on`, so a request that only changed other fields, such as
`{"state": "closed"}`, silently removed it.

The due date is now kept when `due_on` is omitted, and `"due_on": null`
or `"due_on": ""` remove it.

Only the milestone fix is backported. The issue and pull request
deadline changes of https://github.com/go-gitea/gitea/pull/39869 build
on https://github.com/go-gitea/gitea/pull/38677, which isn't on v28.
2026-10-21 17:59:21 +02:00
193c676946 fix(lfs): send the default branch as ref in batch download requests (#39891) (#39920)
Backport #39891

Fixes https://github.com/go-gitea/gitea/issues/38085

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 10:59:03 +00:00
977c9181ad fix(api): return 4xx for invalid fork repository names (#39909) (#39919)
Backport #39909

Fixes #39908

Signed-off-by: Ferdinand-MZ <ferdinandmaulanazafauzi@gmail.com>
Co-authored-by: Ferdinand Maulana Za Fauzi <101077118+Ferdinand-MZ@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 10:40:04 +00:00
197e761471 enhance(webhook): mention release assets in notifications (#39631) (#39912)
Backport #39631 by @stackedbyaradhya

Release webhook notifications never mention attached files, although the
payload carries them.

The summary shared by all webhook providers now appends an asset count
like `(assets: 2)` for created and updated releases.

Closes https://github.com/go-gitea/gitea/issues/39630

Co-authored-by: Aradhya Jain <aradhyaj736@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 07:05:55 +00:00
01db50f0bf fix: enforce public-only token scope on more routes (#39690) (#39853)
Partial backport of https://github.com/go-gitea/gitea/pull/39690 with
only its public-only token checks, without the refactor.

1. Admin, org owner and repo or org creation APIs reject public-only
tokens.
1. Profile and org pages hide private repos and limited owners from
public-only tokens.
1. Heatmap API, OIDC userinfo and OCI blob mounts respect public-only
tokens.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-21 05:58:07 +00:00
4b743d2e8c fix(packages): reject newline-bearing rubygems dependency metadata (#39782) (#39900)
Backport #39782 by @basavaraj-sm05

The RubyGems compact index (the shared `/versions` file and per-package
`info/<name>` files) is line-based.
https://github.com/go-gitea/gitea/pull/38061 rejected line breaks in the
gem name, but the platform, dependency names and version requirements
from the gemspec still accepted them, letting an uploader forge index
lines for bundler clients.

The parser now rejects a line break in any of those fields. The Marshal
based endpoints are length prefixed and unaffected.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 05:24:52 +00:00
silverwind 31653bdb34 ci: update giteabot to v1.3.0 (#39910) (#39911) 2026-10-21 04:56:59 +00:00
Giteabotandwxiaoguang 7365946851 fix: use clear error message when failed to parse git version (#39898) (#39903)
Backport #39898 by @wxiaoguang

fix  #39892

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-21 04:35:32 +00:00
Giteabotandsilverwind 254e18107e enhance: improve hover border of colored button groups (#39861) (#39899)
Backport #39861 by @silverwind

Hovering a button in a primary or red button group showed a grey frame,
because the generic group hover border applied to colored groups too.
Green groups had no colors at all.

Colored groups now let the hover background fill the border, and green
groups get the same colors as primary and red. The devtest page shows
all three.

<img width="569" height="304" alt="Hovered buttons in uncolored,
primary, red and green button groups, before and after"
src="https://github.com/user-attachments/assets/e0fb6b16-f1f1-459b-9848-9386cd9453c9"
/>

Extracted from https://github.com/go-gitea/gitea/pull/38404

Co-authored-by: silverwind <me@silverwind.io>
2026-10-21 04:03:35 +00:00
Giteabotandsilverwind 290568a07b fix: off-by-one errors in code comment patches (#39887) (#39904)
Backport #39887 by @silverwind

Code comments next to a diff hunk could show one line too high in the
conversation view vs. in the code view that showed them on the correct
line.

- Treat the hunk end in `CutDiffAroundLine` as exclusive, so the line
after a hunk falls back to the unchanged-line context
- Don't count `\ No newline at end of file` markers as lines when
cutting the hunk
- Share the unchanged-line fallback with migrated review comments

Co-authored-by: silverwind <me@silverwind.io>
2026-10-20 20:37:19 -07:00
bircniandGiteabot b758396619 chore(deps): update go toolchain directive to v1.27.3 (#39881) (#39896)
Backport of https://github.com/go-gitea/gitea/pull/39881.

Go 1.27.3 includes a crypto/x509 security fix that distrusts a set of
intermediate certificates (https://github.com/golang/go/issues/82345),
so release builds need the new toolchain.

Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-20 14:04:10 -07:00
Giteabotandwxiaoguang 9bc4ce88ee fix: prevent system users from watching a repository (#39886) (#39895)
Backport #39886 by @wxiaoguang

fix #39884

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-20 20:27:42 +00:00
GiteabotandShivansh Garg 9bb9a51623 fix(api): skip label lookup in ListIssues when no labels filter given (#39890) (#39893)
Backport #39890 by @SHIVANSHGARG07

strings.Split("", ",") returns []string{""} (length 1), not an empty
slice, so the previous len(splitted) > 0 guard was always true and never
skipped the label ID lookup, even when the labels query param was
absent. Check the raw trimmed param for emptiness before splitting,
mirroring the pattern already used elsewhere in this file.

Fixes: https://github.com/go-gitea/gitea/issues/39889

Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
2026-10-20 12:19:50 -07:00
wxiaoguang 5838cc8172 fix(pulls): tolerate invalid stored review commit_id (#39849) (#39857)
Simple backport for #39849 to fix #39848
2026-10-21 01:46:56 +08:00
77 changed files with 880 additions and 367 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 - uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea gitea_fork: giteabot/gitea
+3 -3
View File
@@ -37,7 +37,7 @@ on:
checks: checks:
description: Comma-separated list of non-backport checks to run description: Comma-separated list of non-backport checks to run
required: false required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions default: backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions: {} permissions: {}
@@ -56,7 +56,7 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
- uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1 - uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} checks: ${{ github.event.inputs.checks || 'backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+1 -1
View File
@@ -3083,7 +3083,7 @@ LEVEL = Info
;LOG_RETENTION_DAYS = 365 ;LOG_RETENTION_DAYS = 365
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever. ;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded. ;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step. ;; Workflows can request a shorter retention period, down to 1 day.
;ARTIFACT_RETENTION_DAYS = 90 ;ARTIFACT_RETENTION_DAYS = 90
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit. ;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760 ;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
+1 -1
View File
@@ -2,7 +2,7 @@ module gitea.dev
go 1.27 go 1.27
toolchain go1.27.2 toolchain go1.27.3
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
+6 -1
View File
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp { func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
if requested.Has() { if requested.Has() {
return max(requested.Value(), artifactKeepForever+1) now := timeutil.TimeStampNow()
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
if setting.Actions.ArtifactRetentionDays > 0 {
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
}
return expiry
} }
if setting.Actions.ArtifactRetentionDays <= 0 { if setting.Actions.ArtifactRetentionDays <= 0 {
return artifactKeepForever return artifactKeepForever
+24
View File
@@ -5,6 +5,12 @@ package actions
import ( import (
"testing" "testing"
"time"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
} }
assert.Equal(t, []int64{1, 4, 5}, ids) assert.Equal(t, []int64{1, 4, 5}, ids)
} }
func TestArtifactExpiry(t *testing.T) {
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
now := timeutil.TimeStampNow()
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
requested := optional.Some[timeutil.TimeStamp]
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
setting.Actions.ArtifactRetentionDays = 0
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
assert.Equal(t, days(1), artifactExpiry(requested(now)))
}
+5 -5
View File
@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
// Since this sortType is dynamically created, it has to be treated specially. // Since this sortType is dynamically created, it has to be treated specially.
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok { if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
scope := after scope := after
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id") // a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%") " INNER JOIN label ON label.id = issue_label.label_id"+
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int) " WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id") Desc("issue.id")
return return
} }
+5 -2
View File
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
} }
// IsBlobAccessibleForUser tests if the user has access to the blob // IsBlobAccessibleForUser tests if the user has access to the blob
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) { func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
if user.IsAdmin { if user.IsAdmin && !publicOnly {
return true, nil return true, nil
} }
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic} ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}. Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))), And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
) )
if publicOnly {
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
}
return db.GetEngine(ctx). return db.GetEngine(ctx).
Table("package_blob"). Table("package_blob").
+6 -2
View File
@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"}) _, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
require.NoError(t, err) require.NoError(t, err)
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})) accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, accessible) assert.True(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})) accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
require.NoError(t, err)
assert.False(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, accessible) assert.False(t, accessible)
} }
+3
View File
@@ -112,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
} }
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) { func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
if watch.UserID <= 0 {
return nil
}
if watch.Mode == mode { if watch.Mode == mode {
return nil return nil
} }
+10 -4
View File
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watches, err := repo_model.GetWatchers(t.Context(), repo.ID) watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
assert.NoError(t, err) assert.NoError(t, err)
// One watchers are inactive, thus minus 1 // One watcher is inactive, thus minus 1
assert.Len(t, watches, repo.NumWatches-1) assert.Len(t, watches, repo.NumWatches-1)
for _, watch := range watches { for _, watch := range watches {
assert.Equal(t, repo.ID, watch.RepoID) assert.Equal(t, repo.ID, watch.RepoID)
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
setting.Service.AutoWatchOnChanges = true setting.Service.AutoWatchOnChanges = true
// Must not add watch // Must not add watch (user 8 has "don't watch" set)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount) assert.Len(t, watchers, prevCount)
// Should not add watch // Should not add watch (read operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount) assert.Len(t, watchers, prevCount)
// Should add watch // Should add watch (write operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1) assert.Len(t, watchers, prevCount+1)
// Should not add watch (system user should not be able to watch)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
// Should remove watch, inhibit from adding auto // Should remove watch, inhibit from adding auto
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false)) assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
+13 -17
View File
@@ -5,6 +5,7 @@ package git
import ( import (
"bufio" "bufio"
"cmp"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -200,22 +201,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
} }
if old { if old {
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64) begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
end, _ = strconv.ParseInt(groups["endOld"], 10, 64) end, _ = strconv.ParseInt(cmp.Or(groups["endOld"], "1"), 10, 64)
// init otherLine with begin of opposite side // init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64) otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
} else { } else {
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64) begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
if groups["endNew"] != "" { end, _ = strconv.ParseInt(cmp.Or(groups["endNew"], "1"), 10, 64)
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
} else {
end = 0
}
// init otherLine with begin of opposite side // init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64) otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
} }
end += begin // end is for real only the number of lines in hunk end += begin // end is for real only the number of lines in hunk
// lof is between begin and end // lof is between begin and end
if begin <= line && end >= line { if begin <= line && line < end {
hunk = append(hunk, lof) hunk = append(hunk, lof)
currentLine = begin currentLine = begin
continue continue
@@ -265,27 +262,26 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
oldBegin = otherLine oldBegin = otherLine
newBegin = currentLine newBegin = currentLine
} }
// headers + hunk header
newHunk := make([]string, headerLines)
// transfer existing headers
copy(newHunk, hunk[:headerLines])
// transfer last n lines
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
// calculate newBegin, ... by counting lines // calculate newBegin, ... by counting lines
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- { start := len(hunk)
switch hunk[i][0] { for count := 0; count < numbersOfLine && start > headerLines+1; {
start--
switch hunk[start][0] {
case '+': case '+':
newBegin-- newBegin--
newNumOfLines++ newNumOfLines++
case '-': case '-':
oldBegin-- oldBegin--
oldNumOfLines++ oldNumOfLines++
case '\\':
continue
default: default:
oldBegin-- oldBegin--
newBegin-- newBegin--
newNumOfLines++ newNumOfLines++
oldNumOfLines++ oldNumOfLines++
} }
count++
} }
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC" // "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
@@ -294,9 +290,9 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part) // For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
// construct the new hunk header // construct the new hunk header
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@", hunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
oldBegin, oldNumOfLines, newBegin, newNumOfLines) oldBegin, oldNumOfLines, newBegin, newNumOfLines)
return strings.Join(newHunk, "\n"), nil return strings.Join(append(hunk[:headerLines+1], hunk[start:]...), "\n"), nil
} }
// GetAffectedFiles returns the affected files between two commits // GetAffectedFiles returns the affected files between two commits
+9 -1
View File
@@ -120,10 +120,18 @@ func TestCutDiffAroundLine(t *testing.T) {
assert.Empty(t, emptyResult) assert.Empty(t, emptyResult)
// Line is out of scope // Line is out of scope
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0) emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 7, false, 3)
assert.NoError(t, err) assert.NoError(t, err)
assert.Empty(t, emptyResult) assert.Empty(t, emptyResult)
noNewlineDiff := "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a"
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, false, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+a", result)
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, true, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a", result)
// Handle minus diffs properly // Handle minus diffs properly
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4) minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
assert.NoError(t, err) assert.NoError(t, err)
+7 -3
View File
@@ -71,12 +71,12 @@ func DefaultFeatures() *Features {
func loadGitVersionFeatures() (*Features, error) { func loadGitVersionFeatures() (*Features, error) {
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background()) stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
if runErr != nil { if runErr != nil {
return nil, runErr return nil, fmt.Errorf(`failed to run command "git version": %w`, runErr)
} }
ver, err := parseGitVersionLine(strings.TrimSpace(stdout)) ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf(`failed to parse output of command "git version": %w`, err)
} }
features := &Features{gitVersion: ver, UsingGogit: isGogit} features := &Features{gitVersion: ver, UsingGogit: isGogit}
@@ -108,7 +108,11 @@ func parseGitVersionLine(s string) (*version.Version, error) {
if len(versionFields) > 3 { if len(versionFields) > 3 {
versionFields = versionFields[:3] versionFields = versionFields[:3]
} }
return version.NewVersion(strings.Join(versionFields, ".")) ver, err := version.NewVersion(strings.Join(versionFields, "."))
if err != nil {
return nil, fmt.Errorf("invalid git version: %q: %w", s, err)
}
return ver, nil
} }
func checkGitVersionCompatibility(gitVer *version.Version) error { func checkGitVersionCompatibility(gitVer *version.Version) error {
+1
View File
@@ -93,6 +93,7 @@ const (
StderrPathSpec StderrPrefix = "fatal: pathspec" StderrPathSpec StderrPrefix = "fatal: pathspec"
StderrBadRevision StderrPrefix = "fatal: bad revision" StderrBadRevision StderrPrefix = "fatal: bad revision"
StderrNoSuchPath StderrPrefix = "fatal: no such path" StderrNoSuchPath StderrPrefix = "fatal: no such path"
StderrBadObject StderrPrefix = "fatal: bad object "
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128 StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2 StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
+1 -1
View File
@@ -22,7 +22,7 @@ type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
// Client is used to communicate with a LFS source // Client is used to communicate with a LFS source
type Client interface { type Client interface {
BatchSize() int BatchSize() int
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error // ref is the full git ref name the objects belong to, may be empty
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
} }
+1 -1
View File
@@ -38,7 +38,7 @@ func (c *FilesystemClient) objectPath(oid string) (string, error) {
} }
// Download reads the specific LFS object from the target path // Download reads the specific LFS object from the target path
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { func (c *FilesystemClient) Download(ctx context.Context, _ string, objects []Pointer, callback DownloadCallback) error {
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
+12 -12
View File
@@ -67,18 +67,18 @@ func (c *HTTPClient) transferNames() []string {
return keys return keys
} }
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) { func (c *HTTPClient) batch(ctx context.Context, operation, ref string, objects []Pointer) (*BatchResponse, error) {
log.Trace("BATCH operation with objects: %v", objects) log.Trace("BATCH operation with objects: %v", objects)
url := c.endpoint + "/objects/batch" url := c.endpoint + "/objects/batch"
// Original: In some lfs server implementations, they require the ref attribute. #32838 // `ref` is optional, but some servers like aliyun require it, while others like Hugging Face reject an empty or null one
// `ref` is an "optional object describing the server ref that the objects belong to"
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37 // https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
// // Related issues: #32838, #33453, #39891
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453 request := &BatchRequest{Operation: operation, Transfers: c.transferNames(), Objects: objects}
request := &BatchRequest{operation, c.transferNames(), nil, objects} if ref != "" {
request.Ref = &Reference{Name: ref}
}
payload := new(bytes.Buffer) payload := new(bytes.Buffer)
err := json.NewEncoder(payload).Encode(request) err := json.NewEncoder(payload).Encode(request)
@@ -113,17 +113,17 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
} }
// Download reads the specific LFS object from the LFS server // Download reads the specific LFS object from the LFS server
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { func (c *HTTPClient) Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error {
return c.performOperation(ctx, objects, callback, nil) return c.performOperation(ctx, ref, objects, callback, nil)
} }
// Upload sends the specific LFS object to the LFS server // Upload sends the specific LFS object to the LFS server
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error { func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
return c.performOperation(ctx, objects, nil, callback) return c.performOperation(ctx, "", objects, nil, callback)
} }
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch // performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { func (c *HTTPClient) performOperation(ctx context.Context, ref string, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
if len(objects) == 0 { if len(objects) == 0 {
return nil return nil
} }
@@ -133,7 +133,7 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
operation = "upload" operation = "upload"
} }
result, err := c.batch(ctx, operation, objects) result, err := c.batch(ctx, operation, ref, objects)
if err != nil { if err != nil {
return err return err
} }
+3 -1
View File
@@ -175,6 +175,7 @@ func TestHTTPClientDownload(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "download", batchRequest.Operation) assert.Equal(t, "download", batchRequest.Operation)
assert.Equal(t, &Reference{Name: "refs/heads/main"}, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1) assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid) assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size) assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
@@ -248,7 +249,7 @@ func TestHTTPClientDownload(t *testing.T) {
}, },
} }
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error { err := client.Download(t.Context(), "refs/heads/main", []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
if objectError != nil { if objectError != nil {
return objectError return objectError
} }
@@ -283,6 +284,7 @@ func TestHTTPClientUpload(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "upload", batchRequest.Operation) assert.Equal(t, "upload", batchRequest.Operation)
assert.Nil(t, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1) assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid) assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size) assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
+13 -2
View File
@@ -4,17 +4,28 @@
package optional package optional
import ( import (
"reflect"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
func (o *Option[T]) assignUnmarshalledValue(v *T) {
if reflect.TypeFor[T]().Kind() == reflect.Pointer && v == nil {
var zero T
*o = Some[T](zero)
} else {
*o = FromPtr(v)
}
}
func (o *Option[T]) UnmarshalJSON(data []byte) error { func (o *Option[T]) UnmarshalJSON(data []byte) error {
var v *T var v *T
if err := json.Unmarshal(data, &v); err != nil { if err := json.Unmarshal(data, &v); err != nil {
return err return err
} }
*o = FromPtr(v) o.assignUnmarshalledValue(v)
return nil return nil
} }
@@ -31,7 +42,7 @@ func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&v); err != nil { if err := value.Decode(&v); err != nil {
return err return err
} }
*o = FromPtr(v) o.assignUnmarshalledValue(v)
return nil return nil
} }
+35
View File
@@ -6,6 +6,7 @@ package optional_test
import ( import (
std_json "encoding/json" //nolint:depguard // for testing purpose std_json "encoding/json" //nolint:depguard // for testing purpose
"testing" "testing"
"time"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
@@ -28,6 +29,14 @@ type testSerializationStruct struct {
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"` OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
} }
type testSerializationNullValue struct {
OptString optional.Option[string] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
type testSerializationNullPointer struct {
OptNull optional.Option[*time.Time] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
func TestOptionalToJson(t *testing.T) { func TestOptionalToJson(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -63,6 +72,32 @@ func TestOptionalToJson(t *testing.T) {
assert.Equal(t, tc.want, string(b), "std json module returned unexpected") assert.Equal(t, tc.want, string(b), "std json module returned unexpected")
}) })
} }
t.Run("NullValue", func(t *testing.T) {
var v1 testSerializationNullValue
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.False(t, v1.OptString.Has())
})
t.Run("NullPointer", func(t *testing.T) {
var v1 testSerializationNullPointer
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.True(t, v1.OptNull.Has())
assert.Nil(t, v1.OptNull.Value())
var v2 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{}`), &v2)
assert.NoError(t, err)
assert.False(t, v2.OptNull.Has())
var v3 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{"opt_null":"2006-01-02T15:04:05Z"}`), &v3)
assert.NoError(t, err)
assert.True(t, v3.OptNull.Has())
assert.NotNil(t, v3.OptNull.Value())
})
} }
func TestOptionalFromJson(t *testing.T) { func TestOptionalFromJson(t *testing.T) {
+17
View File
@@ -8,6 +8,8 @@ import (
"compress/gzip" "compress/gzip"
"io" "io"
"regexp" "regexp"
"slices"
"strings"
"sync" "sync"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -23,6 +25,8 @@ var (
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
// ErrInvalidVersion indicates an invalid version in the metadata.gz file // ErrInvalidVersion indicates an invalid version in the metadata.gz file
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
// ErrInvalidMetadata indicates metadata that would corrupt the generated index
ErrInvalidMetadata = util.NewInvalidArgumentErrorf("package metadata is invalid")
) )
var globalVars = sync.OnceValue(func() (ret struct { var globalVars = sync.OnceValue(func() (ret struct {
@@ -220,6 +224,19 @@ func parseMetadataFile(r io.Reader) (*Package, error) {
} }
} }
values := []string{m.Platform}
reqs := slices.Concat(m.RequiredRubyVersion, m.RequiredRubygemsVersion)
for _, dep := range slices.Concat(m.RuntimeDependencies, m.DevelopmentDependencies) {
values = append(values, dep.Name)
reqs = append(reqs, dep.Version...)
}
for _, req := range reqs {
values = append(values, req.Restriction, req.Version)
}
if slices.ContainsFunc(values, func(s string) bool { return strings.ContainsAny(s, "\r\n") }) {
return nil, ErrInvalidMetadata // the compact index is line-based, a line break would forge entries
}
return &Package{ return &Package{
Name: spec.Name, Name: spec.Name,
Version: spec.Version.Version, Version: spec.Version.Version,
@@ -43,6 +43,19 @@ version:
assert.Nil(t, rp) assert.Nil(t, rp)
} }
}) })
t.Run("LineBreakInIndexedMetadata", func(t *testing.T) {
for _, field := range []string{
`platform: "ruby\nx"`,
`dependencies: [{name: "d\nx"}]`,
`dependencies: [{name: d, requirement: {requirements: [[">=", {version: "1\nx"}]]}}]`,
`required_ruby_version: {requirements: [[">=", {version: "1\nx"}]]}`,
} {
rp, err := parseMetadataFile(test.CompressGzip("name: g\nversion:\n version: 1\n" + field + "\n"))
assert.ErrorIs(t, err, ErrInvalidMetadata, field)
assert.Nil(t, rp)
}
})
} }
func TestParseMetadataFile(t *testing.T) { func TestParseMetadataFile(t *testing.T) {
+6 -1
View File
@@ -67,8 +67,13 @@ func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *repo_model.Re
errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan) errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan)
}() }()
// some servers require `ref`, send the default branch for objects from all refs like
// `git lfs fetch --all` does on the main branch: it uses current branch as `ref`:
// https://github.com/git-lfs/git-lfs/blob/58a763d09d5fe4b7ea85c89ec799d71c5b0dc1da/commands/commands.go#L124-L137
// `man git-lfs-fetch` said that "--all" can fetch all objects, so the default branch should work
ref := git.RefNameFromBranch(repo.DefaultBranch).String()
downloadObjects := func(pointers []lfs.Pointer) error { downloadObjects := func(pointers []lfs.Pointer) error {
err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { err := lfsClient.Download(ctx, ref, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
if errors.Is(objectError, lfs.ErrObjectNotExist) { if errors.Is(objectError, lfs.ErrObjectNotExist) {
log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError) log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError)
return nil return nil
+3 -1
View File
@@ -56,7 +56,9 @@ type EditLabelOption struct {
// IssueLabelsOption a collection of labels // IssueLabelsOption a collection of labels
type IssueLabelsOption struct { type IssueLabelsOption struct {
// Labels can be a list of integers representing label IDs // Labels can be a list of integers representing label IDs
// or a list of strings representing label names // or a list of strings representing label names.
// If a repository label and an organization label share a name,
// the repository label is used.
Labels []any `json:"labels"` Labels []any `json:"labels"`
} }
+5 -2
View File
@@ -5,6 +5,8 @@ package structs
import ( import (
"time" "time"
"gitea.dev/modules/optional"
) )
// Milestone milestone is a collection of issues on one repository // Milestone milestone is a collection of issues on one repository
@@ -54,6 +56,7 @@ type EditMilestoneOption struct {
// State indicates the updated state of the milestone // State indicates the updated state of the milestone
// enum: ["open","closed"] // enum: ["open","closed"]
State *string `json:"state"` State *string `json:"state"`
// Deadline is the updated due date for the milestone // Deadline is the due date for the milestone, use null or empty string to remove the deadline
Deadline *time.Time `json:"due_on"` // swagger:strfmt date-time
Deadline optional.Option[*string] `json:"due_on,omitzero"`
} }
+1 -1
View File
@@ -3556,7 +3556,7 @@
"units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.", "units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.",
"units.error.unit_not_allowed": "You are not allowed to access this repository section.", "units.error.unit_not_allowed": "You are not allowed to access this repository section.",
"packages.title": "Packages", "packages.title": "Packages",
"packages.desc": "Manage repository packages.", "packages.desc": "Access all packages of the organization, regardless of the team's repositories.",
"packages.empty": "There are no packages yet.", "packages.empty": "There are no packages yet.",
"packages.no_metadata": "No metadata.", "packages.no_metadata": "No metadata.",
"packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.", "packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
+5
View File
@@ -34,6 +34,11 @@ export default defineConfig({
name: 'firefox', name: 'firefox',
use: { use: {
...devices['Desktop Firefox'], ...devices['Desktop Firefox'],
launchOptions: {
firefoxUserPrefs: {
'dom.events.testing.asyncClipboard': true, // disable paste prompt
},
},
}, },
}, },
], ],
+19 -1
View File
@@ -5,6 +5,7 @@ package runner
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"strings" "strings"
"time" "time"
@@ -17,6 +18,7 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"connectrpc.com/connect" "connectrpc.com/connect"
lru "github.com/hashicorp/golang-lru/v2"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@@ -26,6 +28,10 @@ const (
tokenHeaderKey = protocol.TokenHeader tokenHeaderKey = protocol.TokenHeader
) )
type runnerTokenHash struct{ salt, hash string }
var verifiedRunnerTokens, _ = lru.New[runnerTokenHash, [sha256.Size]byte](10000) // not auth_model.TokenCache, whose small default size thrashes once more runners poll in turn
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
methodName := getMethodName(request) methodName := getMethodName(request)
@@ -42,7 +48,7 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
} }
return nil, status.Error(codes.Internal, err.Error()) return nil, status.Error(codes.Internal, err.Error())
} }
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { if !verifyRunnerToken(runner, token) {
return nil, status.Error(codes.Unauthenticated, "unregistered runner") return nil, status.Error(codes.Unauthenticated, "unregistered runner")
} }
@@ -73,6 +79,18 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
} }
})) }))
func verifyRunnerToken(runner *actions_model.ActionRunner, token string) bool {
key, digest := runnerTokenHash{runner.TokenSalt, runner.TokenHash}, sha256.Sum256([]byte(token))
if cached, ok := verifiedRunnerTokens.Get(key); ok && util.CryptoConstTimeEqual(cached[:], digest[:]) {
return true
}
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) {
return false
}
verifiedRunnerTokens.Add(key, digest)
return true
}
func getMethodName(req connect.AnyRequest) string { func getMethodName(req connect.AnyRequest) string {
splits := strings.Split(req.Spec().Procedure, "/") splits := strings.Split(req.Spec().Procedure, "/")
if len(splits) > 0 { if len(splits) > 0 {
@@ -0,0 +1,26 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"crypto/sha256"
"testing"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"github.com/stretchr/testify/assert"
)
func TestVerifyRunnerToken(t *testing.T) {
verifiedRunnerTokens.Purge()
runner := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("token", "salt")}
assert.True(t, verifyRunnerToken(runner, "token"))
assert.True(t, verifyRunnerToken(runner, "token"))
assert.False(t, verifyRunnerToken(runner, "wrong"))
other := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("other", "salt")}
assert.False(t, verifyRunnerToken(other, "token"))
assert.Equal(t, [][sha256.Size]byte{sha256.Sum256([]byte("token"))}, verifiedRunnerTokens.Values())
}
+1 -1
View File
@@ -244,7 +244,7 @@ func PostBlobsUploads(ctx *context.Context) {
Digest: mount, Digest: mount,
}) })
if blob != nil { if blob != nil {
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer) accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer, context.TokenIsPublicOnly(ctx))
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
+8 -4
View File
@@ -492,6 +492,9 @@ func reqAnyRepoReader() func(ctx *context.APIContext) {
// reqOrgOwnership user should be an organization owner, or a site admin // reqOrgOwnership user should be an organization owner, or a site admin
func reqOrgOwnership() func(ctx *context.APIContext) { func reqOrgOwnership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
if rejectPublicOnly()(ctx); ctx.Written() { // org ownership reaches the org's private repos (hooks, teams, secrets)
return
}
if ctx.IsUserSiteAdmin() { if ctx.IsUserSiteAdmin() {
return return
} }
@@ -1283,6 +1286,7 @@ func Routes() *web.Router {
// FIXME: we need org in context // FIXME: we need org in context
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
reqToken(), reqToken(),
rejectPublicOnly(),
bind(api.CreateRepoOption{}), bind(api.CreateRepoOption{}),
repo.CreateOrgRepoDeprecated) repo.CreateOrgRepoDeprecated)
@@ -1303,7 +1307,7 @@ func Routes() *web.Router {
m.Combo("").Get(reqAnyRepoReader(), repo.Get). m.Combo("").Get(reqAnyRepoReader(), repo.Get).
Delete(reqToken(), reqRepoDangerZone(), repo.Delete). Delete(reqToken(), reqRepoDangerZone(), repo.Delete).
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) m.Post("/generate", reqToken(), rejectPublicOnly(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
m.Group("/transfer", func() { m.Group("/transfer", func() {
m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer) m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer)
m.Post("/accept", repo.AcceptTransfer) m.Post("/accept", repo.AcceptTransfer)
@@ -1777,7 +1781,7 @@ func Routes() *web.Router {
m.Get("", reqToken(), org.ListUserOrgs) m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker)
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), rejectPublicOnly(), bind(api.CreateOrgOption{}), org.Create)
m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll) m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll)
m.Group("/orgs/{org}", func() { m.Group("/orgs/{org}", func() {
m.Combo("").Get(org.Get). m.Combo("").Get(org.Get).
@@ -1785,7 +1789,7 @@ func Routes() *web.Router {
Delete(reqToken(), reqOrgOwnership(), org.Delete) Delete(reqToken(), reqOrgOwnership(), org.Delete)
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename) m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
m.Combo("/repos").Get(user.ListOrgRepos). m.Combo("/repos").Get(user.ListOrgRepos).
Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), bind(api.CreateRepoOption{}), repo.CreateOrgRepo). Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo).
Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos) Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos)
m.Group("/members", func() { m.Group("/members", func() {
m.Get("", reqToken(), org.ListMembers) m.Get("", reqToken(), org.ListMembers)
@@ -1921,7 +1925,7 @@ func Routes() *web.Router {
m.Get("/runs", admin.ListWorkflowRuns) m.Get("/runs", admin.ListWorkflowRuns)
m.Get("/jobs", admin.ListWorkflowJobs) m.Get("/jobs", admin.ListWorkflowJobs)
}) })
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin(), rejectPublicOnly())
m.Group("/topics", func() { m.Group("/topics", func() {
m.Get("/search", repo.TopicSearch) m.Get("/search", repo.TopicSearch)
+1 -8
View File
@@ -12,7 +12,6 @@ import (
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access" access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -166,13 +165,7 @@ func CreateFork(ctx *context.APIContext) {
Description: repo.Description, Description: repo.Description,
}) })
if err != nil { if err != nil {
if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { ctx.APIErrorAuto(err)
ctx.APIError(http.StatusConflict, err.Error())
} else if errors.Is(err, user_model.ErrBlockedUser) {
ctx.APIError(http.StatusForbidden, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
+28 -31
View File
@@ -337,8 +337,8 @@ func ListIssues(ctx *context.APIContext) {
keyword := ctx.FormTrim("q") keyword := ctx.FormTrim("q")
var labelIDs []int64 var labelIDs []int64
if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { if labelsParam := ctx.FormTrim("labels"); labelsParam != "" {
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, strings.Split(labelsParam, ","))
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -513,17 +513,12 @@ func GetIssue(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { if err := issue.LoadAttributes(ctx); err != nil {
ctx.APIErrorNotFound() ctx.APIErrorInternal(err)
return return
} }
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue)) ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
@@ -698,19 +693,13 @@ func EditIssue(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
form := web.GetForm[*api.EditIssueOption](ctx) form := web.GetForm[*api.EditIssueOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
issue.Repo = ctx.Repo.Repository
canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
err = issue.LoadAttributes(ctx) err := issue.LoadAttributes(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -901,13 +890,12 @@ func DeleteIssue(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
if err = issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil { if err := issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
@@ -953,13 +941,8 @@ func UpdateIssueDeadline(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm[*api.EditDeadlineOption](ctx) form := web.GetForm[*api.EditDeadlineOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -999,3 +982,17 @@ func closeOrReopenIssue(ctx *context.APIContext, issue *issues_model.Issue, stat
} }
} }
} }
func getRepoIssueByIndexForDoer(ctx *context.APIContext, index int64) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, index)
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
+4 -11
View File
@@ -146,14 +146,8 @@ func CheckIssueAssignee(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return return
} }
@@ -187,9 +181,8 @@ func checkAssignableUser(ctx *context.APIContext, assigneeName string, repo *rep
} }
func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) { func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
+4 -16
View File
@@ -57,7 +57,7 @@ func GetIssueAttachment(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -100,7 +100,7 @@ func ListIssueAttachments(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -163,7 +163,7 @@ func CreateIssueAttachment(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -333,20 +333,8 @@ func DeleteIssueAttachment(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }
func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return nil return nil
} }
+6 -21
View File
@@ -69,17 +69,10 @@ func ListIssueComments(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return
}
issue.Repo = ctx.Repo.Repository
opts := &issues_model.FindCommentsOptions{ opts := &issues_model.FindCommentsOptions{
IssueID: issue.ID, IssueID: issue.ID,
@@ -173,12 +166,10 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return return
} }
issue.Repo = ctx.Repo.Repository
opts := &issues_model.FindCommentsOptions{ opts := &issues_model.FindCommentsOptions{
ListOptions: utils.GetListOptions(ctx), ListOptions: utils.GetListOptions(ctx),
@@ -381,14 +372,8 @@ func CreateIssueComment(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
form := web.GetForm[*api.CreateIssueCommentOption](ctx) form := web.GetForm[*api.CreateIssueCommentOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return return
} }
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"testing"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestIssueReadEndpointsRequireUnitPermission(t *testing.T) {
unittest.PrepareTestEnv(t)
for name, handler := range map[string]func(*context.APIContext){
"timeline": ListIssueCommentsAndTimeline,
"labels": ListIssueLabels,
"subscribers": GetIssueSubscribers,
"attachments": ListIssueAttachments,
} {
t.Run(name, func(t *testing.T) {
request := func(index string) int {
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
ctx.Repo.Permission = access_model.Permission{}
ctx.Repo.Permission.SetUnitsWithDefaultAccessMode([]*repo_model.RepoUnit{{Type: unit.TypeIssues}}, perm_model.AccessModeRead)
ctx.SetPathParam("index", index)
handler(ctx)
return resp.Code
}
assert.Equal(t, http.StatusOK, request("1"))
assert.Equal(t, http.StatusNotFound, request("2"))
})
}
}
+8 -30
View File
@@ -61,15 +61,8 @@ func GetIssueDependencies(ctx *context.APIContext) {
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return
}
// 1. We must be able to read this issue
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return return
} }
@@ -79,7 +72,7 @@ func GetIssueDependencies(ctx *context.APIContext) {
blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems)) blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems))
// 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>` // Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions) blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -177,7 +170,7 @@ func CreateIssueDependency(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
// We want to make <:index> depend on <Form>, i.e. <:index> is the target // We want to make <:index> depend on <Form>, i.e. <:index> is the target
target := getParamsIssue(ctx) target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -238,7 +231,7 @@ func RemoveIssueDependency(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
// We want to make <:index> depend on <Form>, i.e. <:index> is the target // We want to make <:index> depend on <Form>, i.e. <:index> is the target
target := getParamsIssue(ctx) target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -303,16 +296,11 @@ func GetIssueBlocks(ctx *context.APIContext) {
// We need to list the issues that DEPEND on this issue not the other way round // We need to list the issues that DEPEND on this issue not the other way round
// Therefore whether dependencies are enabled or not in this repository is potentially irrelevant. // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant.
issue := getParamsIssue(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return
}
page := max(ctx.FormInt("page"), 1) page := max(ctx.FormInt("page"), 1)
limit := ctx.FormInt("limit") limit := ctx.FormInt("limit")
if limit <= 1 { if limit <= 1 {
@@ -399,7 +387,7 @@ func CreateIssueBlocking(ctx *context.APIContext) {
// "404": // "404":
// description: the issue does not exist // description: the issue does not exist
dependency := getParamsIssue(ctx) dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -456,7 +444,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
dependency := getParamsIssue(ctx) dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -480,16 +468,6 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
} }
func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue { func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue {
var repo *repo_model.Repository var repo *repo_model.Repository
if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name { if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name {
+23 -31
View File
@@ -10,6 +10,7 @@ import (
"reflect" "reflect"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/modules/container"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/services/context" "gitea.dev/services/context"
@@ -47,13 +48,8 @@ func ListIssueLabels(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -167,13 +163,8 @@ func DeleteIssueLabel(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -292,13 +283,8 @@ func ClearIssueLabels(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -316,14 +302,9 @@ func ClearIssueLabels(ctx *context.APIContext) {
} }
func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) { func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) { return nil, nil, errors.New("issue not found")
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil, err
} }
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) { if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
@@ -359,12 +340,23 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
} }
labelIDs = append(labelIDs, repoLabelIDs...) labelIDs = append(labelIDs, repoLabelIDs...)
if ctx.Repo.Owner.IsOrganization() { if ctx.Repo.Owner.IsOrganization() {
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, labelNames) repoLabels, err := issues_model.GetLabelsByIDs(ctx, repoLabelIDs, "name")
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return nil, nil, err return nil, nil, err
} }
labelIDs = append(labelIDs, orgLabelIDs...) orgLabelNames := container.SetOf(labelNames...)
for _, label := range repoLabels {
orgLabelNames.Remove(label.Name)
}
if len(orgLabelNames) > 0 {
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, orgLabelNames.Values())
if err != nil {
ctx.APIErrorInternal(err)
return nil, nil, err
}
labelIDs = append(labelIDs, orgLabelIDs...)
}
} }
} }
+6 -8
View File
@@ -51,9 +51,8 @@ func LockIssue(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
reason := web.GetForm[*api.LockIssueOption](ctx).Reason reason := web.GetForm[*api.LockIssueOption](ctx).Reason
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -70,7 +69,7 @@ func LockIssue(ctx *context.APIContext) {
} }
issue.Repo = ctx.Repo.Repository issue.Repo = ctx.Repo.Repository
err = issues_model.LockIssue(ctx, opt) err := issues_model.LockIssue(ctx, opt)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -114,9 +113,8 @@ func UnlockIssue(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -132,7 +130,7 @@ func UnlockIssue(ctx *context.APIContext) {
} }
issue.Repo = ctx.Repo.Repository issue.Repo = ctx.Repo.Repository
err = issues_model.UnlockIssue(ctx, opt) err := issues_model.UnlockIssue(ctx, opt)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+9 -26
View File
@@ -41,20 +41,13 @@ func PinIssue(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else if issues_model.IsErrIssueMaxPinReached(err) {
ctx.APIError(http.StatusBadRequest, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
// If we don't do this, it will crash when trying to add the pin event to the comment history // If we don't do this, it will crash when trying to add the pin event to the comment history
err = issue.LoadRepo(ctx) err := issue.LoadRepo(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -98,18 +91,13 @@ func UnpinIssue(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
// If we don't do this, it will crash when trying to add the unpin event to the comment history // If we don't do this, it will crash when trying to add the unpin event to the comment history
err = issue.LoadRepo(ctx) err := issue.LoadRepo(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -159,17 +147,12 @@ func MoveIssuePin(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
err = issues_model.MovePin(ctx, issue, ctx.PathParamInt("position")) err := issues_model.MovePin(ctx, issue, ctx.PathParamInt("position"))
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+2 -7
View File
@@ -172,13 +172,8 @@ func DeleteIssueStopwatch(ctx *context.APIContext) {
} }
func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue { func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil return nil
} }
+6 -24
View File
@@ -104,14 +104,8 @@ func DelIssueSubscription(ctx *context.APIContext) {
} }
func setIssueSubscription(ctx *context.APIContext, watch bool) { func setIssueSubscription(ctx *context.APIContext, watch bool) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -185,14 +179,8 @@ func CheckIssueSubscription(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -251,14 +239,8 @@ func GetIssueSubscribers(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
+11 -13
View File
@@ -75,9 +75,8 @@ func ListTrackedTimes(ctx *context.APIContext) {
ctx.APIErrorNotFound("timetracker is disabled") ctx.APIErrorNotFound("timetracker is disabled")
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -100,6 +99,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
opts.UserID = user.ID opts.UserID = user.ID
} }
var err error
if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
@@ -200,9 +200,8 @@ func AddTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm[*api.AddTimeOption](ctx) form := web.GetForm[*api.AddTimeOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -219,6 +218,7 @@ func AddTime(ctx *context.APIContext) {
if form.User != "" { if form.User != "" {
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin { if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
// allow only RepoAdmin, Admin and User to add time // allow only RepoAdmin, Admin and User to add time
var err error
user, err = user_model.GetUserByName(ctx, form.User) user, err = user_model.GetUserByName(ctx, form.User)
if err != nil { if err != nil {
ctx.APIErrorAuto(err) ctx.APIErrorAuto(err)
@@ -280,9 +280,8 @@ func ResetIssueTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -295,7 +294,7 @@ func ResetIssueTime(ctx *context.APIContext) {
return return
} }
err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer) err := issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer)
if err != nil { if err != nil {
if db.IsErrNotExist(err) { if db.IsErrNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error()) ctx.APIError(http.StatusNotFound, err.Error())
@@ -349,9 +348,8 @@ func DeleteTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
+4 -1
View File
@@ -219,7 +219,10 @@ func EditMilestone(ctx *context.APIContext) {
if form.Description != nil { if form.Description != nil {
milestone.Content = *form.Description milestone.Content = *form.Description
} }
milestone.DeadlineUnix, _ = common.ParseAPIDeadlineToEndOfDay(form.Deadline) if _, err := common.ApplyAPIDeadlineTimestamp(&milestone.DeadlineUnix, form.Deadline, nil); err != nil {
ctx.APIErrorAuto(err)
return
}
oldIsClosed := milestone.IsClosed oldIsClosed := milestone.IsClosed
if form.State != nil { if form.State != nil {
+2 -1
View File
@@ -9,6 +9,7 @@ import (
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"gitea.dev/routers/api/v1/utils" "gitea.dev/routers/api/v1/utils"
"gitea.dev/services/context" "gitea.dev/services/context"
"gitea.dev/services/convert" "gitea.dev/services/convert"
@@ -154,7 +155,7 @@ func GetUserHeatmapData(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, util.Iif(ctx.PublicOnly, nil, ctx.Doer))
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+33
View File
@@ -6,8 +6,10 @@ package common
import ( import (
"time" "time"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
) )
func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) { func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) {
@@ -29,3 +31,34 @@ func ParseAPIDeadlineToEndOfDay(t *time.Time) (timeutil.TimeStamp, error) {
deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation) deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation)
return timeutil.TimeStamp(deadline.Unix()), nil return timeutil.TimeStamp(deadline.Unix()), nil
} }
func ApplyAPIDeadlineTimestamp(field *timeutil.TimeStamp, input optional.Option[*string], remove *bool) (changed bool, err error) {
old := *field
if remove != nil && *remove || input.Has() && (input.Value() == nil || *input.Value() == "") {
*field = 0
return old != *field, nil
}
if !input.Has() {
return false, nil
}
inputValue := *input.Value()
formats := []string{
"2006-01-02",
"2006-01-02 15:04:05",
time.RFC3339,
}
for _, format := range formats {
t, err := time.ParseInLocation(format, inputValue, setting.DefaultUILocation)
if err == nil {
ts := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation).Unix()
if ts < 86400*2 {
ts = 0 // tolerate timezone difference between server and client
}
*field = timeutil.TimeStamp(ts)
return old != *field, nil
}
}
return false, util.NewInvalidArgumentErrorf("invalid date format: %s", inputValue)
}
+15 -16
View File
@@ -121,16 +121,7 @@ func InfoOAuth(ctx *context.Context) {
Picture: ctx.Doer.AvatarLink(ctx), Picture: ctx.Doer.AvatarLink(ctx),
} }
var accessTokenScope auth.AccessTokenScope onlyPublicGroups, _ := tokenScope.PublicOnly()
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
accessTokenScope, _, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
}
}
// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
if err != nil { if err != nil {
ctx.ServerError("Oauth groups for user", err) ctx.ServerError("Oauth groups for user", err)
@@ -141,12 +132,21 @@ func InfoOAuth(ctx *context.Context) {
ctx.JSON(http.StatusOK, response) ctx.JSON(http.StatusOK, response)
} }
// RFC 6749 section 2.3.1: client_secret_basic credentials are form-urlencoded
func parseClientSecretBasic(authHeader string) (clientID, clientSecret string, ok bool) {
parsed, _ := httpauth.ParseAuthorizationHeader(authHeader)
if parsed.BasicAuth == nil {
return "", "", false
}
clientID, errID := url.QueryUnescape(parsed.BasicAuth.Username)
clientSecret, errSecret := url.QueryUnescape(parsed.BasicAuth.Password)
return clientID, clientSecret, errID == nil && errSecret == nil
}
// IntrospectOAuth introspects an oauth token // IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) { func IntrospectOAuth(ctx *context.Context) {
var introspectingApp *auth.OAuth2Application var introspectingApp *auth.OAuth2Application
authHeader := ctx.Req.Header.Get("Authorization") if clientID, clientSecret, ok := parseClientSecretBasic(ctx.Req.Header.Get("Authorization")); ok {
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) { if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details // this is likely a database error; log it and respond without details
@@ -504,15 +504,14 @@ func AccessTokenOAuth(ctx *context.Context) {
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
if form.ClientID == "" || form.ClientSecret == "" { if form.ClientID == "" || form.ClientSecret == "" {
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" { if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) clientID, clientSecret, ok := parseClientSecretBasic(authHeader)
if !ok || parsed.BasicAuth == nil { if !ok {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest, ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header", ErrorDescription: "cannot parse basic auth header",
}) })
return return
} }
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// validate that any fields present in the form match the Basic auth header // validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID { if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/base64"
"net/http"
"strings"
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/test"
"gitea.dev/modules/web"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"gitea.dev/services/oauth2_provider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOAuth2FormEncodedBasicAuth(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
require.NoError(t, err)
defer test.MockVariableValue(&oauth2_provider.DefaultSigningKey, signingKey)()
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
const fixtureClientSecretPlaintext = "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA="
escape := strings.NewReplacer("-", "%2D", "=", "%3D").Replace
t.Run("AccessToken accepts client matching body client_id and rejects unknown code", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "POST /login/oauth/access_token")
ctx.Req.SetBasicAuth(escape(app.ClientID), escape(fixtureClientSecretPlaintext))
web.SetForm(ctx, &forms.AccessTokenForm{GrantType: "authorization_code", ClientID: app.ClientID})
AccessTokenOAuth(ctx)
var accessTokenErr oauth2_provider.AccessTokenError
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &accessTokenErr))
assert.Equal(t, "client is not authorized", accessTokenErr.ErrorDescription)
})
t.Run("Introspect accepts client and reports inactive token", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "POST /login/oauth/introspect")
ctx.Req.SetBasicAuth(escape(app.ClientID), escape(fixtureClientSecretPlaintext))
web.SetForm(ctx, &forms.IntrospectTokenForm{Token: "invalid"})
IntrospectOAuth(ctx)
assert.Equal(t, http.StatusOK, resp.Code)
})
}
func TestParseClientSecretBasic(t *testing.T) {
basicAuth := func(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
clientID, clientSecret, ok := parseClientSecretBasic(basicAuth("a+b", "c%2Bd"))
assert.True(t, ok)
assert.Equal(t, "a b", clientID)
assert.Equal(t, "c+d", clientSecret)
_, _, ok = parseClientSecretBasic(basicAuth("a%zz", "c"))
assert.False(t, ok)
_, _, ok = parseClientSecretBasic(basicAuth("a", "c%zz"))
assert.False(t, ok)
_, _, ok = parseClientSecretBasic("Bearer token")
assert.False(t, ok)
}
+16 -13
View File
@@ -15,6 +15,7 @@ import (
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
"gitea.dev/models/renderhelper" "gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -276,27 +277,29 @@ func GetFeedType(name string, req *http.Request) (showFeed bool, feedType string
// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item // feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item
func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) { func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) {
canReadReleases := ctx.Repo.Permission.CanRead(unit.TypeReleases)
for _, rel := range releases { for _, rel := range releases {
err := rel.LoadAttributes(ctx) err := rel.LoadAttributes(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var title string
var content template.HTML var content template.HTML
title := rel.TagName
if rel.IsTag {
title = rel.TagName
} else {
title = rel.Title
}
link := &feeds.Link{Href: rel.HTMLURL()} link := &feeds.Link{Href: rel.HTMLURL()}
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
content, err = markdown.RenderString(rctx, if !canReadReleases {
rel.Note) // the tags feed only requires code access, so list releases as plain tags like the tags page does
if err != nil { link.Href = rel.Repo.HTMLURL() + "/src/tag/" + util.PathEscapeSegments(rel.TagName)
return nil, err } else {
if !rel.IsTag {
title = rel.Title
}
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
content, err = markdown.RenderString(rctx, rel.Note)
if err != nil {
return nil, err
}
} }
items = append(items, &feeds.Item{ items = append(items, &feeds.Item{
+1 -1
View File
@@ -148,7 +148,7 @@ func orgHome(ctx *context.Context, viewRepositories bool) {
Keyword: keyword, Keyword: keyword,
OwnerID: org.ID, OwnerID: org.ID,
OrderBy: orderBy, OrderBy: orderBy,
Private: ctx.IsSigned, Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
Actor: ctx.Doer, Actor: ctx.Doer,
Language: language, Language: language,
IncludeDescription: setting.UI.SearchRepoDescription, IncludeDescription: setting.UI.SearchRepoDescription,
+1 -1
View File
@@ -166,7 +166,7 @@ func loadHeaderCount(ctx *context.Context) error {
repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{ repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
OwnerID: ctx.ContextUser.ID, OwnerID: ctx.ContextUser.ID,
Private: ctx.IsSigned, Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
Collaborate: optional.Some(false), Collaborate: optional.Some(false),
IncludeDescription: setting.UI.SearchRepoDescription, IncludeDescription: setting.UI.SearchRepoDescription,
}) })
+4 -1
View File
@@ -714,7 +714,7 @@ func UsernameSubRoute(ctx *context.Context) {
} }
// check view permissions // check view permissions
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) || (context.TokenIsPublicOnly(ctx) && !ctx.ContextUser.Visibility.IsPublic()) {
ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name)) ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name))
return false return false
} }
@@ -743,6 +743,9 @@ func UsernameSubRoute(ctx *context.Context) {
} }
default: default:
context.UserAssignmentWeb()(ctx) context.UserAssignmentWeb()(ctx)
if !ctx.Written() && context.TokenIsPublicOnly(ctx) && !ctx.ContextUser.Visibility.IsPublic() {
ctx.NotFound(nil)
}
if !ctx.Written() { if !ctx.Written() {
ctx.Data["EnableFeed"] = setting.Other.EnableFeed ctx.Data["EnableFeed"] = setting.Other.EnableFeed
OwnerProfile(ctx) OwnerProfile(ctx)
+3 -3
View File
@@ -203,7 +203,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
Actor: ctx.Doer, Actor: ctx.Doer,
Keyword: keyword, Keyword: keyword,
OrderBy: orderBy, OrderBy: orderBy,
Private: ctx.IsSigned, Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
StarredByID: ctx.ContextUser.ID, StarredByID: ctx.ContextUser.ID,
Collaborate: optional.Some(false), Collaborate: optional.Some(false),
TopicOnly: topicOnly, TopicOnly: topicOnly,
@@ -230,7 +230,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
Actor: ctx.Doer, Actor: ctx.Doer,
Keyword: keyword, Keyword: keyword,
OrderBy: orderBy, OrderBy: orderBy,
Private: ctx.IsSigned, Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
WatchedByID: ctx.ContextUser.ID, WatchedByID: ctx.ContextUser.ID,
Collaborate: optional.Some(false), Collaborate: optional.Some(false),
TopicOnly: topicOnly, TopicOnly: topicOnly,
@@ -290,7 +290,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
Keyword: keyword, Keyword: keyword,
OwnerID: ctx.ContextUser.ID, OwnerID: ctx.ContextUser.ID,
OrderBy: orderBy, OrderBy: orderBy,
Private: ctx.IsSigned, Private: ctx.IsSigned && !context.TokenIsPublicOnly(ctx),
Collaborate: optional.Some(false), Collaborate: optional.Some(false),
TopicOnly: topicOnly, TopicOnly: topicOnly,
Language: language, Language: language,
+2 -1
View File
@@ -123,7 +123,7 @@ func TestCleanupRetentionZeroKeepsForever(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Zero(t, art.ExpiredUnix) assert.Zero(t, art.ExpiredUnix)
// a workflow-requested expiry is honored, only the instance default may mean never // a workflow-requested expiry is bounded, only the instance default may mean never
asked, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.Some(timeutil.TimeStampNow())) asked, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.Some(timeutil.TimeStampNow()))
require.NoError(t, err) require.NoError(t, err)
assert.Positive(t, asked.ExpiredUnix) assert.Positive(t, asked.ExpiredUnix)
@@ -141,6 +141,7 @@ func TestCleanupRetentionZeroKeepsForever(t *testing.T) {
_, err = db.GetEngine(t.Context()).In("id", art.ID, past.ID).Cols("status"). _, err = db.GetEngine(t.Context()).In("id", art.ID, past.ID).Cols("status").
Update(&actions_model.ActionArtifact{Status: actions_model.ArtifactStatusUploadConfirmed}) Update(&actions_model.ActionArtifact{Status: actions_model.ArtifactStatusUploadConfirmed})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, actions_model.UpdateArtifact(t.Context(), &actions_model.ActionArtifact{ID: past.ID, ExpiredUnix: 1}, "expired_unix"))
expiring, err := actions_model.ListNeedExpiredArtifacts(t.Context()) expiring, err := actions_model.ListNeedExpiredArtifacts(t.Context())
require.NoError(t, err) require.NoError(t, err)
ids := container.FilterSlice(expiring, func(a *actions_model.ActionArtifact) (int64, bool) { return a.ID, true }) ids := container.FilterSlice(expiring, func(a *actions_model.ActionArtifact) (int64, bool) { return a.ID, true })
+6 -1
View File
@@ -64,6 +64,11 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
refProtected = false refProtected = false
} }
retentionDays := "" // not "0", v3 artifact uploads would read it as a 0-day limit
if setting.Actions.ArtifactRetentionDays > 0 {
retentionDays = strconv.FormatInt(setting.Actions.ArtifactRetentionDays, 10)
}
gitContext := GiteaContext{ gitContext := GiteaContext{
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2. "action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
@@ -89,7 +94,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World. "repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. "repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. "repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. "retention_days": retentionDays, // string, The number of days that artifacts are kept, empty when kept forever.
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. "run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. "run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. "run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
+6
View File
@@ -16,6 +16,7 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions" actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
webhook_module "gitea.dev/modules/webhook" webhook_module "gitea.dev/modules/webhook"
@@ -408,6 +409,11 @@ func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
assert.Equal(t, "refs/heads/main", giteaCtx["ref"]) assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
assert.Equal(t, "main", giteaCtx["ref_name"]) assert.Equal(t, "main", giteaCtx["ref_name"])
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 7)()
assert.Equal(t, "7", GenerateGiteaContext(t.Context(), run, nil, nil)["retention_days"])
setting.Actions.ArtifactRetentionDays = 0
assert.Empty(t, GenerateGiteaContext(t.Context(), run, nil, nil)["retention_days"])
} }
func TestGenerateGiteaContextRefProtected(t *testing.T) { func TestGenerateGiteaContextRefProtected(t *testing.T) {
+16
View File
@@ -1620,6 +1620,22 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error)
return diff, nil return diff, nil
} }
// GetCodeCommentPatch returns the code snippet stored with a code comment
func GetCodeCommentPatch(ctx context.Context, gitRepo *git.Repository, mergeBase, headCommitID, treePath string, line int64) (string, error) {
patch, err := git.GetFileDiffCutAroundLine(ctx, gitRepo, mergeBase, headCommitID, treePath,
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
)
contextCommitID := util.Iif(line < 0, mergeBase, headCommitID)
if err != nil || patch != "" || contextCommitID == "" {
return patch, err
}
patch, err = GeneratePatchForUnchangedLine(ctx, gitRepo, contextCommitID, treePath, line, setting.UI.CodeCommentLines)
if err != nil {
log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, contextCommitID, err)
}
return patch, nil
}
// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line // GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line
func GeneratePatchForUnchangedLine(ctx context.Context, gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) { func GeneratePatchForUnchangedLine(ctx context.Context, gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) {
commit, err := gitRepo.GetCommit(ctx, commitID) commit, err := gitRepo.GetCommit(ctx, commitID)
+2 -4
View File
@@ -31,6 +31,7 @@ import (
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/uri" "gitea.dev/modules/uri"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/services/gitdiff"
"gitea.dev/services/pull" "gitea.dev/services/pull"
repo_service "gitea.dev/services/repository" repo_service "gitea.dev/services/repository"
) )
@@ -905,10 +906,7 @@ func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base
// SECURITY: The TreePath must be cleaned! use relative path // SECURITY: The TreePath must be cleaned! use relative path
comment.TreePath = util.PathJoinRel(comment.TreePath) comment.TreePath = util.PathJoinRel(comment.TreePath)
patch, _ := git.GetFileDiffCutAroundLine(ctx, patch, _ := gitdiff.GetCodeCommentPatch(ctx, g.gitRepo, pr.MergeBase, headCommitID, comment.TreePath, int64(line+comment.Position-1))
g.gitRepo, pr.MergeBase, headCommitID, comment.TreePath,
int64((&issues_model.Comment{Line: int64(line + comment.Position - 1)}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
)
if comment.CreatedAt.IsZero() { if comment.CreatedAt.IsZero() {
comment.CreatedAt = review.CreatedAt comment.CreatedAt = review.CreatedAt
+16 -2
View File
@@ -74,6 +74,10 @@ type GithubDownloaderV3 struct {
maxPerPage int maxPerPage int
SkipReactions bool SkipReactions bool
SkipReviews bool SkipReviews bool
issuePage int
issueCursor string
nextIssueCursor string
} }
// NewGithubDownloaderV3 creates a github Downloader via github v3 API // NewGithubDownloaderV3 creates a github Downloader via github v3 API
@@ -428,12 +432,21 @@ func (g *GithubDownloaderV3) GetIssues(ctx context.Context, page, perPage int) (
Page: page, Page: page,
}, },
} }
switch page {
case g.issuePage:
opt.After = g.issueCursor
case g.issuePage + 1:
opt.After = g.nextIssueCursor
}
if opt.After != "" {
opt.ListOptions.Page = 0
}
allIssues := make([]*base.Issue, 0, perPage) allIssues := make([]*base.Issue, 0, perPage)
g.waitAndPickClient(ctx) g.waitAndPickClient(ctx)
issues, resp, err := g.getClient().Issues.ListByRepo(ctx, g.repoOwner, g.repoName, opt) issues, resp, err := g.getClient().Issues.ListByRepo(ctx, g.repoOwner, g.repoName, opt)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("error while listing repos: %w", err) return nil, false, fmt.Errorf("GetIssues: %w", err)
} }
log.Trace("Request get issues %d/%d, but in fact get %d", perPage, page, len(issues)) log.Trace("Request get issues %d/%d, but in fact get %d", perPage, page, len(issues))
g.setRate(&resp.Rate) g.setRate(&resp.Rate)
@@ -500,7 +513,8 @@ func (g *GithubDownloaderV3) GetIssues(ctx context.Context, page, perPage int) (
}) })
} }
return allIssues, len(issues) < perPage, nil g.issuePage, g.issueCursor, g.nextIssueCursor = page, opt.After, resp.After
return allIssues, resp.NextPage == 0 && resp.After == "", nil
} }
// SupportGetRepoComments return true if it supports get repo comments // SupportGetRepoComments return true if it supports get repo comments
+99
View File
@@ -5,9 +5,13 @@
package migrations package migrations
import ( import (
"fmt"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"testing" "testing"
"time" "time"
@@ -19,6 +23,101 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestGithubIssuePagination(t *testing.T) {
for _, tc := range []struct {
name string
pages []string
}{
{name: "offset", pages: []string{"page=1", "page=2", "page=3"}},
{name: "cursor", pages: []string{"page=1", "after=cursor-2", "after=cursor-3"}},
{name: "switch to cursor", pages: []string{"page=1", "page=2", "after=cursor-3"}},
} {
t.Run(tc.name, func(t *testing.T) {
var requests []string
failReaction := true
issue := func(number int) string {
return fmt.Sprintf(`{"number":%d,"title":"Issue","user":{},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}`, number)
}
responses := []string{
"[" + issue(1) + `,{"pull_request":{}}]`,
`[{"pull_request":{}}]`,
"[" + issue(4) + "," + issue(5) + "]",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-RateLimit-Remaining", "100")
if r.URL.Path == "/api/v3/repos/o/r/issues/4/reactions" && failReaction {
failReaction = false
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"temporary failure"}`))
return
}
if r.URL.Path != "/api/v3/repos/o/r/issues" {
_, _ = w.Write([]byte(`[]`))
return
}
query := r.URL.Query()
assert.Equal(t, "2", query.Get("per_page"))
assert.Equal(t, "all", query.Get("state"))
assert.Equal(t, "created", query.Get("sort"))
assert.Equal(t, "asc", query.Get("direction"))
query.Del("per_page")
query.Del("state")
query.Del("sort")
query.Del("direction")
pagination := query.Encode()
requests = append(requests, pagination)
page := slices.Index(tc.pages, pagination)
if page < 0 {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"unexpected pagination"}`))
return
}
if page+1 < len(tc.pages) {
w.Header().Set("Link", fmt.Sprintf(`<https://api.github.com/repos/o/r/issues?per_page=2&%s>; rel="next"`, tc.pages[page+1]))
}
_, _ = w.Write([]byte(responses[page]))
}))
defer server.Close()
downloader, err := NewGithubDownloaderV3(t.Context(), server.URL, "", "", "", "o", "r")
require.NoError(t, err)
issues, isEnd, err := downloader.GetIssues(t.Context(), 1, 2)
require.NoError(t, err)
require.Len(t, issues, 1)
assert.EqualValues(t, 1, issues[0].Number)
assert.False(t, isEnd)
for range 2 {
issues, isEnd, err = downloader.GetIssues(t.Context(), 2, 2)
require.NoError(t, err)
assert.Empty(t, issues)
assert.False(t, isEnd)
}
_, _, err = downloader.GetIssues(t.Context(), 3, 2)
require.Error(t, err)
for range 2 {
issues, isEnd, err = downloader.GetIssues(t.Context(), 3, 2)
require.NoError(t, err)
require.Len(t, issues, 2)
assert.EqualValues(t, 4, issues[0].Number)
assert.EqualValues(t, 5, issues[1].Number)
assert.True(t, isEnd)
}
_, isEnd, err = downloader.GetIssues(t.Context(), 1, 2)
require.NoError(t, err)
assert.False(t, isEnd)
issues, isEnd, err = downloader.GetIssues(t.Context(), 2, 2)
require.NoError(t, err)
assert.Empty(t, issues)
assert.False(t, isEnd)
assert.Equal(t, []string{tc.pages[0], tc.pages[1], tc.pages[1], tc.pages[2], tc.pages[2], tc.pages[2], tc.pages[0], tc.pages[1]}, requests)
})
}
}
func TestGitHubDownloadRepo(t *testing.T) { func TestGitHubDownloadRepo(t *testing.T) {
token := os.Getenv("GITHUB_READ_TOKEN") token := os.Getenv("GITHUB_READ_TOKEN")
liveMode := token != "" liveMode := token != ""
+9 -18
View File
@@ -17,14 +17,13 @@ import (
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/services/gitdiff" "gitea.dev/services/gitdiff"
notify_service "gitea.dev/services/notify" notify_service "gitea.dev/services/notify"
) )
func isErrBlameNotFoundOrNotEnoughLines(err error) bool { func isErrBlameNotFoundOrNotEnoughLines(err error) bool {
return gitcmd.IsStderr(err, gitcmd.StderrNoSuchPath, gitcmd.StderrFileNoEnoughLines) return gitcmd.IsStderr(err, gitcmd.StderrNoSuchPath, gitcmd.StderrFileNoEnoughLines, gitcmd.StderrBadObject)
} }
// ErrDismissRequestOnClosedPR represents an error when a user tries to dismiss a review associated to a closed or merged PR. // ErrDismissRequestOnClosedPR represents an error when a user tries to dismiss a review associated to a closed or merged PR.
@@ -248,10 +247,15 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
// Get latest commit referencing the commented line // Get latest commit referencing the commented line
// No need for get commit for base branch changes // No need for get commit for base branch changes
commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line)) commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
// A force push may have removed the review's stored commit.
if err != nil && head != pr.GetGitHeadRefName() {
head = pr.GetGitHeadRefName()
commit, err = lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
}
if err == nil { if err == nil {
commitID = commit.ID.String() commitID = commit.ID.String()
} else if !isErrBlameNotFoundOrNotEnoughLines(err) { } else {
return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitHeadRefName(), gitRepo.LogString(), treePath, line, err) log.Debug("LineBlame[%s, %s, %s, %d]: %v", head, gitRepo.LogString(), treePath, line, err)
} }
} }
} }
@@ -266,23 +270,10 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
commitID = headCommitID commitID = headCommitID
} }
patch, err = git.GetFileDiffCutAroundLine(ctx, patch, err = gitdiff.GetCodeCommentPatch(ctx, gitRepo, pr.MergeBase, headCommitID, treePath, line)
gitRepo, pr.MergeBase, headCommitID, treePath,
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// If patch is still empty (unchanged line), generate code context
contextCommitID := util.Iif(line < 0, pr.MergeBase, headCommitID) // the line number is relative to the diff sides, not to the blamed commitID
if patch == "" && contextCommitID != "" {
patch, err = gitdiff.GeneratePatchForUnchangedLine(ctx, gitRepo, contextCommitID, treePath, line, setting.UI.CodeCommentLines)
if err != nil {
// Log the error but don't fail comment creation
log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, contextCommitID, err)
}
}
} }
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeCode, Type: issues_model.CommentTypeCode,
+4
View File
@@ -225,6 +225,10 @@ func getReleasePayloadInfo(p *api.ReleasePayload, linkFormatter linkFormatter, w
text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName) text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
} }
if p.Action != api.HookReleaseDeleted && len(p.Release.Attachments) > 0 {
text += fmt.Sprintf(" (assets: %d)", len(p.Release.Attachments))
}
return text, color return text, color
} }
+3 -2
View File
@@ -579,6 +579,7 @@ func TestGetWikiPayloadInfo(t *testing.T) {
func TestGetReleasePayloadInfo(t *testing.T) { func TestGetReleasePayloadInfo(t *testing.T) {
p := pullReleaseTestPayload() p := pullReleaseTestPayload()
p.Release.Attachments = []*api.Attachment{{}, {}}
cases := []struct { cases := []struct {
action api.HookReleaseAction action api.HookReleaseAction
@@ -587,12 +588,12 @@ func TestGetReleasePayloadInfo(t *testing.T) {
}{ }{
{ {
api.HookReleasePublished, api.HookReleasePublished,
"[test/repo] Release created: v1.0 by user1", "[test/repo] Release created: v1.0 by user1 (assets: 2)",
greenColor, greenColor,
}, },
{ {
api.HookReleaseUpdated, api.HookReleaseUpdated,
"[test/repo] Release updated: v1.0 by user1", "[test/repo] Release updated: v1.0 by user1 (assets: 2)",
yellowColor, yellowColor,
}, },
{ {
+3
View File
@@ -54,6 +54,9 @@
<div class="ui buttons"><button type="button" class="ui button active">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div> <div class="ui buttons"><button type="button" class="ui button active">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button active">2</button><button type="button" class="ui button">3</button></div> <div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button active">2</button><button type="button" class="ui button">3</button></div>
<div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button active">3</button></div> <div class="ui buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button active">3</button></div>
<div class="ui primary buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
<div class="ui red buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
<div class="ui green buttons"><button type="button" class="ui button">1</button><button type="button" class="ui button">2</button><button type="button" class="ui button">3</button></div>
</div> </div>
<div> <div>
+2 -2
View File
@@ -5686,7 +5686,7 @@
"x-go-name": "Description" "x-go-name": "Description"
}, },
"due_on": { "due_on": {
"description": "Deadline is the updated due date for the milestone", "description": "Deadline is the due date for the milestone, use null or empty string to remove the deadline",
"format": "date-time", "format": "date-time",
"type": "string", "type": "string",
"x-go-name": "Deadline" "x-go-name": "Deadline"
@@ -7416,7 +7416,7 @@
"description": "IssueLabelsOption a collection of labels", "description": "IssueLabelsOption a collection of labels",
"properties": { "properties": {
"labels": { "labels": {
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names", "description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names.\nIf a repository label and an organization label share a name,\nthe repository label is used.",
"items": {}, "items": {},
"type": "array", "type": "array",
"x-go-name": "Labels" "x-go-name": "Labels"
+2 -2
View File
@@ -28779,7 +28779,7 @@
"x-go-name": "Description" "x-go-name": "Description"
}, },
"due_on": { "due_on": {
"description": "Deadline is the updated due date for the milestone", "description": "Deadline is the due date for the milestone, use null or empty string to remove the deadline",
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"x-go-name": "Deadline" "x-go-name": "Deadline"
@@ -30525,7 +30525,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"labels": { "labels": {
"description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names", "description": "Labels can be a list of integers representing label IDs\nor a list of strings representing label names.\nIf a repository label and an organization label share a name,\nthe repository label is used.",
"type": "array", "type": "array",
"items": {}, "items": {},
"x-go-name": "Labels" "x-go-name": "Labels"
+34
View File
@@ -175,6 +175,40 @@ func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
MakeRequest(t, req, http.StatusNoContent) MakeRequest(t, req, http.StatusNoContent)
} }
func TestAPIAddIssueLabelsWithSharedRepoAndOrgLabelName(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6, RepoID: repo.ID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: repo.ID})
orgOnlyLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 4, OrgID: owner.ID})
assert.NoError(t, issues_model.NewLabel(t.Context(), &issues_model.Label{OrgID: owner.ID, Name: repoLabel.Name, Color: "#abcdef"}))
token := getTokenForLoggedInUser(t, loginUser(t, "user1"), auth_model.AccessTokenScopeWriteIssue)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner.Name, repo.Name, issue.Index)
for _, method := range []string{"POST", "PUT"} {
t.Run(method, func(t *testing.T) {
req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestWithJSON(t, method, urlStr, &api.IssueLabelsOption{
Labels: []any{repoLabel.Name, orgOnlyLabel.Name},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
var apiLabelIDs []int64
for _, label := range apiLabels {
apiLabelIDs = append(apiLabelIDs, label.ID)
}
assert.ElementsMatch(t, []int64{repoLabel.ID, orgOnlyLabel.ID}, apiLabelIDs)
})
}
}
func TestAPIReplaceIssueLabels(t *testing.T) { func TestAPIReplaceIssueLabels(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures()) assert.NoError(t, unittest.LoadFixtures())
@@ -41,6 +41,7 @@ func TestAPIIssuesMilestone(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
apiMilestone := DecodeJSON(t, resp, &structs.Milestone{}) apiMilestone := DecodeJSON(t, resp, &structs.Milestone{})
assert.EqualValues(t, "closed", apiMilestone.State) assert.EqualValues(t, "closed", apiMilestone.State)
assert.NotNil(t, apiMilestone.Deadline, "an omitted due_on keeps the due date")
req = NewRequest(t, "GET", urlStr). req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token) AddTokenAuth(token)
@@ -48,6 +49,10 @@ func TestAPIIssuesMilestone(t *testing.T) {
apiMilestone2 := DecodeJSON(t, resp, &structs.Milestone{}) apiMilestone2 := DecodeJSON(t, resp, &structs.Milestone{})
assert.EqualValues(t, "closed", apiMilestone2.State) assert.EqualValues(t, "closed", apiMilestone2.State)
req = NewRequestWithJSON(t, "PATCH", urlStr, map[string]any{"due_on": ""}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Nil(t, DecodeJSON(t, resp, &structs.Milestone{}).Deadline)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{
Title: "wow", Title: "wow",
Description: "closed one", Description: "closed one",
+18
View File
@@ -117,6 +117,24 @@ func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
} }
func TestAPIPublicOnlyRejectedRoutes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteOrganization,
auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopePublicOnly)
for _, route := range []struct{ method, url string }{
{"GET", "/api/v1/admin/orgs"},
{"POST", "/api/v1/orgs"},
{"POST", "/api/v1/org/org3/repos"},
{"POST", "/api/v1/orgs/org3/repos"},
{"DELETE", "/api/v1/orgs/org3/repos"},
{"POST", "/api/v1/orgs/org3/actions/runners/registration-token"},
{"POST", "/api/v1/repos/user2/repo1/generate"},
} {
MakeRequest(t, NewRequest(t, route.method, route.url).AddTokenAuth(token), http.StatusForbidden)
}
}
func TestAPITeamReposPublicOnly(t *testing.T) { func TestAPITeamReposPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
@@ -35,4 +35,8 @@ func TestUserHeatmap(t *testing.T) {
dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1}) dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1})
assert.Equal(t, dummyheatmap, heatmap) assert.Equal(t, dummyheatmap, heatmap)
publicOnlyToken := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)).AddTokenAuth(publicOnlyToken)
assert.Empty(t, DecodeJSON(t, MakeRequest(t, req, http.StatusOK), []*activities_model.UserHeatmapData{}))
} }
+18
View File
@@ -9,9 +9,14 @@ import (
"testing" "testing"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestFeedRepo(t *testing.T) { func TestFeedRepo(t *testing.T) {
@@ -33,6 +38,19 @@ func TestFeedRepo(t *testing.T) {
assert.Equal(t, "issue5", rss.Channel.Items[0].Description) assert.Equal(t, "issue5", rss.Channel.Items[0].Description)
assert.NotEmpty(t, rss.Channel.Items[0].PubDate) assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
}) })
t.Run("TagsWithoutReleasesAccess", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit_model.Type{unit_model.TypeReleases}))
resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/tags.rss"), http.StatusOK)
data := resp.Body.String()
assert.Contains(t, data, "/user2/repo1/src/tag/v1.0")
assert.NotContains(t, data, "pre-release")
assert.NotContains(t, data, "some text for a pre release")
})
} }
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the // TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
+24
View File
@@ -107,3 +107,27 @@ func TestProfileActivityPublicOnlyToken(t *testing.T) {
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK) respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker) assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
} }
func TestProfileReposAndLimitedOwnersPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
for link, privateRepoLink := range map[string]string{
"/user2?tab=repositories&q=repo2": `"/user2/repo2"`,
"/user2?tab=stars": `"/user2/repo2"`,
"/org3": `"/org3/repo3"`,
} {
reqFull := NewRequest(t, "GET", link)
reqFull.SetBasicAuth("user2", fullToken)
assert.Contains(t, MakeRequest(t, reqFull, http.StatusOK).Body.String(), privateRepoLink, link)
reqPublicOnly := NewRequest(t, "GET", link)
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
assert.NotContains(t, MakeRequest(t, reqPublicOnly, http.StatusOK).Body.String(), privateRepoLink, link)
}
for _, limitedLink := range []string{"/user33", "/user33.rss", "/limited_org"} {
assertBasicAuthStatus(t, limitedLink, fullToken, http.StatusOK)
assertBasicAuthStatus(t, limitedLink, publicOnlyToken, http.StatusNotFound)
}
}
+21
View File
@@ -90,6 +90,27 @@ func TestViewIssuesSortByType(t *testing.T) {
}) })
} }
func TestIssueIDsSortByLabelScope(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
high := &issues_model.Label{RepoID: 1, Name: "priority/high", Exclusive: true, ExclusiveOrder: 1}
low := &issues_model.Label{RepoID: 1, Name: "priority/low", Exclusive: true, ExclusiveOrder: 2}
assert.NoError(t, db.Insert(t.Context(), high, low))
assert.NoError(t, db.Insert(t.Context(),
&issues_model.IssueLabel{IssueID: 1, LabelID: low.ID},
&issues_model.IssueLabel{IssueID: 2, LabelID: high.ID},
))
for page, expectedIDs := range [][]int64{{2, 1, 11}, {5, 3}} {
issueIDs, total, err := issues_model.IssueIDs(t.Context(), &issues_model.IssuesOptions{
RepoIDs: []int64{1},
SortType: issues_model.ScopeSortPrefix + "priority",
Paginator: &db.ListOptions{Page: page + 1, PageSize: 3},
})
assert.NoError(t, err)
assert.Equal(t, expectedIDs, issueIDs)
assert.EqualValues(t, 5, total)
}
}
func TestViewIssuesKeyword(t *testing.T) { func TestViewIssuesKeyword(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
+6
View File
@@ -1123,6 +1123,12 @@ func testOAuthGrantScopesClaimPublicOnlyGroups(t *testing.T) {
require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed)) require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
assert.Contains(t, userinfoParsed.Email, "user2@example.com") assert.Contains(t, userinfoParsed.Email, "user2@example.com")
defer test.MockVariableValue(&setting.DisableQueryAuthToken, false)()
queryUserinfoResp := MakeRequest(t, NewRequest(t, "GET", "/login/oauth/userinfo?access_token="+parsed.AccessToken), http.StatusOK)
queryUserinfoParsed := new(userinfoResponse)
require.NoError(t, json.Unmarshal(queryUserinfoResp.Body.Bytes(), queryUserinfoParsed))
assert.Equal(t, userinfoParsed.Groups, queryUserinfoParsed.Groups)
// test both id_token and call to /login/oauth/userinfo // test both id_token and call to /login/oauth/userinfo
for _, publicGroup := range []string{ for _, publicGroup := range []string{
"org17", "org17",
+14 -3
View File
@@ -245,16 +245,19 @@
border-color: var(--color-red-dark-2); border-color: var(--color-red-dark-2);
} }
.ui.green.button { .ui.green.button,
.ui.green.buttons .button {
color: var(--color-white); color: var(--color-white);
background: var(--color-green); background: var(--color-green);
} }
.ui.green.button:hover { .ui.green.button:hover,
.ui.green.buttons .button:hover {
background: var(--color-green-dark-1); background: var(--color-green-dark-1);
} }
.ui.green.button:active { .ui.green.button:active,
.ui.green.buttons .button:active {
background: var(--color-green-dark-2); background: var(--color-green-dark-2);
} }
@@ -337,6 +340,14 @@ It needs some tricks to tweak the left/right borders with active state */
border-left: 1px solid var(--color-secondary-dark-2); border-left: 1px solid var(--color-secondary-dark-2);
} }
.ui:is(.primary, .red, .green).buttons .button:hover {
border-color: transparent; /* let the colored hover background fill the border */
}
.ui:is(.primary, .red, .green).buttons .button:hover + .button {
border-left-color: transparent;
}
/* TODO: these "tw-hidden" selectors are only used by "blame.tmpl" buttons: Raw/Normal View/History/Unescape, need to be refactored to a clear solution later */ /* TODO: these "tw-hidden" selectors are only used by "blame.tmpl" buttons: Raw/Normal View/History/Unescape, need to be refactored to a clear solution later */
.ui.buttons .button:first-child, .ui.buttons .button:first-child,
.ui.buttons .button.tw-hidden:first-child + .button { .ui.buttons .button.tw-hidden:first-child + .button {