Compare commits

...
30 Commits
Author SHA1 Message Date
b2794f96b9 fix(actions): keep workflow run trailing on one row with long branch names (#38382) (#38403)
Backport #38382 by @bircni

The flex-list refactor (#37505) raised the shared `.item-trailing`
selector's specificity, so its `flex-wrap: wrap` started overriding the
run list's intended `flex-wrap: nowrap` — a long branch name pushed the
trailing content past its fixed 280px width and wrapped the kebab menu
onto its own line.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-10 22:08:24 +00:00
b6aa2b61c6 fix(web): use locale-aware date formatting for contribution calendar tooltips (#38398) (#38401)
Backport #38398 by @SudhanshuMatrix

The contribution calendar manually constructed localized date strings
instead of using the browser's locale-aware date formatting. Replace
this with `toLocaleDateString()` to correctly format dates for all
locales and calendar systems, including non-Gregorian calendars.

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

### Before and After Comparison (Persian `fa-IR`)

Before (Buggy) 
 
<img width="1246" height="651" alt="image"
src="https://github.com/user-attachments/assets/670698e6-dd4a-4c7e-b7fb-23849090d1b9"
/>
 
After (Fixed) 
<img width="1246" height="651" alt="image"
src="https://github.com/user-attachments/assets/9b65c647-876e-475b-98d1-614ef316fd6f"
/>

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
2026-07-10 21:28:54 +00:00
74ad781db9 fix(pull): re-evaluate review official flag on target branch change (#38319) (#38402)
Backport #38319 by @bircni

The `official` flag of a pull request review is computed against the
target branch's protection rules at submit time and stored on the review
record. `ChangeTargetBranch` updated the base branch but never
re-evaluated it, so an `official` approval obtained against an
unprotected branch could be retargeted onto a protected branch and
satisfy its required approvals, bypassing the branch protection.

This re-evaluates the `official` flag of the latest approve/reject
reviews against the new base branch whenever the target branch changes.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-10 19:42:00 +00:00
GiteabotandGitHub ed493f0684 fix(security): harden access checks and migration validation (#38324) (#38400) 2026-07-10 20:14:38 +02:00
af7ba2edef fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)
Backport #38323 by @bircni

Three independent security hardening fixes, each with a regression test:

- **Locale DoS:** the `Locale` middleware passed the raw
`Accept-Language` header to `ParseAcceptLanguage`, whose guard only
counts `-` while the scanner aliases `_` to `-` — a large `_`-separated
header on an unauthenticated request burned CPU. The header is now
length-bounded before parsing.
- **Public-only token scope:** `GET /teams/{id}/repos`,
`.../repos/{org}/{repo}`, `/teams/{id}/activities/feeds`, and
`/users/{username}/orgs/{org}/permissions` still returned private
repo/activity/permission data to a public-only token. They now filter
via `TokenCanAccessRepo` / `ApplyPublicOnly` and reject non-public org
permissions.
- **Push-option visibility:** `repo.private` / `repo.template` push
options were applied to any existing repo, letting an owner/admin
silently flip visibility bypassing audit, webhooks, and notifications.
They are now honored only on push-to-create.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-10 19:07:04 +02:00
8b4252e7f6 fix: co-author detection (#38392) (#38397)
Backport #38392

Fix #38384

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-10 11:15:20 +00:00
500a09e044 fix(api): stop leaking private repo metadata after access revocation (#38321) (#38390)
Backport #38321 by @bircni

The `/user/starred` and `/user/subscriptions` endpoints returned private
repositories a user had starred/watched even after their access to those
repositories was revoked, still exposing the repository name,
description and visibility (including later metadata changes).

Private repositories in the starred/watched queries are now gated on the
actor's current access via `AccessibleRepositoryCondition`, so users who
no longer have access no longer receive the metadata. Public
repositories and public-only tokens are unaffected.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-10 07:40:02 +00:00
bfebc4b0e1 fix: incorrect co-author detection on commit page (#38386) (#38387)
Backport #38386 by @bircni


The commit page built its "co-authored by" list from
`AllParticipantIdentities()[1:]`, which only drops the author and leaves
the committer in the list even though the committer is already shown
separately as "committed by". This caused the committer to be wrongly
rendered as a co-author (issue #38384): a commit authored by
`silverwind`, committed by `bircni`, with a `Co-authored-by: silverwind`
trailer displayed "co-authored by bircni" instead of the actual trailer
identity. This adds a `Commit.CoAuthorIdentities()` method that excludes
both the author and the committer, uses it on the commit page, and
covers the fix with a regression test.

Closes #38384

Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 18:29:28 -07:00
d9534e7bfa fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
Backport #38322 by @bircni

The LFS batch and upload handlers linked an object that already existed
in the content store but was not linked to the current repo whenever the
token's user could access it in another repo. Deploy-key tokens carry
the repo owner's identity, so a single-repo write deploy key could link
and then download objects from any repo the owner can see.

This drops the cross-repo access check: the batch handler now makes the
client upload (hash-verified) any object not yet linked to the repo, and
the upload handler skips proof of possession only when the object is
already linked to the current repo.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 22:18:07 +00:00
532828cc82 enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
Backport #38371 by @Zettat123

Follow #38237

#38237 posts "skipped" commit statuses for every workflow that is not
triggered due to a filter (e.g. `paths` or `branches`) mismatch.
However, for non-required workflows, creating "skipped" commit statuses
for them would generate a lot of noise.

To address this issue, this PR adds a check before creating commit
status:

- For the context that matches any required status check patterns, a
"skipped" commit status will be created. The `Required` label can inform
users that this status check is required, but has been skipped because
of a filter mismatch.

<img width="800" alt="image"
src="https://github.com/user-attachments/assets/264fd716-413c-457d-a5a6-6717175d3807"
/>

- For a non-required context, nothing will be created.

NOTE: Reducing noise is a best-effort approach and isn't entirely
accurate. When creating commit statuses, it is impossible to predict
which branch protection rule will take effect. Therefore, we have to
compare the commit status context against the required patterns from all
rules. If any rule matches, the context is considered "required".
 
Closes https://github.com/go-gitea/gitea/issues/38351

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-09 15:58:46 +00:00
8cf988f0a6 fix(ui): restore commits table column widths (#38379) (#38383)
Backport #38379 by @silverwind

https://github.com/go-gitea/gitea/pull/37594 widened the author column
from `three wide` to `four wide`, leaving a large empty gap around the
SHA column in the common single-author case. The old author cell was
capped at 180px, so the wider column only adds whitespace: avatar-stack
names ellipsize at 240px each, and wider multi-author rows still expand
naturally in the auto-layout table. Restore the pre-existing
`three`/`eight` widths.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-09 17:07:20 +02:00
04a26f40a0 test(e2e): fix race in pdf file render test (#38380) (#38381)
Backport #38380 by @silverwind

`data-render-name` is set before the plugin's async render runs, so
measuring the container height right after the attribute appears can
observe the pre-render 48px height when the `pdfobject` chunk loads
slowly (flaked in CI). Poll for the height instead, like the asciicast
test in the same file does.

Co-authored-by: silverwind <me@silverwind.io>
2026-07-09 13:57:50 +00:00
fc4eac390a fix: golang html template url escaping (#38363) (#38369)
Backport #38363

fix #38362

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-08 12:10:26 +08:00
64d559a8e6 perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
Backport #38281 by @bircni

3 reductions in the DB load generated by many runners polling `FetchTask`:

**1. Debounce runner heartbeat writes**
Every poll wrote `last_online`, and every `UpdateTask`/`UpdateLog` wrote
`last_active` — while a runner streams logs that is many writes per
second per runner. These are now persisted only when stale enough to
actually affect the active/offline status (`ShouldPersistLastOnline` /
`ShouldPersistLastActive`), using the existing columns.

**2. Throttle concurrent task picks**
A new in-process semaphore (`MAX_CONCURRENT_TASK_PICKS`) bounds how many
runners run the task-assignment transaction at once, so a fleet polling
together cannot stampede the query. Throttled polls retry on their next
poll without advancing the runner's tasks version.

**3. Paginate the task-pick query**
`CreateTaskForRunner` previously loaded every waiting job in the
runner's scope into memory on each poll (no `LIMIT`). Now it pages
through the waiting backlog oldest-first with `LIMIT`, claiming the
first label-matching job.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-07 16:27:00 -07:00
03372836ab fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
Backport #38320 by @bircni

Pull mirror sync ran `git fetch` / `remote update` / `remote prune`
without disabling HTTP redirects. A mirror remote that later starts
redirecting to an otherwise-blocked or internal address could be used as
an SSRF/exfiltration vector on scheduled syncs, bypassing the
allow/block validation applied at migration time.

This sets `http.followRedirects=false` on all three remote-contacting
commands in the pull mirror path, matching the existing guard already
present on the clone path.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-07 17:59:33 +00:00
1af1e06ac4 fix: minio init check (#38355) (#38361)
Backport #38355

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-07 14:41:57 +02:00
e07a5de53f fix: org project view assignee list (#38357) (#38360)
Backport #38357

fix #38129

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-07 07:07:30 +00:00
GiteabotandGitHub d4d81af583 fix(actions): release claimed task if context is cancelled during FetchTask (#38343) (#38347) 2026-07-06 07:38:47 +02:00
6cd2c647ec test: compare key file contents instead of FileInfo in TestInitKeys (#38330) (#38331)
Backport #38330 by @Zettat123

`TestInitKeys` verified whether a host key file was regenerated by
comparing `os.FileInfo` before and after a `InitDefaultHostKeys` call.
On systems where the OS clock / filesystem timestamp granularity is
coarse, both writes land in the same tick and get an identical mtime.
The resulting `FileInfo` is then byte-for-byte equal even though the key
was actually regenerated, so `assert.NotEqual` fails.

Since a regenerated key always produces different random bytes,
comparing the content can reliably detect regeneration.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-04 20:52:52 +00:00
37c8604105 fix(release): validate web attachment renames against allowed types (#38314) (#38328)
Backport #38314 by @lunny

This fixes the web release edit flow so renamed release attachments are
validated against `[repository.release] ALLOWED_TYPES`.

Previously, the API attachment edit endpoint already enforced release
attachment type restrictions, but the web release edit form passed
`attachment-edit-*` values into `release_service.UpdateRelease`, which
updated attachment names directly without validating the new filename
against `setting.Repository.Release.AllowedTypes`.

As a result, a user with repository write access could rename an
existing release attachment to a disallowed extension through the web
UI.

- validate edited release attachment names in
`release_service.UpdateRelease`
- reject forbidden attachment renames using
  `setting.Repository.Release.AllowedTypes`
- re-render the web release edit page with a validation error instead of
  returning an internal server error
- add regression coverage for both the service layer and the web flow

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-04 17:45:11 +02:00
a9814e789c fix(actions): make runner list pagination order deterministic (#38313) (#38327)
Backport #38313 by @bircni

The runner-listing API endpoints (`admin`, `org`, `user`, `repo`, and
`shared`) return runners in a non-deterministic order across pages. When
paging through runners, some runners from page 1 could reappear on page
2 (and others get skipped entirely).

The cause is in `FindRunnerOptions.ToOrders()`. Most sort modes order by
a **non-unique** column only:

- default / `online` / `offline` → `last_online`
- `alphabetically` / `reversealphabetically` → `name`

When multiple runners tie on the sort key (e.g. every offline runner
shares `last_online = 0`, or two runners have the same name), the
database is free to return the tied rows in any order between separate
queries. Combined with `LIMIT`/`OFFSET` pagination, this means the same
runner can land on more than one page.

## Fix

Append the unique primary key `id` as a stable tiebreaker to each
non-unique sort order:

The `newest`/`oldest` modes already sort by the unique `id`, so they are
left unchanged.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-03 20:59:33 +00:00
ab10e37acf fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
Backport #38318 by @bircni

Draft-release access control was enforced only on the API release
endpoints (`/api/v1/repos/{owner}/{repo}/releases/...`) but not on the
UUID-based web attachment endpoints (`/attachments/{uuid}`,
`/{owner}/{repo}/attachments/{uuid}`,
`/{owner}/{repo}/releases/attachments/{uuid}`).

Anyone who obtained an attachment UUID — including unauthenticated
callers — could download files belonging to a hidden draft release,
since `ServeAttachment` only checked repo-level read permission and
never the release's draft state.

This extends `ServeAttachment` to require write access to releases when
the attachment belongs to a draft release, mirroring the existing
API-side `canAccessReleaseDraft` gate. A regression test is included.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-03 20:34:37 +02:00
f7bc6b89c1 fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
Backport #38243 by @puni9869

Follow up for https://github.com/go-gitea/gitea/pull/38204.

Signed-off-by: puni9869 <80308335+puni9869@users.noreply.github.com>
Co-authored-by: puni9869 <80308335+puni9869@users.noreply.github.com>
2026-07-02 10:07:29 +00:00
fabc5e6fcb fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
Backport #38227 by @SudhanshuMatrix

### Description
This PR resolves a UI alignment bug in the Gitea Actions log viewer
where the expand/collapse disclosure chevron overlaps with the log text
(specifically the timestamp) when timestamps are enabled.

### Cause
When log timestamps are enabled, the timestamp element
(`.log-time-stamp`) is rendered as the first element next to the line
number. Because it only had a default `10px` left margin, it positioned
itself exactly where the group's expand/collapse chevron is located,
causing them to overlap.

Fixes #38222.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-02 11:22:26 +02:00
GiteabotandGitHub a016d678db fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302) 2026-07-01 20:10:11 +00:00
b6e409badd fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
Backport #38274 by @afahey03

Fixes HTTP 500 when OIDC auto account linking (`ACCOUNT_LINKING=auto`)
requires local 2FA. `oauth2LinkAccount` set `linkAccount` in the session
before redirecting to 2FA but did not persist `linkAccountData`, so
`TwoFactorPost` failed with `not in LinkAccount session`. The manual
linking flow already stored both, this aligns auto-link with that
behavior.

Created the test, `TestOAuth2AutoLinkWithTwoFactor`, which verifies that
automatic account linking completes after the user passes local 2FA when
an OIDC identity matches an existing account.

DISCLAIMER: I used AI to create the test

Closes #38171

Co-authored-by: Aidan Fahey <afahey2003@yahoo.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-01 13:18:37 +02:00
2734504cfc fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
Backport #38284 by @bircni

Fixes #38278

## Problem

When branch protection matches the branch an Actions workflow pushes to,
the runner's `git push` is rejected — even though the workflow token has
`contents: write` and the same push performed with a PAT (write access)
succeeds. Disabling protection or changing the pattern so it no longer
matches makes the push work.

## Root cause

In `preReceiveBranch` (`routers/private/hook_pre_receive.go`), the "can
the doer push to this protected branch" check resolves the pusher with
`user_model.GetUserByID(ctx, ctx.opts.UserID)`. For an Actions push the
user ID is `-2` (the virtual `ActionsUserID`), which has no database
row, so the lookup fails. Even past that, `CanUserPush` →
`HasAccessUnit`/whitelist membership cannot evaluate a virtual user and
returns `false`. As a result the Actions bot was rejected on every
matching protected branch, despite the earlier `assertCanWriteRef`
already confirming the token's code-write via
`GetActionsUserRepoPermission`.

This was inconsistent: a PAT with identical write access passed the
exact same check.

## Fix

Evaluate the Actions bot against its already-computed token permission
instead of a user lookup, mirroring the existing
`IsUserMergeWhitelisted` pattern:

- Add `CanActionsUserPush` / `CanActionsUserForcePush` on
`ProtectedBranch`, which take the precomputed `access_model.Permission`.
- Allow the push when push is enabled, **no** push whitelist is
enforced, and the token has code-write.
- Keep the bot blocked when a whitelist is enforced — it cannot be added
to one, so it must use a pull request. This preserves the whitelist as a
real security boundary.

Force-push, signed-commit and protected-file-path checks are untouched.

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-01 11:46:17 +02:00
eee7967b81 fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
Backport #38280 by @bircni

The **Status** filter dropdown on the repository Actions run list does
not let you filter for **Blocked** runs (nor **Cancelled** or
**Skipped**). These statuses are missing from the dropdown even though a
run can legitimately end up in any of them.

A run's status is computed by `aggregateJobStatus`, which can return
`Blocked`, `Cancelled` and `Skipped`. Because the filter dropdown only
offered Success, Failure, Waiting, Running and Cancelling, runs in those
other states existed but were impossible to filter for.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-01 10:12:11 +02:00
1df8f91691 fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
Backport #38273 by @Vinod-OAI

After upgrading from 1.25.x to 1.26.x, `repo-archive` workers can fail
to unmarshal queued items:

```
Failed to unmarshal item from queue "repo-archive":
json: unable to unmarshal into Go convert.Conversion within "/Repo/Units/0/Config":
cannot derive concrete type for nil interface with finite type set
```

`ArchiveRequest` started embedding `*repo_model.Repository` in 1.26,
which does not round-trip through the JSON queue.

This change stores a minimal `archiveQueueItem` (`RepoID`, `Type`,
`CommitID`, `Paths`) in `repo-archive` and loads the repository in the
worker. `UnmarshalJSON` accepts legacy payloads that used `RepoID` or
embedded `Repo.id`.

Fixes #38272

<!--
Before submitting:
- Target the `main` branch; release branches are for backports only.
- Use a Conventional Commits title, e.g. `fix(repo): handle empty branch
names`.
- Read the contributing guidelines:
https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
- Documentation changes go to https://gitea.com/gitea/docs

Describe your change below and link any issue it fixes.
-->

Co-authored-by: Vinod-OAI <venkat.vinod@observe.ai>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-30 19:17:29 +02:00
4654e7eccd docs: Changelog for 1.27.0-rc0 (#38247)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-29 21:06:07 +02:00
107 changed files with 2595 additions and 615 deletions
+268
View File
@@ -4,6 +4,274 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0-rc0) - 2026-06-28
* BREAKING
* Feat(actions)!: improve support for reusable workflows (#37478)
* Use Content-Security-Policy: script nonce (#37232)
* SECURITY
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
* Fix(oauth): restrict introspection to the token's client (#38042)
* Fix(api): don't expose private org membership via public_members (#38145)
* Fix(actions): deny fork-PR cross-repo access via collaborative owner (#38214)
* Fix(migrations): prevent path traversal in repository restore (#38215)
* FEATURES
* Feat(actions): add workflow status badge modal (#38196)
* Feat(actions): support owner-level and global scoped workflows (#38154)
* Feat(api): support ref suffixes in compare (#38148)
* Feat(actions): implement `jobs.<job_id>.continue-on-error` (#38100)
* Feat(actions): show run status on browser tab favicon (#38071)
* Feat(api): add token introspection and self-deletion endpoint (#37995)
* Feat(api): add q parameter to list branches API for server-side filtering (#37982)
* Feat(repo): split repository creation limit into user and org scopes (#37872)
* Feat(actions): bulk delete, disable and enable runners in admin UI (#37869)
* Feat(actions): List workflows that were executed once but got removed from the default branch (#37835)
* Feat(org): add team visibility so org members can discover teams (#37680)
* Feat: add raw diff/patch endpoint for repository comparisons (#37632)
* Feat: Add avatar stacks (#37594)
* Feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
* Feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
* Support for Custom URI Schemes in OAuth2 Redirect URIs (#37356)
* Feat(orgs): Add search bar for organization members tab page (#37347)
* Feat(api): Add assignees APIs (#37330)
* Feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)
* Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
* Add project column picker to issue and pull request sidebar (#37037)
* Allow multiple projects per issue and pull requests (#36784)
* Feat(ui): add "follow rename" to file commit history list (#34994)
* Feat(ssh): auto generate additional ssh keys (#33974)
* ENHANCEMENTS
* Enhance: allow builtin default git config options to be overridden (#38172)
* Enhance: allow MathML core elements (#38034)
* Enhance(markup): improve issue title rendering (#37908)
* Enhance(actions): set descriptive browser tab title on run view (#37870)
* Enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866)
* Enhance(actions): show workflow name from YAML instead of filename (#37833)
* Feat(actions): add before/after to PR synchronize event payload (#37827)
* Enhance(actions): add branch filters to run list (#37826)
* Enhance(actions): Make Summary UI more beautiful with more infos (#37824)
* Feat: add copy button to action step header, improve other copy buttons (#37744)
* Fix(icon): use repo-forked icon to display forks count (#37731)
* Feat(api): add sort and order query parameters to job list endpoints (#37672)
* Feat(api): add last_sync to repository API (#37566)
* Enhance: Adjust Workflow Graph styling (#37497)
* Improve code editor text selection and clean up lint enablement (#37474)
* Add mirror auth updates to repo edit API and settings (#37468)
* Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411)
* Feat: Add default PR branch update style setting (#37410)
* Fix inconsistent disabled styling on logged-out repo header buttons (#37406)
* Allow fast-forward-only merge when signed commits are required (#37335)
* Enhance styling in actions page (#37323)
* Fix: improve actions status icons and texts (#37206)
* Make Markdown fenced code block work with more syntaxes (#37154)
* Fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
* Add API endpoint to reply to pull request review comments (#36683)
* PERFORMANCE
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
* Perf: Various performance regression fixes (#38078)
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
* Batch-load related data in actions run, job, and task API endpoints (#37032)
* BUGFIXES
* Fix: update npm dependencies, fix misc issues (#38257)
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
* Fix: codemirror regressions (#38248)
* Fix(api): support HEAD requests on all API GET endpoints (#38245)
* Fix(actions): Cleanup workflow status badge code (#38241)
* Fix(web): Correctly align the "disabled" label on larger workflow names (#38240)
* Fix(actions): don't swallow HTML entities into linkified URLs (#38239)
* Fix(packages): accept npm "repository" and "bin" in string form (#38236)
* Fix(actions): fix 500 error when canceling a canceling task (#38223)
* Fix(deps): update module golang.org/x/image to v0.43.0 [security] (#38219)
* Fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)
* Fix(api): deny private org member enumeration via /members (#38213)
* Fix(actions): ensure all waiting jobs get runners in large workflows (#38200)
* Fix(deps): update go dependencies (#38194)
* Fix(deps): update npm dependencies (#38193)
* Fix(cli): default must-change-password to false for bot users (#38175)
* Fix(actions): show run index in run view and fix summary graph height (#38165)
* Fix: csp (#38162)
* Fix(deps): update npm dependencies (#38123)
* Fix(mssql): expand legacy issue and comment long-text columns (#38120)
* Fix(packages): validate debian distribution and component names (#38116)
* Fix(packages): validate module version in goproxy ParsePackage (#38104)
* Fix(deps): update dependency esbuild to v0.28.1 [security] (#38097)
* Fix: git push hook post receive (#38089)
* Fix(ui): prevent commit status popup overflowing its row (#38081)
* Fix: validate gem name in rubygems parseMetadataFile (#38061)
* Fix: commit display name (#38057)
* Fix: csp regressions (#38047)
* Fix: api error message (#38031)
* Fix(deps): update npm dependencies (#38029)
* Fix: pgsql lint (#38022)
* Fix(indexer): fix assignee filters in issue search (#38021)
* Fix: various dropdown problems (#38020)
* Fix: refactor git error handling and make archive streaming handle non-existing commit id (#38007)
* Fix: raise git required version to 2.13 (#37996)
* Fix: remove "no-transfrom" from the cache-control header (#37985)
* Fix(deps): update module github.com/google/go-github/v87 to v88 (#37971)
* Fix: use committer time where ever possible as default (#37969)
* Fix(deps): update npm dependencies, remove nolyfill (#37968)
* Fix(deps): update go dependencies (#37967)
* Fix(pull): preserve squash message trailers and additional commit messages (#37954)
* Fix(deps): update module golang.org/x/image to v0.41.0 [security] (#37904)
* Fix: support ##[command] log prefix in action run UI (#37882)
* Fix(deps): update module github.com/google/go-github/v86 to v87 (#37845)
* Fix(deps): update npm dependencies (#37844)
* Fix(deps): update go dependencies (#37841)
* Fix(frontend): resolve Vite assets by manifest source path (#37836)
* Fix(locales): Replace hardcoded strings (#37788)
* Fix(packages): render markdown links relative to linked repo (#37676)
* Fix: persist mirror repository metadata (#37519)
* Fix cmd tests by mocking builtin paths (#37369)
* Add `form-fetch-action` to some forms, fix "fetch action" resp bug (#37305)
* Feat: execute post run cleanup when workflow is cancelled (#37275)
* Fix `relative-time` error and improve global error handler (#37241)
* Refactor flash message and remove SanitizeHTML template func (#37179)
* TESTING
* Test: speed up two tests (#37905)
* Test: Fix random failure test (#37887)
* Test: fix flaky `issue-comment` close test (#37880)
* Test: enable WAL for sqlite integration tests (#37861)
* Test: fix flaky `TestResourceIndex` and reduce its runtime (#37847)
* Test: run `TestAPIRepoMigrate` offline via a local clone source (#37817)
* Ci: shard tests and reduce redundant work (#37618)
* Test(e2e): run playwright via container (#37300)
* Remove external service dependencies in migration tests (#36866)
* BUILD
* Fix(actions): authenticate snapcraft before nightly remote build (#38252)
* Ci: cap Elasticsearch heap in db-tests (#37816)
* Build(snap): publish nightly version to snapcraft via actions (#37814)
* Ci: split pgsql shards into plain jobs, dedupe setup actions (#37802)
* Ci: narrow files-changed frontend filter (#37749)
* Ci: add `zizmor` to `lint-actions` (#37720)
* Chore: clean up "contrib" dir (#37690)
* Fix: snap build (main branch) (#37685)
* Ci: Also lint json5 files (#37659)
* Feat(editor): broaden language detection in web code editor (#37619)
* Build: update pnpm to v11 (#37591)
* Refactor(deps): migrate from `nektos/act` fork to `gitea/runner` (#37557)
* Refactor: lint bare `fill`/`stroke` colors, add vars for git graph color series (#37543)
* Update go js py dependencies (#37525)
* Ci: lint PR titles with commitlint (#37498)
* Chore: upgrade Go version in devcontainer image to 1.26 (#37374)
* Update GitHub Actions to latest major versions (#37313)
* Update go js dependencies (#37312)
* Fail vite build on rolldown warnings via NODE_ENV=test (#37270)
* Remove htmx (#37224)
* Replace custom Go formatter with `golangci-lint fmt` (#37194)
* Refactor htmx and fetch-action related code (#37186)
* Integrate renovate bot for all dependency updates (#37050)
* Build(sign): move to sigstore (#38250)
* DOCS
* Docs: update changelog for 1.26.3 & 1.26.4 (#38178)
* Docs: fix duplicated word in foreachref doc comment (#38161)
* Docs: Clarify criteria for becoming a merger (#38113)
* Docs: Publish TOC Election Result 2026 (#38111)
* Docs: mark openapi3 as autogenerated in attributes (#37963)
* Docs: add development setup guide (#37960)
* MISC
* Revert(sign): restore gpg (#38251)
* Refactor: replace legacy `delete-button` with `link-action` (#38143)
* Refactor(actions): read runner capabilities from proto field (#38068)
* Refactor(api): clarify APIError message usage and fix legacy lint error (#38012)
* Refactor: Use db.Get[] instead of db.GetEngine(ctx).Get(bean) to avoid zero value fetching wrong database record (#37977)
* Fix(deps): update go dependencies (#37851)
* Ci: Fix sync PR labels from the conventional-commit title (#37784) (#37825)
* Ci: tweak `files-changed`, add `free-disk-space` (#37819)
* Fix(deps): update module golang.org/x/crypto to v0.52.0 [security] (#37806)
* Test(e2e): add comment, release, star, PR and fork tests (#37800)
* Chore: simplify issue and pull request templates (#37799)
* Chore: Update giteabot to fix failure when backport (#37789)
* Fix(api): handle partial failures in push mirror synchronization gracefully (#37782)
* Fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771)
* Ci: split giteabot workflow (#37770)
* Fix(deps): update npm dependencies (#37768)
* Refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764)
* Fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762)
* Ci: fix cache-related issues (#37761)
* Chore: fix tests (#37760)
* Fix(deps): update module github.com/google/go-github/v85 to v86 (#37754)
* Fix(deps): update npm dependencies (#37753)
* Fix(deps): update go dependencies (#37752)
* Chore(deps): update action dependencies (#37751)
* Fix(markup): wrap indented code blocks for the code-copy button (#37748)
* Chore(db): introduce db.Session and db.EngineMigration interfaces (#37746)
* Feat(web): also display PR counts in repo list (#37739)
* Refactor(glob): use strings.Builder for regexp compilation (#37730)
* Chore(doctor): remove four obsolete doctor check implementations (#37728)
* Refactor(org): simplify owner-team org repo creation logic (#37727)
* Refactor: move `workflowpattern` into `modules/actions` (#37717)
* Chore: clean up tests (#37715)
* Style: misc UI fixes (#37691)
* Ci: add shellcheck linter (#37682)
* Fix: catch and fix more lint problems (#37674)
* Fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
* Fix(deps): update npm dependencies (#37647)
* Ci(renovate): update Go import paths on major bumps (#37641)
* Fix(deps): update go dependencies (major) (#37639)
* Chore(deps): update action dependencies (major) (#37638)
* Fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#37637)
* Fix(deps): update npm dependencies (#37636)
* Refactor(log): replace log.Critical with log.Error (#37624)
* Build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#37616)
* Feat(oauth): Support AWS Cognito OAuth2 provider (#37607)
* Chore(deps): update action dependencies (#37603)
* Ci: allow `chore` type in PR title lint (#37575)
* Refactor: only reset a database table when the table's data was changed (#37573)
* Ci: increase renovate frequency and fix RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (#37565)
* Refactor: use modernc sqlite driver as default (#37562)
* Docs: fix 4 typos in CHANGELOG.md (#37549)
* Fix(deps): update go dependencies (#37541)
* Chore(deps): update action dependencies (#37540)
* Refactor pull request view (6) (#37522)
* Fix: redirect early CLI console logger to stderr (#37507)
* Refactor "flex-list" to "flex-divided-list" (#37505)
* Refactor compare diff/pull page (1) (#37481)
* Refactor pull request view (4) (#37451)
* Update 1.26.1 changelog in main (#37442)
* Refactor: use named `Permission` field in `Repository` struct instead of anonymous embedding (#37441)
* Refactor: serve site manifest via `/assets/site-manifest.json` endpoint (#37405)
* Remove IsValidExternalURL/IsAPIURL and use IsValidURL at call sites (#37364)
* Update `Block a user` form (#37359)
* Move review request functions to a standalone file (#37358)
* Feat(security): set X-Content-Type-Options: nosniff by default (#37354)
* Enable strict TypeScript, add `errorMessage` helper (#37292)
* Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291)
* Update Nix flake (#37284)
* Fix Repository transferring page (#37277)
* Remove `SubmitEvent` polyfill (#37276)
* Remove dead code identified by `deadcode` tool (#37271)
* Upgrade go-git to v5.18.0 (#37268)
* Don't add useless labels which will bother changelog generation (#37267)
* Move heatmap to first-party code (#37262)
* Tests/integration: simplify code (#37249)
* Add pagination and search box to org teams list (#37245)
* Remove error returns from crypto random helpers and callers (#37240)
* Add `ExternalIDClaim` option for OAuth2 OIDC auth source (#37229)
* Refactor: simplify ParseCatFileTreeLine and catBatchParseTreeEntries (#37210)
* Refactor "htmx" to "fetch action" (#37208)
* Update go js py dependencies (#37204)
* Add comment for the design of "user activity time" (#37195)
* Remove outdated RunUser logic (#37180)
* Models/fixtures: add "DO NOT add more test data" comment to all yml fixture files (#37150)
* Update javascript dependencies (#37142)
* Update go dependencies (#37141)
* Frontport changelog of v1.26.0-rc0 (#37138)
* Introduce `ActionRunAttempt` to represent each execution of a run (#37119)
* Workflow Artifact Info Hover (#37100)
* Extend issue context popup beyond markdown content (#36908)
* Add bulk repository deletion for organizations (#36763)
* Feat: Add bypass allowlist for branch protection (#36514)
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
* SECURITY
+4
View File
@@ -3008,6 +3008,10 @@ LEVEL = Info
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
;; Maximum number of attempts a single workflow run can have. Default value is 50.
;MAX_RERUN_ATTEMPTS = 50
;; Maximum number of runners that may run the task-assignment query concurrently, per Gitea instance.
;; Caps this instance's DB load when many runners poll at once; excess runners retry on their next poll.
;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16.
;MAX_CONCURRENT_TASK_PICKS = 16
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+1 -1
View File
@@ -121,7 +121,7 @@ func (run *ActionRun) PrettyRef() string {
return refName.ShortName()
}
// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
// RefTooltip return a tooltip of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
func (run *ActionRun) RefTooltip() string {
payload, err := run.GetPullRequestEventPayload()
if err == nil && payload != nil && payload.PullRequest != nil {
+2 -2
View File
@@ -135,8 +135,8 @@ type StatusInfo struct {
// GetStatusInfoList returns a slice of StatusInfo
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
// same as those in aggregateJobStatus
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
statusInfoList := make([]StatusInfo, 0, len(allStatus))
for _, s := range allStatus {
statusInfoList = append(statusInfoList, StatusInfo{
+3
View File
@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
assert.Equal(t, []StatusInfo{
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
}, statusInfoList)
}
+28 -5
View File
@@ -75,8 +75,28 @@ type ActionRunner struct {
const (
RunnerOfflineTime = time.Minute
RunnerIdleTime = 10 * time.Second
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
RunnerHeartbeatInterval = 30 * time.Second
// RunnerActiveInterval is how often last_active is persisted while a runner
// streams task updates and logs. Must stay well below RunnerIdleTime so a
// busy runner keeps showing ACTIVE without a DB write on every RPC.
RunnerActiveInterval = 5 * time.Second
)
// ShouldPersistLastOnline reports whether last_online is stale enough to be
// worth writing back. Avoids a DB write on every runner poll.
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
}
// ShouldPersistLastActive reports whether last_active is stale enough to be
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
// a runner is actively streaming logs.
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerActiveInterval
}
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
func (r *ActionRunner) BelongsToOwnerName() string {
if r.RepoID != 0 {
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
}
func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries,
// otherwise the same runner may appear on more than one page.
switch opts.Sort {
case "online":
return "last_online DESC"
return "last_online DESC, id ASC"
case "offline":
return "last_online ASC"
return "last_online ASC, id ASC"
case "alphabetically":
return "name ASC"
return "name ASC, id ASC"
case "reversealphabetically":
return "name DESC"
return "name DESC, id ASC"
case "newest":
return "id DESC"
case "oldest":
return "id ASC"
}
return "last_online DESC"
return "last_online DESC, id ASC"
}
// GetRunnerByUUID returns a runner via uuid
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPersistLastOnline(t *testing.T) {
now := time.Now()
tests := []struct {
name string
last timeutil.TimeStamp
want bool
}{
{
name: "fresh, skip write",
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
want: false,
},
{
name: "exactly at interval, write",
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
want: true,
},
{
name: "stale, write",
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
want: true,
},
{
name: "zero (never seen), write",
last: 0,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
})
}
}
func TestShouldPersistLastActive(t *testing.T) {
now := time.Now()
tests := []struct {
name string
last timeutil.TimeStamp
want bool
}{
{
name: "fresh, skip write",
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
want: false,
},
{
name: "exactly at interval, write",
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
want: true,
},
{
name: "stale, write",
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
want: true,
},
{
name: "zero (never seen), write",
last: 0,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
})
}
}
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
// Sorts on a non-unique column must end with the unique id tiebreaker so
// pagination is deterministic; without it, runners sharing the same
// last_online or name can appear on more than one page. Sorts already on
// the unique id need no tiebreaker.
expected := map[string]string{
"": "last_online DESC, id ASC",
"online": "last_online DESC, id ASC",
"offline": "last_online ASC, id ASC",
"alphabetically": "name ASC, id ASC",
"reversealphabetically": "name DESC, id ASC",
"newest": "id DESC",
"oldest": "id ASC",
}
for sort, want := range expected {
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
}
}
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
runner := &ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}
require.NoError(t, db.Insert(ctx, runner))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
+42 -17
View File
@@ -231,6 +231,11 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
// another runner won the optimistic-lock race; it is never returned to callers.
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
// so a large backlog is not fetched into memory on every runner poll.
// It is a var only so tests can shrink it to exercise pagination cheaply.
var pickTaskBatchSize = 100
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
// atomically claims it. It iterates through all matching jobs so that a
// concurrent claim by another runner (which would lose the optimistic lock on
@@ -249,31 +254,51 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
}
if jobCond.IsValid() {
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
}
var jobs []*ActionRunJob
if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
return nil, false, err
}
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
// TODO: a more efficient way to filter labels
log.Trace("runner labels: %v", runner.AgentLabels)
for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) {
continue
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
// Keyset pagination on (updated, id) is safe under concurrent claims:
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
var cursorUpdated timeutil.TimeStamp
var cursorID int64
for {
cond := baseCond
if cursorID > 0 {
cond = cond.And(builder.Or(
builder.Gt{"updated": cursorUpdated},
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
))
}
task, ok, err := claimJobForRunner(ctx, runner, v)
if err != nil {
var jobs []*ActionRunJob
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err
}
if ok {
return task, true, nil
for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) {
continue
}
task, ok, err := claimJobForRunner(ctx, runner, v)
if err != nil {
return nil, false, err
}
if ok {
return task, true, nil
}
// Another runner claimed this job concurrently; try the next one.
}
// Another runner claimed this job concurrently; try the next one.
// A short page means no waiting jobs remain beyond it.
if len(jobs) < pickTaskBatchSize {
return nil, false, nil
}
last := jobs[len(jobs)-1]
cursorUpdated, cursorID = last.Updated, last.ID
}
return nil, false, nil
}
// claimJobForRunner attempts to atomically claim job for runner inside its own
+71
View File
@@ -371,3 +371,74 @@ func TestReleaseTaskForRunner(t *testing.T) {
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
}
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
func TestCreateTaskForRunnerPagination(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
pickTaskBatchSize = 2
run := &ActionRun{
Title: "pagination-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 9903,
TriggerUserID: 2,
Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
// Five waiting jobs the runner cannot run, then one it can.
// With a page size of 2 the matching job only appears on the third page.
for i := range 5 {
mismatch := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "mismatch-" + string(rune('a'+i)),
Attempt: 1,
JobID: "mismatch-" + string(rune('a'+i)),
Status: StatusWaiting,
RunsOn: []string{"windows-latest"},
}
require.NoError(t, db.Insert(t.Context(), mismatch))
}
target := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "target-job",
Attempt: 1,
JobID: "target-job",
Status: StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
}
require.NoError(t, db.Insert(t.Context(), target))
runner := &ActionRunner{
UUID: "pagination-runner-uuid",
Name: "pagination-runner",
AgentLabels: []string{"ubuntu-latest"},
}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
task, ok, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
assert.Equal(t, StatusRunning, claimed.Status)
assert.Equal(t, task.ID, claimed.TaskID)
}
+53
View File
@@ -324,6 +324,59 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
}
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
// and reject reviews of an issue against its pull request's current base branch.
// It must be called whenever the target branch changes, otherwise an approval that
// was official on the previous (possibly unprotected) branch would keep satisfying
// the new branch's protection rules.
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
if err := issue.LoadPullRequest(ctx); err != nil {
return err
}
// Clearing and restoring the official flags must happen atomically, otherwise a
// failure in between would leave the reviews without any official flag set.
return db.WithTx(ctx, func(ctx context.Context) error {
// Only the latest approve/reject review of each reviewer counts as official, so
// clear the flag on all of them first and restore it only where it still applies.
if _, err := db.GetEngine(ctx).
Where("issue_id = ?", issue.ID).
In("type", ReviewTypeApprove, ReviewTypeReject).
Cols("official").
Update(&Review{Official: false}); err != nil {
return err
}
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
IssueID: issue.ID,
})
if err != nil {
return err
}
for _, review := range reviews {
if err := review.LoadReviewer(ctx); err != nil {
return err
}
if review.Reviewer == nil {
continue
}
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
if err != nil {
return err
}
if official {
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
return err
}
}
}
return nil
})
}
// CreateReview creates a new review based on opts
func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Review, error) {
+44
View File
@@ -6,6 +6,8 @@ package issues_test
import (
"testing"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -386,3 +388,45 @@ func TestAddReviewRequest(t *testing.T) {
assert.NotNil(t, comment.CommentMetaData)
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
}
func TestRecalculateReviewsOfficial(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// PR #2 targets repo1's "master" branch. Simulate an approval that became
// official while the PR targeted an unprotected branch.
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Type: issues_model.ReviewTypeApprove,
Issue: issue,
Reviewer: reviewer,
Official: true,
})
assert.NoError(t, err)
// Protect the (now current) target branch with an approvals whitelist that
// does not include the reviewer, mirroring a retarget onto a protected branch.
rule := &git_model.ProtectedBranch{
RepoID: issue.RepoID,
RuleName: "master",
EnableApprovalsWhitelist: true,
ApprovalsWhitelistUserIDs: []int64{2},
RequiredApprovals: 1,
}
assert.NoError(t, db.Insert(t.Context(), rule))
// Re-evaluating must strip the stale official flag, otherwise the approval
// would still satisfy the protected branch's required approvals.
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
assert.False(t, review.Official)
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
assert.NoError(t, err)
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
assert.True(t, review.Official)
}
+23 -8
View File
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
type SearchTeamRepoOptions struct {
db.ListOptions
TeamID int64
// PublicOnly restricts the result (and count) to non-private repositories.
PublicOnly bool
}
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
cond := builder.NewCond()
if opts.TeamID > 0 {
cond = cond.And(builder.In("id",
builder.Select("repo_id").
From("team_repo").
Where(builder.Eq{"team_id": opts.TeamID}),
))
}
if opts.PublicOnly {
cond = cond.And(builder.Eq{"is_private": false})
}
return cond
}
// GetTeamRepositories returns paginated repositories in team of organization.
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
sess := db.GetEngine(ctx)
if opts.TeamID > 0 {
sess = sess.In("id",
builder.Select("repo_id").
From("team_repo").
Where(builder.Eq{"team_id": opts.TeamID}),
)
}
sess := db.GetEngine(ctx).Where(opts.toCond())
if opts.PageSize > 0 {
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
Find(&repos)
}
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
}
// AccessibleReposEnvironment operations involving the repositories that are
// accessible to a particular user
type AccessibleReposEnvironment interface {
+14 -2
View File
@@ -22,6 +22,9 @@ type StarredReposOptions struct {
StarrerID int64
RepoOwnerID int64
IncludePrivate bool
// Actor is the user the private repositories are gated on: a private repo is only
// returned when Actor still has access to it, even if it was starred while access was granted.
Actor *user_model.User
}
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
@@ -39,7 +42,10 @@ func (opts *StarredReposOptions) ToConds() builder.Cond {
"repository.owner_id": opts.RepoOwnerID,
})
}
if !opts.IncludePrivate {
if opts.IncludePrivate {
// only include private repos the actor can still access, so metadata does not leak after access revocation
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
} else {
cond = cond.And(builder.Eq{
"repository.is_private": false,
})
@@ -66,6 +72,9 @@ type WatchedReposOptions struct {
WatcherID int64
RepoOwnerID int64
IncludePrivate bool
// Actor is the user the private repositories are gated on: a private repo is only
// returned when Actor still has access to it, even if it was watched while access was granted.
Actor *user_model.User
}
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
@@ -83,7 +92,10 @@ func (opts *WatchedReposOptions) ToConds() builder.Cond {
"repository.owner_id": opts.RepoOwnerID,
})
}
if !opts.IncludePrivate {
if opts.IncludePrivate {
// only include private repos the actor can still access, so metadata does not leak after access revocation
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
} else {
cond = cond.And(builder.Eq{
"repository.is_private": false,
})
+15 -10
View File
@@ -55,29 +55,34 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
return parsed, nil
}
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event, returning those whose `on:` matches.
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event.
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
func MatchScopedWorkflows(
parsed []*ParsedScopedWorkflow,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
) []*DetectedWorkflow {
workflows := make([]*DetectedWorkflow, 0, len(parsed))
) (matched, filtered []*DetectedWorkflow) {
for _, p := range parsed {
for _, evt := range p.Events {
if evt.IsSchedule() {
// schedule is a non-target for scoped workflows
continue
}
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
workflows = append(workflows, &DetectedWorkflow{
EntryName: p.EntryName,
TriggerEvent: evt,
Content: p.Content,
})
dwf := &DetectedWorkflow{
EntryName: p.EntryName,
TriggerEvent: evt,
Content: p.Content,
}
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
case detectMatched:
matched = append(matched, dwf)
case detectFilteredOut:
filtered = append(filtered, dwf)
case detectNotApplicable:
}
}
}
return workflows
return matched, filtered
}
+99 -57
View File
@@ -30,6 +30,14 @@ type DetectedWorkflow struct {
Content []byte
}
type detectResult int
const (
detectMatched detectResult = iota // event matched; run normally
detectNotApplicable // event/type doesn't apply; create nothing
detectFilteredOut // matched but excluded by a branch/paths filter; posts a skipped commit status when the context is a required check
)
func init() {
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
// Log the error instead of panic or fatal.
@@ -172,18 +180,16 @@ func DetectWorkflows(
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
detectSchedule bool,
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
_, entries, err := ListWorkflows(commit)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
workflows := make([]*DetectedWorkflow, 0, len(entries))
schedules := make([]*DetectedWorkflow, 0, len(entries))
for _, entry := range entries {
content, err := GetContentFromEntry(entry)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
// one workflow may have multiple events
@@ -203,18 +209,24 @@ func DetectWorkflows(
}
schedules = append(schedules, dwf)
}
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
} else {
dwf := &DetectedWorkflow{
EntryName: entry.Name(),
TriggerEvent: evt,
Content: content,
}
workflows = append(workflows, dwf)
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
case detectMatched:
workflows = append(workflows, dwf)
case detectFilteredOut:
filtered = append(filtered, dwf)
case detectNotApplicable:
}
}
}
}
return workflows, schedules, nil
return workflows, schedules, filtered, nil
}
func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
@@ -252,9 +264,9 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
return wfs, nil
}
func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool {
func detectWorkflowMatch(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
if !canGithubEventMatch(evt.Name, triggedEvent) {
return false
return detectNotApplicable
}
switch triggedEvent {
@@ -268,7 +280,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
}
// no special filter parameters for these events, just return true if name matched
return true
return detectMatched
case // push
webhook_module.HookEventPush:
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel,
webhook_module.HookEventIssueMilestone:
return matchIssuesEvent(payload.(*api.IssuePayload), evt)
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // issue_comment
webhook_module.HookEventIssueComment,
// `pull_request_comment` is same as `issue_comment`
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
webhook_module.HookEventPullRequestComment:
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request
webhook_module.HookEventPullRequest,
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
case // pull_request_review
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected:
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // pull_request_review_comment
webhook_module.HookEventPullRequestReviewComment:
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
return detectMatched
}
return detectNotApplicable
case // release
webhook_module.HookEventRelease:
return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // registry_package
webhook_module.HookEventPackage:
return matchPackageEvent(payload.(*api.PackagePayload), evt)
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
return detectMatched
}
return detectNotApplicable
case // workflow_run
webhook_module.HookEventWorkflowRun:
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
return detectMatched
}
return detectNotApplicable
default:
log.Warn("unsupported event %q", triggedEvent)
return false
return detectNotApplicable
}
}
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) detectResult {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return true
return detectMatched
}
matchTimes := 0
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
case "paths-ignore":
if refName.IsTag() {
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
default:
log.Warn("push event unsupported condition %q", cond)
@@ -427,7 +460,10 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
if hasBranchFilter && hasTagFilter {
matchTimes++
}
return matchTimes == len(evt.Acts())
if matchTimes == len(evt.Acts()) {
return detectMatched
}
return detectFilteredOut
}
func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
@@ -478,7 +514,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
return matchTimes == len(evt.Acts())
}
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) detectResult {
acts := evt.Acts()
activityTypeMatched := false
matchTimes := 0
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
if err != nil {
log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
return false
return detectNotApplicable
}
}
@@ -557,33 +593,39 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
case "paths-ignore":
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
if err != nil {
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
} else {
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
return detectNotApplicable
}
patterns, err := workflowpattern.CompilePatterns(vals...)
if err != nil {
break
}
if !workflowpattern.Filter(patterns, filesChanged) {
matchTimes++
}
default:
log.Warn("pull request event unsupported condition %q", cond)
}
}
return activityTypeMatched && matchTimes == len(evt.Acts())
if !activityTypeMatched {
return detectNotApplicable
}
if matchTimes != len(evt.Acts()) {
return detectFilteredOut
}
return detectMatched
}
func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
+29 -17
View File
@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
triggedEvent webhook_module.HookEventType
payload api.Payloader
yamlOn string
expected bool
expected detectResult
}{
{
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
triggedEvent: webhook_module.HookEventCreate,
payload: nil,
yamlOn: "on: create",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueOpened},
yamlOn: "on: issues",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
triggedEvent: webhook_module.HookEventIssues,
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
yamlOn: "on: issues",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
triggedEvent: webhook_module.HookEventPullRequestSync,
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
yamlOn: "on: pull_request",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on: pull_request",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
yamlOn: "on: pull_request",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
@@ -155,56 +155,56 @@ func TestDetectMatched(t *testing.T) {
},
},
yamlOn: "on:\n pull_request:\n branches: [main]",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
triggedEvent: webhook_module.HookEventPullRequest,
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
yamlOn: "on:\n pull_request:\n types: [labeled]",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [published]",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage,
payload: &api.PackagePayload{Action: api.HookPackageCreated},
yamlOn: "on:\n registry_package:\n types: [updated]",
expected: false,
expected: detectNotApplicable,
},
{
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
triggedEvent: webhook_module.HookEventWiki,
payload: nil,
yamlOn: "on: gollum",
expected: true,
expected: detectMatched,
},
{
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
triggedEvent: webhook_module.HookEventSchedule,
payload: nil,
yamlOn: "on: schedule",
expected: true,
expected: detectMatched,
},
{
desc: "push to tag matches workflow with paths condition (should skip paths check)",
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
},
commit: nil,
yamlOn: "on:\n push:\n paths:\n - src/**",
expected: true,
expected: detectMatched,
},
{
desc: "push branch filter excludes -> filtered out",
triggedEvent: webhook_module.HookEventPush,
payload: &api.PushPayload{
Ref: "refs/heads/feature/x",
Before: "0000000",
Commits: []*api.PayloadCommit{{ID: "abc", Added: []string{"a.go"}, Message: "x"}},
},
commit: nil,
yamlOn: "on:\n push:\n branches: [main]",
expected: detectFilteredOut,
},
}
@@ -231,7 +243,7 @@ func TestDetectMatched(t *testing.T) {
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
assert.NoError(t, err)
assert.Len(t, evts, 1)
assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
assert.Equal(t, tc.expected, detectWorkflowMatch(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
})
}
}
+52 -16
View File
@@ -10,16 +10,22 @@ import (
"sync"
"gitea.dev/modules/charset"
"gitea.dev/modules/container"
"gitea.dev/modules/util"
)
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
const CoAuthoredByTrailer = "Co-authored-by"
const (
commitIdentityRoleAuthor = 1
commitIdentityRoleCommitter = 2
commitIdentityRoleCoAuthor = 3
)
type CommitIdentity struct {
Name string
Email string
role int
}
// CommitMessageTrailerValues keys are all in lower-case
@@ -33,7 +39,9 @@ type CommitMessage struct {
trailerValues CommitMessageTrailerValues
allParticipants []*CommitIdentity
allParticipants []*CommitIdentity
committerCoAuthorIdx int
committerCoAuthor *CommitIdentity
}
func (c *CommitMessage) MessageUTF8() string {
@@ -105,29 +113,57 @@ func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
return c.allParticipants
}
exclude := container.Set[string]{}
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
exclude.Add(strings.ToLower(c.Author.Email))
addParticipant := func(name, email string) {
exclude := map[string]int{}
addParticipant := func(name, email string, role int) (existingRole int) {
if name == "" && email == "" {
return
return 0
}
emailLower := strings.ToLower(email)
if emailLower != "" && exclude.Contains(emailLower) {
return
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
return existingRole
}
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
exclude.Add(emailLower)
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
exclude[emailLower] = role
return 0
}
addParticipant(c.Committer.Name, c.Committer.Email)
c.committerCoAuthorIdx = -1
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
addr, err := mail.ParseAddress(coAuthorValue)
coAuthorName, coAuthorEmail := coAuthorValue, ""
if err == nil {
addParticipant(addr.Name, addr.Address)
} else {
addParticipant(coAuthorValue, "")
coAuthorName, coAuthorEmail = addr.Name, addr.Address
}
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
c.committerCoAuthorIdx = len(c.allParticipants)
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
}
}
return c.allParticipants
}
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
// * Author & Co-author: they changed the code (attribution)
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
// So, a committer can also be a co-author if they changed the code.
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
all := c.AllParticipantIdentities()
if len(all) <= 1 {
return nil // no co-author list
}
if all[1].role != commitIdentityRoleCommitter {
return all[1:] // no committer, so all after author are co-authors
}
if c.committerCoAuthorIdx == -1 {
return all[2:] // the committer is not in the co-author list, so just return the co-author list
}
// the committer is in the co-author list but de-duplicated, so include them as co-author again
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
coAuthors = append(coAuthors, c.committerCoAuthor)
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
return coAuthors
}
+77 -30
View File
@@ -47,36 +47,83 @@ func TestCommitMessageTrailer(t *testing.T) {
}
}
func TestCommitMessageAllParticipantIdentities(t *testing.T) {
func TestCommitMessageParticipants(t *testing.T) {
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} }
cases := []struct {
commit *Commit
participant []*CommitIdentity
}{
{
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
},
[]*CommitIdentity{idt("a", "a@m.com"), idt("c", "c@m.com"), idt("", "x@m.com")},
},
{
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
},
[]*CommitIdentity{idt("a", "a@m.com")},
},
{
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")},
},
}
for _, c := range cases {
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
type testCase struct {
name string
commit *Commit
identities []*CommitIdentity
}
t.Run("AllParticipants", func(t *testing.T) {
cases := []testCase{
{
"DifferentUsers",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)},
},
{
"SameUser",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
},
{
"NoCommitter",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
},
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)},
},
}
for _, c := range cases {
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name)
}
})
t.Run("CoAuthors", func(t *testing.T) {
cases := []testCase{
{
"GenuineCoAuthor",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>"},
},
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor)},
},
{
"CoAuthorIsCommitter",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
},
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
},
{
"CoAuthorIsAuthor",
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: a <a@m.com>"},
},
[]*CommitIdentity{},
},
{
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"},
},
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
},
}
for _, c := range cases {
assert.Equal(t, c.identities, c.commit.CoAuthorIdentities(), "case: %s", c.name)
}
})
}
+13 -13
View File
@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
assert.Equal(t, int64(3), commitsCount)
}
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestCommitsCountWithSinceUntil(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
@@ -65,6 +52,19 @@ func TestCommitsCountWithSinceUntil(t *testing.T) {
}
}
func TestCommitsCountWithoutBase(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
CommitsCountOptions{
Not: "master",
Revision: []string{"branch1"},
})
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
+17 -6
View File
@@ -14,6 +14,8 @@ import (
const defaultMaxRerunAttempts = 50
const defaultMaxConcurrentTaskPicks = 16
// Actions settings
var (
Actions = struct {
@@ -31,13 +33,18 @@ var (
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
// MaxConcurrentTaskPicks bounds how many runners may run the task-assignment
// transaction at once per Gitea instance, to avoid a thundering herd when many
// runners poll together. It is a per-process limit, not a cluster-wide one.
MaxConcurrentTaskPicks int `ini:"MAX_CONCURRENT_TASK_PICKS"`
}{
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts,
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
MaxRerunAttempts: defaultMaxRerunAttempts,
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
}
)
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
}
if Actions.MaxConcurrentTaskPicks <= 0 {
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
}
if !Actions.LogCompression.IsValid() {
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
}
+14 -13
View File
@@ -73,17 +73,18 @@ func TestInitKeys(t *testing.T) {
require.NoError(t, err)
assert.Len(t, keyFiles, len(keyTypes))
metadata := map[string]os.FileInfo{}
// Record file contents so regeneration can be detected
content := map[string][]byte{}
for _, keyType := range keyTypes {
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
info, err := os.Stat(privKeyPath)
data, err := os.ReadFile(privKeyPath)
require.NoError(t, err)
metadata[privKeyPath] = info
content[privKeyPath] = data
info, err = os.Stat(pubKeyPath)
data, err = os.ReadFile(pubKeyPath)
require.NoError(t, err)
metadata[pubKeyPath] = info
content[pubKeyPath] = data
}
// Test recreation on missing private key and noop for missing pub key
@@ -98,26 +99,26 @@ func TestInitKeys(t *testing.T) {
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
infoPriv, err := os.Stat(privKeyPath)
dataPriv, err := os.ReadFile(privKeyPath)
require.NoError(t, err)
switch keyType {
case "rsa":
// No modification to RSA key
infoPub, err := os.Stat(pubKeyPath)
dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err)
assert.Equal(t, metadata[privKeyPath], infoPriv)
assert.Equal(t, metadata[pubKeyPath], infoPub)
assert.Equal(t, content[privKeyPath], dataPriv)
assert.Equal(t, content[pubKeyPath], dataPub)
case "ecdsa":
// ECDSA public key should be missing, private unchanged
assert.Equal(t, metadata[privKeyPath], infoPriv)
assert.Equal(t, content[privKeyPath], dataPriv)
assert.NoFileExists(t, pubKeyPath)
case "ed25519":
// ed25519 private key was removed, so both keys regenerated
infoPub, err := os.Stat(pubKeyPath)
dataPub, err := os.ReadFile(pubKeyPath)
require.NoError(t, err)
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
assert.NotEqual(t, content[privKeyPath], dataPriv)
assert.NotEqual(t, content[pubKeyPath], dataPub)
}
}
}
+27 -33
View File
@@ -6,6 +6,7 @@ package storage
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
@@ -47,40 +48,42 @@ type MinioStorage struct {
basePath string
}
func convertMinioErr(err error) error {
func convertMinioErr(err error, optMsg ...string) error {
if err == nil {
return nil
}
errResp, ok := err.(minio.ErrorResponse)
wrapErr := func(err error) error {
if len(optMsg) == 0 {
return err
}
return fmt.Errorf("%s: %w", optMsg[0], err)
}
errResp, ok := errors.AsType[minio.ErrorResponse](err)
if !ok {
return err
return wrapErr(err)
}
// Convert two responses to standard analogues
switch errResp.Code {
case "NoSuchKey":
return os.ErrNotExist
return wrapErr(os.ErrNotExist)
case "AccessDenied":
return os.ErrPermission
return wrapErr(os.ErrPermission)
}
return err
}
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
_, err := minioClient.GetBucketVersioning(ctx, bucket)
return err
return wrapErr(err)
}
// NewMinioStorage returns a minio storage
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
config := cfg.MinioConfig
log.Info("Creating minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
}
log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
var lookup minio.BucketLookupType
switch config.BucketLookUpType {
case "auto", "":
@@ -93,6 +96,13 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
return nil, fmt.Errorf("invalid minio bucket lookup type: %s", config.BucketLookUpType)
}
// The request error message is something like:
// * "The request signature we calculated does not match the signature you provided. Check your key and signing method."
// It doesn't contain useful information to site admin, so here we wrap the error with our error message
// to tell the site admin what is the problem.
makeErrMsg := func(hint string) string {
return fmt.Sprintf("ObjectStorage.%s: endpoint=%s, location=%s, bucket=%s", hint, config.Endpoint, config.Location, config.Bucket)
}
minioClient, err := minio.New(config.Endpoint, &minio.Options{
Creds: buildMinioCredentials(config),
Secure: config.UseSSL,
@@ -101,37 +111,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
BucketLookup: lookup,
})
if err != nil {
return nil, convertMinioErr(err)
}
// The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
// The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
// Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
// Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
// Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
err = getBucketVersioning(ctx, minioClient, config.Bucket)
if err != nil {
errResp, ok := err.(minio.ErrorResponse)
if !ok {
return nil, err
}
if errResp.StatusCode == http.StatusBadRequest {
log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
return nil, err
}
return nil, convertMinioErr(err, makeErrMsg("NewClient"))
}
// Check to see if we already own this bucket
exists, err := minioClient.BucketExists(ctx, config.Bucket)
if err != nil {
return nil, convertMinioErr(err)
return nil, convertMinioErr(err, makeErrMsg("BucketExists"))
}
// If the bucket doesn't exist, try to create one
if !exists {
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
Region: config.Location,
}); err != nil {
return nil, convertMinioErr(err)
return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
}
}
+4 -20
View File
@@ -4,16 +4,13 @@
package storage
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/minio/minio-go/v7"
"github.com/stretchr/testify/assert"
)
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
}
func TestS3StorageBadRequest(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("S3Storage not present outside of CI")
return
}
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
cfg := &setting.Storage{
MinioConfig: setting.MinioStorageConfig{
Endpoint: "minio:9000",
Endpoint: endpoint,
AccessKeyID: "123456",
SecretAccessKey: "12345678",
SecretAccessKey: "invalid-secret",
Bucket: "bucket",
Location: "us-east-1",
},
}
message := "ERROR"
old := getBucketVersioning
defer func() { getBucketVersioning = old }()
getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
return minio.ErrorResponse{
StatusCode: http.StatusBadRequest,
Code: "FixtureError",
Message: message,
}
}
_, err := NewStorage(setting.MinioStorageType, cfg)
assert.ErrorContains(t, err, message)
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
}
func TestMinioCredentials(t *testing.T) {
+8 -1
View File
@@ -92,9 +92,12 @@ func TestTemplateEscape(t *testing.T) {
}
t.Run("Golang URL Escape", func(t *testing.T) {
// Golang template considers "href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: demo cases (html/template/attr.go):
// Golang template considers "href", "data-href", "*src*", "*uri*", "*url*" (and more) ... attributes as contentTypeURL and does auto-escaping
actual := execTmpl(`<a href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a href="?a=%25"></a>`, actual)
actual = execTmpl(`<a data-href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-href="?a=%25"></a>`, actual)
actual = execTmpl(`<a data-xxx-url="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-xxx-url="?a=%25"></a>`, actual)
})
@@ -102,6 +105,10 @@ func TestTemplateEscape(t *testing.T) {
// non-URL content isn't auto-escaped
actual := execTmpl(`<a data-link="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-link="?a=%"></a>`, actual)
// the attr names like "data-href" and "data-action" are treated as URL (as the "data-" prefix is stripped)
// but "data-xxx-href" and "data-xxx-action" are not, so no escaping.
actual = execTmpl(`<a data-xxx-href="?a={{"%"}}"></a>`)
assert.Equal(t, `<a data-xxx-href="?a=%"></a>`, actual)
})
t.Run("QueryBuild", func(t *testing.T) {
actual := execTmpl(`<a href="{{QueryBuild "?" "a" "%"}}"></a>`)
+19 -1
View File
@@ -12,6 +12,24 @@ import (
"golang.org/x/text/language"
)
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
// malformed inputs, and its built-in guard only counts "-" separators while the
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
// truncating a longer header is safe.
const maxAcceptLanguageLen = 200
// parseAcceptLanguage parses the Accept-Language header after bounding its length
// to avoid a quadratic-time DoS on attacker-controlled input.
func parseAcceptLanguage(header string) []language.Tag {
if len(header) > maxAcceptLanguageLen {
header = header[:maxAcceptLanguageLen]
}
tags, _, _ := language.ParseAcceptLanguage(header)
return tags
}
// Locale handle locale
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
// 1. Check URL arguments.
@@ -35,7 +53,7 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
// 3. Get language information from 'Accept-Language'.
// The first element in the list is chosen to be the default language automatically.
if len(lang) == 0 {
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
tag := translation.Match(tags...)
lang = tag.String()
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package middleware
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseAcceptLanguage(t *testing.T) {
// a normal header is parsed and its leading language preserved
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
assert.NotEmpty(t, tags)
assert.Equal(t, "de-DE", tags[0].String())
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
// quadratic-time path (the built-in guard only counts "-"); the length bound
// keeps the input passed to the parser small so it cannot be used for a DoS.
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
tags = parseAcceptLanguage(malicious)
// no panic / hang, and nothing meaningful is parsed out of the garbage
assert.Empty(t, tags)
}
+19 -6
View File
@@ -8,6 +8,7 @@ import (
"crypto/subtle"
"errors"
"strings"
"time"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
@@ -45,14 +46,26 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
}
cols := []string{"last_online"}
runner.LastOnline = timeutil.TimeStampNow()
if methodName == "UpdateTask" || methodName == "UpdateLog" {
runner.LastActive = timeutil.TimeStampNow()
now := time.Now()
cols := make([]string, 0, 2)
// Debounce last_active too: while a runner streams logs, UpdateLog fires
// many times per second and writing on each is a major source of DB load.
// Persist only when stale enough to affect the active/idle status.
if (methodName == "UpdateTask" || methodName == "UpdateLog") &&
actions_model.ShouldPersistLastActive(runner.LastActive, now) {
runner.LastActive = timeutil.TimeStamp(now.Unix())
cols = append(cols, "last_active")
}
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
log.Error("can't update runner status: %v", err)
// Debounce last_online: writing on every poll is a major source of DB load
// with many runners. Persist only when stale enough to affect offline status.
if actions_model.ShouldPersistLastOnline(runner.LastOnline, now) {
runner.LastOnline = timeutil.TimeStamp(now.Unix())
cols = append(cols, "last_online")
}
if len(cols) > 0 {
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
log.Error("can't update runner status: %v", err)
}
}
ctx = context.WithValue(ctx, runnerCtxKey{}, runner)
+7 -1
View File
@@ -194,9 +194,15 @@ func (s *Service) FetchTask(
// if the task version in request is not equal to the version in db,
// it means there may still be some tasks that haven't been assigned.
// try to pick a task for the runner that send the request.
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil {
if t, ok, throttled, err := actions_service.TryPickTask(ctx, freshRunner); err != nil {
log.Error("pick task failed: %v", err)
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
} else if throttled {
// Concurrency limit reached: don't advance the runner's tasks version,
// so it retries on its next poll instead of sleeping until the next bump.
latestVersion = tasksVersion
// A steady stream here means MAX_CONCURRENT_TASK_PICKS is too low for the fleet.
log.Debug("task pick throttled for runner %q (id %d); it will retry on its next poll", freshRunner.Name, freshRunner.ID)
} else if ok {
task = t
}
+7
View File
@@ -145,6 +145,13 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
op := api.OrganizationPermissions{}
// A public-only token must not disclose membership/permission details of a
// non-public org, even for the token owner's own private orgs.
if ctx.PublicOnly && !o.Visibility.IsPublic() {
ctx.APIErrorNotFound()
return
}
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
ctx.APIErrorNotFound()
return
+47 -5
View File
@@ -567,10 +567,20 @@ func GetTeamRepos(ctx *context.APIContext) {
team := ctx.Org.Team
listOptions := utils.GetListOptions(ctx)
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
// A public-only token must not expose (or count) private repos, even when the
// doer owning the token otherwise has access to them, so filter them out at the
// query level to keep the returned page and the total-count header consistent.
searchOpts := &repo_model.SearchTeamRepoOptions{
ListOptions: listOptions,
TeamID: team.ID,
})
PublicOnly: ctx.PublicOnly,
}
teamRepos, err := repo_model.GetTeamRepositories(ctx, searchOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
count, err := repo_model.CountTeamRepositories(ctx, searchOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -584,14 +594,16 @@ func GetTeamRepos(ctx *context.APIContext) {
}
// A team's repo list is reachable by non-team-members through the team's
// visibility tier, so never expose repos (incl. their names) the doer
// cannot access.
// cannot access. This per-repo visibility trim can't be expressed in the
// SQL count above without regressing per-unit public access, so for such
// non-members the total-count header may be a small upper bound.
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, repo, permission))
}
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
ctx.SetTotalCountHeader(int64(team.NumRepos))
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, repos)
}
@@ -630,6 +642,12 @@ func GetTeamRepo(ctx *context.APIContext) {
return
}
// A public-only token must not confirm the existence of a private repo.
if !ctx.TokenCanAccessRepo(repo) {
ctx.APIErrorNotFound()
return
}
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
ctx.APIErrorNotFound()
return
@@ -664,6 +682,22 @@ func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository {
return repo
}
func canChangeTeamRepository(ctx *context.APIContext) bool {
if ctx.Org.Organization.RepoAdminChangeTeamAccess {
return true
}
isOwner, err := ctx.Org.Organization.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return false
}
if !isOwner {
ctx.APIError(http.StatusForbidden, "user is nor repo admin nor owner")
return false
}
return true
}
// AddTeamRepository api for adding a repository to a team
func AddTeamRepository(ctx *context.APIContext) {
// swagger:operation PUT /teams/{id}/repos/{org}/{repo} organization orgAddTeamRepository
@@ -700,6 +734,9 @@ func AddTeamRepository(ctx *context.APIContext) {
if ctx.Written() {
return
}
if !canChangeTeamRepository(ctx) {
return
}
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
ctx.APIErrorInternal(err)
return
@@ -752,6 +789,9 @@ func RemoveTeamRepository(ctx *context.APIContext) {
if ctx.Written() {
return
}
if !canChangeTeamRepository(ctx) {
return
}
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
ctx.APIErrorInternal(err)
return
@@ -889,6 +929,8 @@ func ListTeamActivityFeeds(ctx *context.APIContext) {
Date: ctx.FormString("date"),
ListOptions: listOptions,
}
// A public-only token must not receive private activity entries.
opts.ApplyPublicOnly(ctx.PublicOnly)
feeds, count, err := feed_service.GetFeeds(ctx, opts)
if err != nil {
+21 -2
View File
@@ -258,8 +258,27 @@ func GetAllCommits(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
} else if commitsCountTotal == 0 {
ctx.APIErrorNotFound()
return
// when date filters are active, a zero count may just mean no
// commits in the requested range — not that the path is invalid
if since == "" && until == "" {
ctx.APIErrorNotFound()
return
}
// verify the path actually exists in the revision history
totalWithoutDate, err := gitrepo.CommitsCount(ctx, ctx.Repo.Repository,
gitrepo.CommitsCountOptions{
Not: not,
Revision: []string{sha},
RelPath: []string{path},
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if totalWithoutDate == 0 {
ctx.APIErrorNotFound()
return
}
}
commits, _, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
+33
View File
@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
@@ -132,11 +133,33 @@ func ListTrackedTimes(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
}
func filterTrackedTimesByAccess(ctx *context.APIContext, trackedTimes issues_model.TrackedTimeList) (issues_model.TrackedTimeList, error) {
filtered := make(issues_model.TrackedTimeList, 0, len(trackedTimes))
for _, trackedTime := range trackedTimes {
if trackedTime.Issue == nil || trackedTime.Issue.Repo == nil {
continue
}
permission, err := access_model.GetIndividualUserRepoPermission(ctx, trackedTime.Issue.Repo, ctx.Doer)
if err != nil {
return nil, err
}
if permission.HasAnyUnitAccessOrPublicAccess() {
filtered = append(filtered, trackedTime)
}
}
return filtered, nil
}
// AddTime add time manual to the given issue
func AddTime(ctx *context.APIContext) {
// swagger:operation Post /repos/{owner}/{repo}/issues/{index}/times issue issueAddTime
@@ -542,6 +565,11 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
@@ -604,6 +632,11 @@ func ListMyTrackedTimes(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
trackedTimes, err = filterTrackedTimesByAccess(ctx, trackedTimes)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes))
+7 -3
View File
@@ -24,6 +24,7 @@ func getStarredRepos(ctx *context.APIContext, user *user_model.User, private boo
ListOptions: utils.GetListOptions(ctx),
StarrerID: user.ID,
IncludePrivate: private,
Actor: user,
}
opts.ApplyPublicOnly(ctx.PublicOnly)
@@ -32,13 +33,16 @@ func getStarredRepos(ctx *context.APIContext, user *user_model.User, private boo
return nil, err
}
repos := make([]*api.Repository, len(starredRepos))
for i, starred := range starredRepos {
repos := make([]*api.Repository, 0, len(starredRepos))
for _, starred := range starredRepos {
permission, err := access_model.GetIndividualUserRepoPermission(ctx, starred, user)
if err != nil {
return nil, err
}
repos[i] = convert.ToRepo(ctx, starred, permission)
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, starred, permission))
}
return repos, nil
}
+1
View File
@@ -22,6 +22,7 @@ func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private boo
ListOptions: utils.GetListOptions(ctx),
WatcherID: user.ID,
IncludePrivate: private,
Actor: user,
}
opts.ApplyPublicOnly(ctx.PublicOnly)
+3
View File
@@ -147,6 +147,9 @@ func MustInitSessioner() func(next http.Handler) http.Handler {
Secure: setting.SessionConfig.Secure,
SameSite: setting.SessionConfig.SameSite,
Domain: setting.SessionConfig.Domain,
// in the future, if websocket is used, the websocket handler should manage its own session sync (release)
IgnoreReleaseForWebSocket: true,
})
if err != nil {
log.Fatal("common.Sessioner failed: %v", err)
+10 -3
View File
@@ -160,10 +160,17 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
return false
}
// FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag
// These options should only be used for "push-to-create"
// Only honor these options while the repo is still empty (the push-to-create
// case). On a populated repo a bare "git push -o repo.private=..." would
// silently flip visibility, bypassing the audit log, webhooks and notifications.
if !repo.IsEmpty {
return true
}
// The repo is empty and being initialized by this push, so there is no
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
// yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
// TODO: it needs to do more work
repo.IsPrivate = isPrivate.Value()
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err)
+8 -32
View File
@@ -31,9 +31,7 @@ import (
type preReceiveContext struct {
*gitea_context.PrivateContext
// loadedPusher indicates that where the following information are loaded
loadedPusher bool
user *user_model.User // it's the org user if a DeployKey is used
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
userPerm access_model.Permission
deployKeyAccessMode perm_model.AccessMode
@@ -53,10 +51,7 @@ type preReceiveContext struct {
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
var canWrite bool
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.canWriteCodeUnitCached = &canWrite
}
return *ctx.canWriteCodeUnitCached
@@ -91,9 +86,6 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
// CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
if !ctx.checkedCanCreatePullRequest {
if !ctx.loadPusherAndPermission() {
return false
}
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
ctx.checkedCanCreatePullRequest = true
}
@@ -124,6 +116,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts,
}
if !ourCtx.loadPusherAndPermission() {
return // if error occurs, loadPusherAndPermission had written the error response
}
// Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i]
@@ -281,18 +277,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
}
} else {
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
if err != nil {
log.Error("Unable to GetUserByID for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Unable to GetUserByID for commits from %s to %s: %v", oldCommitID, newCommitID, err),
})
return
}
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, user)
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
} else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user)
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
}
}
@@ -354,12 +342,6 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
return
}
// although we should have called `loadPusherAndPermission` before, here we call it explicitly again because we need to access ctx.user below
if !ctx.loadPusherAndPermission() {
// if error occurs, loadPusherAndPermission had written the error response
return
}
// Now check if the user is allowed to merge PRs for this repository
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
@@ -499,10 +481,6 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
if ctx.loadedPusher {
return true
}
if ctx.opts.UserID == user_model.ActionsUserID {
taskID := ctx.opts.ActionsTaskID
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
@@ -555,7 +533,5 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
}
ctx.deployKeyAccessMode = deployKey.Mode
}
ctx.loadedPusher = true
return true
}
-1
View File
@@ -52,7 +52,6 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
userPerm: headPerm,
}
+5 -5
View File
@@ -118,7 +118,7 @@ func autoSignIn(ctx *context.Context) (bool, error) {
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -357,7 +357,7 @@ func SignInPost(ctx *context.Context) {
// User will need to use WebAuthn, save data
updates["totpEnrolled"] = u.ID
}
if err := updateSession(ctx, nil, updates); err != nil {
if err := regenerateSession(ctx, nil, updates); err != nil {
ctx.ServerError("UserSignIn: Unable to update session", err)
return
}
@@ -398,7 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
return
}
if err := updateSession(ctx, []string{
if err := regenerateSession(ctx, []string{
// Delete the openid, 2fa and link_account data
"openid_verified_uri",
"openid_signin_remember",
@@ -884,7 +884,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
log.Trace("User activated: %s", user.Name)
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
"uid": user.ID,
"uname": user.Name,
}); err != nil {
@@ -936,7 +936,7 @@ func ActivateEmail(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
}
func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
return fmt.Errorf("regenerate session: %w", err)
}
+6 -1
View File
@@ -164,7 +164,12 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
return
}
if err := updateSession(ctx, nil, map[string]any{
if err := Oauth2SetLinkAccountData(ctx, *linkAccountData); err != nil {
ctx.ServerError("Oauth2SetLinkAccountData", err)
return
}
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": remember,
+3 -5
View File
@@ -285,9 +285,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
}
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{
"linkAccountData": linkAccountData,
})
return ctx.Session.Set("linkAccountData", linkAccountData)
}
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
@@ -409,7 +407,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
return
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -434,7 +432,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
}
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": false,
+1 -1
View File
@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
if u != nil {
nickname = u.LowerName
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
"openid_verified_uri": id,
"openid_determined_email": email,
"openid_determined_username": nickname,
+1 -1
View File
@@ -450,7 +450,7 @@ func ViewProject(ctx *context.Context) {
ctx.Data["MilestoneID"] = milestoneID
// Get assignees.
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
if err != nil {
ctx.ServerError("LoadIssuesAssigneesForProject", err)
return
+16
View File
@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return
}
// Draft release attachments must not be exposed to anyone without write
// access, matching the API-side canAccessReleaseDraft gate. Otherwise the
// UUID-based web endpoints would leak draft attachments to any recipient of
// the (leaked) download URL.
if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) {
rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
if err != nil {
ctx.ServerError("GetReleaseByID", err)
return
}
if rel.IsDraft {
ctx.HTTPError(http.StatusNotFound)
return
}
}
if requiredScope, ok := attachmentReadScope(unitType); ok {
context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() {
+1 -1
View File
@@ -398,7 +398,7 @@ func Diff(ctx *context.Context) {
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
ctx.Data["Verification"] = verification
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:]
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.CoAuthorIdentities(), nil).Participants
ctx.Data["Parents"] = parents
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
+19
View File
@@ -130,6 +130,25 @@ func RemoveDependency(ctx *context.Context) {
return
}
// Existing cross-repo dependencies must remain removable even when
// AllowCrossRepositoryDependencies is disabled, so only enforce that the
// doer can read the dependency's repository.
if issue.RepoID != dep.RepoID {
if err := dep.LoadRepo(ctx); err != nil {
ctx.ServerError("loadRepo", err)
return
}
depRepoPerm, err := access_model.GetDoerRepoPermission(ctx, dep.Repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
if !depRepoPerm.CanReadIssuesOrPulls(dep.IsPull) {
ctx.Redirect(issue.Link())
return
}
}
if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil {
if issues_model.IsErrDependencyNotExists(err) {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist"))
+1 -21
View File
@@ -14,17 +14,6 @@ import (
"gitea.dev/services/context"
)
type userSearchInfo struct {
UserID int64 `json:"user_id"`
UserName string `json:"username"`
AvatarLink string `json:"avatar_link"`
FullName string `json:"full_name"`
}
type userSearchResponse struct {
Results []*userSearchInfo `json:"results"`
}
func IssuePullPosters(ctx *context.Context) {
isPullList := ctx.PathParam("type") == "pulls"
issuePosters(ctx, isPullList)
@@ -46,14 +35,5 @@ func issuePosters(ctx *context.Context, isPullList bool) {
posters = append(posters, ctx.Doer)
}
}
posters = shared_user.MakeSelfOnTop(ctx.Doer, posters)
resp := &userSearchResponse{}
resp.Results = make([]*userSearchInfo, len(posters))
for i, user := range posters {
resp.Results[i] = &userSearchInfo{UserID: user.ID, UserName: user.Name, AvatarLink: user.AvatarLink(ctx)}
resp.Results[i].FullName = user.FullName
}
ctx.JSON(http.StatusOK, resp)
ctx.JSON(http.StatusOK, shared_user.ToSearchUserResponse(ctx, ctx.Doer, posters))
}
+24 -55
View File
@@ -27,7 +27,6 @@ import (
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/web/feed"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
"gitea.dev/services/forms"
@@ -338,7 +337,6 @@ func LatestRelease(ctx *context.Context) {
func newReleaseCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
ctx.Data["PageIsReleaseList"] = true
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
@@ -346,17 +344,8 @@ func newReleaseCommon(ctx *context.Context) {
return
}
ctx.Data["Tags"] = tags
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
upload.AddUploadContext(ctx, "release")
PrepareBranchList(ctx) // for New Release page
}
@@ -425,8 +414,8 @@ func GenerateReleaseNotes(ctx *context.Context) {
// NewReleasePost response for creating a release
func NewReleasePost(ctx *context.Context) {
newReleaseCommon(ctx)
if ctx.Written() {
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
@@ -450,35 +439,28 @@ func NewReleasePost(ctx *context.Context) {
// Or another choice is "always show the tag-only button" if error occurs.
ctx.Data["ShowCreateTagOnlyButton"] = form.TagOnly || rel == nil
// do some form checks
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew)
return
}
form.Target = util.IfZero(form.Target, ctx.Repo.Repository.DefaultBranch)
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist {
ctx.RenderWithErrDeprecated(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("form.target_branch_not_exist"))
return
}
if !form.TagOnly && form.Title == "" {
// if not "tag only", then the title of the release cannot be empty
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.title_empty"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.title_empty"))
return
}
handleTagReleaseError := func(err error) {
ctx.Data["Err_TagName"] = true
switch {
case release_service.IsErrTagAlreadyExists(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.branch.tag_collision", form.TagName), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.branch.tag_collision", form.TagName))
case repo_model.IsErrReleaseAlreadyExist(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
case release_service.IsErrInvalidTagName(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_invalid"))
case release_service.IsErrProtectedTagName(err):
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_protected"))
default:
ctx.ServerError("handleTagReleaseError", err)
}
@@ -497,7 +479,7 @@ func NewReleasePost(ctx *context.Context) {
return
}
ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.TagName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName))
ctx.JSONRedirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.TagName))
return
}
@@ -522,7 +504,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err)
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
return
}
@@ -530,8 +512,7 @@ func NewReleasePost(ctx *context.Context) {
// old logic: if the release is not a tag (it is a real release), do not update it on the "new release" page
// add new logic: if tag-only, do not convert the tag to a release
if form.TagOnly || !rel.IsTag {
ctx.Data["Err_TagName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
ctx.JSONError(ctx.Tr("repo.release.tag_name_already_exist"))
return
}
@@ -547,7 +528,7 @@ func NewReleasePost(ctx *context.Context) {
handleTagReleaseError(err)
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
}
// EditRelease render release edit page
@@ -589,55 +570,39 @@ func EditRelease(ctx *context.Context) {
return
}
ctx.Data["attachments"] = rel.Attachments
// Get assignees.
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, rel.Repo)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.HTML(http.StatusOK, tplReleaseNew)
}
// EditReleasePost response for edit release
func EditReleasePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditReleaseForm)
newReleaseCommon(ctx)
if ctx.Written() {
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsEditRelease"] = true
form := web.GetForm(ctx).(*forms.EditReleaseForm)
tagName := ctx.PathParam("*")
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
if err != nil {
if repo_model.IsErrReleaseNotExist(err) {
ctx.NotFound(err)
ctx.JSONErrorNotFound(err.Error())
} else {
ctx.ServerError("GetRelease", err)
}
return
}
if rel.IsTag {
ctx.NotFound(err) // for a pure tag release, don't allow to edit it as a release
ctx.JSONErrorNotFound() // for a pure tag release, don't allow to edit it as a release
return
}
ctx.Data["tag_name"] = rel.TagName
ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch)
ctx.Data["title"] = rel.Title
ctx.Data["content"] = rel.Note
ctx.Data["prerelease"] = rel.IsPrerelease
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew)
return
}
const delPrefix = "attachment-del-"
const editPrefix = "attachment-edit-"
var addAttachmentUUIDs, delAttachmentUUIDs []string
@@ -659,10 +624,14 @@ func EditReleasePost(ctx *context.Context) {
rel.IsPrerelease = form.Prerelease
if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo,
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil {
ctx.ServerError("UpdateRelease", err)
if upload.IsErrFileTypeForbidden(err) {
ctx.JSONError(err.Error())
} else {
ctx.ServerError("UpdateRelease", err)
}
return
}
ctx.Redirect(ctx.Repo.RepoLink + "/releases")
ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases")
}
// DeleteRelease deletes a release
+13 -11
View File
@@ -4,12 +4,14 @@
package repo
import (
"net/http/httptest"
"testing"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/modules/test"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/contexttest"
@@ -39,15 +41,15 @@ func TestNewReleasePost(t *testing.T) {
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"])
})
post := func(t *testing.T, form forms.NewReleaseForm) *context.Context {
ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new")
post := func(t *testing.T, form forms.NewReleaseForm) (*context.Context, *httptest.ResponseRecorder) {
ctx, resp := contexttest.MockContext(t, "user2/repo1/releases/new")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
web.SetForm(ctx, &form)
NewReleasePost(ctx)
return ctx
return ctx, resp
}
loadRelease := func(t *testing.T, tagName string) *repo_model.Release {
@@ -70,7 +72,7 @@ func TestNewReleasePost(t *testing.T) {
})
t.Run("ReleaseExistsDoUpdate(non-tag)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
_, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1",
Target: "master",
Title: "updated-title",
@@ -80,11 +82,11 @@ func TestNewReleasePost(t *testing.T) {
require.NotNil(t, rel)
assert.False(t, rel.IsTag)
assert.Equal(t, "testing-release", rel.Title)
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
})
t.Run("ReleaseExistsDoUpdate(tag-only)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
ctx, resp := post(t, forms.NewReleaseForm{
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
Target: "master",
Title: "updated-title",
@@ -95,12 +97,12 @@ func TestNewReleasePost(t *testing.T) {
require.NotNil(t, rel)
assert.True(t, rel.IsTag) // the record should not be updated because the request is "tag-only". TODO: need to improve the logic?
assert.Equal(t, "delete-tag", rel.Title)
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
assert.NotEmpty(t, ctx.Data["ShowCreateTagOnlyButton"]) // still show the "tag-only" button
})
t.Run("ReleaseExistsDoUpdate(tag-release)", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
ctx, _ := post(t, forms.NewReleaseForm{
TagName: "delete-tag", // a strange name, but it is the only "is_tag=true" fixture
Target: "master",
Title: "updated-title",
@@ -114,7 +116,7 @@ func TestNewReleasePost(t *testing.T) {
})
t.Run("TagOnly", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
ctx, _ := post(t, forms.NewReleaseForm{
TagName: "new-tag-only",
Target: "master",
Title: "title",
@@ -128,7 +130,7 @@ func TestNewReleasePost(t *testing.T) {
})
t.Run("TagOnlyConflict", func(t *testing.T) {
ctx := post(t, forms.NewReleaseForm{
_, resp := post(t, forms.NewReleaseForm{
TagName: "v1.1",
Target: "master",
Title: "title",
@@ -138,7 +140,7 @@ func TestNewReleasePost(t *testing.T) {
rel := loadRelease(t, "v1.1")
require.NotNil(t, rel)
assert.False(t, rel.IsTag)
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
})
}
+33 -11
View File
@@ -11,22 +11,44 @@ import (
"gitea.dev/models/user"
)
type SearchUserInfo struct {
UserID int64 `json:"user_id"`
UserName string `json:"username"`
AvatarLink string `json:"avatar_link"`
FullName string `json:"full_name"`
}
type SearchUserResponse struct {
Results []*SearchUserInfo `json:"results"`
}
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
if doer != nil {
idx := slices.IndexFunc(users, func(u *user.User) bool {
return u.ID == doer.ID
})
if idx > 0 {
newUsers := make([]*user.User, len(users))
newUsers[0] = users[idx]
copy(newUsers[1:], users[:idx])
copy(newUsers[idx+1:], users[idx+1:])
return newUsers
}
if doer == nil {
return users
}
idx := slices.IndexFunc(users, func(u *user.User) bool {
return u.ID == doer.ID
})
if idx > 0 {
newUsers := make([]*user.User, len(users))
newUsers[0] = users[idx]
copy(newUsers[1:], users[:idx])
copy(newUsers[idx+1:], users[idx+1:])
return newUsers
}
return users
}
func ToSearchUserResponse(ctx context.Context, self *user.User, users []*user.User) (ret *SearchUserResponse) {
infos := MakeSelfOnTop(self, users)
resp := &SearchUserResponse{Results: make([]*SearchUserInfo, len(infos))}
for i, u := range infos {
resp.Results[i] = &SearchUserInfo{UserID: u.ID, UserName: u.Name, AvatarLink: u.AvatarLink(ctx)}
resp.Results[i].FullName = u.FullName
}
return resp
}
// GetFilterUserIDByName tries to get the user ID from the given username.
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
// So it's better to make it work like GitHub: users could input username directly.
+26
View File
@@ -4,6 +4,7 @@
package user
import (
stdCtx "context"
"fmt"
"net/http"
"strings"
@@ -12,8 +13,10 @@ import (
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
@@ -97,6 +100,12 @@ func prepareUserNotificationsData(ctx *context.Context) {
return
}
failCount += len(failures)
notifications, failures, err = filterNotificationsByRepoAccess(ctx, ctx.Doer, notifications)
if err != nil {
ctx.ServerError("filterNotificationsByRepoAccess", err)
return
}
failCount += len(failures)
failures, err = notifications.LoadIssues(ctx)
if err != nil {
@@ -135,6 +144,23 @@ func prepareUserNotificationsData(ctx *context.Context) {
ctx.Data["Page"] = pager
}
func filterNotificationsByRepoAccess(ctx stdCtx.Context, doer *user_model.User, notifications activities_model.NotificationList) (activities_model.NotificationList, []int, error) {
failures := make([]int, 0)
for i, notification := range notifications {
if notification.Repository == nil {
continue
}
perm, err := access_model.GetIndividualUserRepoPermission(ctx, notification.Repository, doer)
if err != nil {
return nil, nil, err
}
if !perm.HasAnyUnitAccessOrPublicAccess() {
failures = append(failures, i)
}
}
return notifications.Without(failures), failures, nil
}
// NotificationStatusPost is a route for changing the status of a notification
func NotificationStatusPost(ctx *context.Context) {
notificationID := ctx.FormInt64("notification_id")
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
activities_model "gitea.dev/models/activities"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterNotificationsByRepoAccess(t *testing.T) {
require.NoError(t, unittest.LoadFixtures())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
inaccessibleRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
require.True(t, inaccessibleRepo.IsPrivate)
accessibleRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
notifications := activities_model.NotificationList{
{ID: 1, Repository: inaccessibleRepo},
{ID: 2, Repository: accessibleRepo},
}
filtered, failures, err := filterNotificationsByRepoAccess(t.Context(), doer, notifications)
require.NoError(t, err)
assert.Equal(t, []int{0}, failures)
require.Len(t, filtered, 1)
assert.EqualValues(t, 2, filtered[0].ID)
}
+101 -4
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
@@ -14,10 +15,14 @@ import (
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/glob"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
pull_service "gitea.dev/services/pull"
commitstatus_service "gitea.dev/services/repository/commitstatus"
)
@@ -147,21 +152,113 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
}
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
}
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
if err != nil {
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
}
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
if err != nil {
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
}
globs := make([]glob.Glob, 0, len(required))
for _, c := range required {
if gp, err := glob.Compile(c); err != nil {
log.Error("glob.Compile %q: %v", c, err)
} else {
globs = append(globs, gp)
}
}
return globs, nil
}
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
// workflow that matched the triggering event but was excluded by a branch/paths filter.
// This lets a required status check tied to that context be satisfied without the workflow running.
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
//
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
if len(requiredGlobs) == 0 {
return nil // nothing is required, so no skipped status is needed
}
// Derive the status event name and target commit from the payload.
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
var statusEvent, commitID string
switch event {
case webhook_module.HookEventPush:
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
statusEvent, commitID = "push", p.HeadCommit.ID
}
case webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestReviewRequest,
webhook_module.HookEventPullRequestMilestone:
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
if triggerEvent == actions_module.GithubEventPullRequestTarget {
statusEvent = "pull_request_target"
}
}
}
if statusEvent == "" || commitID == "" {
return nil // unsupported event or missing commit id, nothing to post
}
workflows, err := jobparser.Parse(content)
if err != nil {
return fmt.Errorf("jobparser.Parse: %w", err)
}
displayName := actions_module.WorkflowDisplayName(workflowID, content)
for _, sw := range workflows {
_, job := sw.Job()
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
if scopedPrefix != "" {
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
}
// Only a required context needs the skipped status.
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
continue
}
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
return err
}
}
return nil
}
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
// (issue #35699).
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
// the legacy hash is still the "latest" for this SHA, reuse that hash so
// the new row supersedes it; otherwise the old pending status would stay
// stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time.
legacyHash := git_model.HashCommitStatusContext(ctxName)
state := toCommitStatus(job.Status)
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
description := toCommitStatusDescription(job)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
if err != nil {
+79 -7
View File
@@ -183,8 +183,9 @@ func notify(ctx context.Context, input *notifyInput) error {
}
var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
workflows, schedules, filtered, err := actions_module.DetectWorkflows(gitRepo, commit,
input.Event,
input.Payload,
shouldDetectSchedules,
@@ -212,6 +213,17 @@ func notify(ctx context.Context, input *notifyInput) error {
}
}
for _, wf := range filtered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
if input.PullRequest != nil {
// detect pull_request_target workflows
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
@@ -219,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
if err != nil {
return fmt.Errorf("DetectWorkflows: %w", err)
}
@@ -227,11 +239,24 @@ func notify(ctx context.Context, input *notifyInput) error {
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
} else {
for _, wf := range baseWorkflows {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
detectedWorkflows = append(detectedWorkflows, wf)
}
}
}
for _, wf := range baseFiltered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
}
if shouldDetectSchedules {
@@ -244,6 +269,8 @@ func notify(ctx context.Context, input *notifyInput) error {
return err
}
handleFilteredWorkflows(ctx, input, filteredWorkflows)
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
}
@@ -369,6 +396,28 @@ func buildApproveAndInsertRun(
return nil
}
// handleFilteredWorkflows posts a skipped commit status for each filtered-out workflow whose context is a required status check;
// a non-required one posts nothing, so it cannot leak into a pull request.
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
if len(filteredWorkflows) == 0 {
return
}
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("repo %s: required status contexts: %v", input.Repo.RelativePath(), err)
return
}
if len(requiredGlobs) == 0 {
return
}
for _, dwf := range filteredWorkflows {
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, "", requiredGlobs); err != nil {
log.Error("repo %s: skipped commit status for workflow %s: %v", input.Repo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
return newNotifyInput(issue.Repo, issue.Poster, event)
}
@@ -616,6 +665,12 @@ func detectAndHandleScopedWorkflows(
isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// A filtered-out scoped workflow only posts a skipped status when its context is a required check.
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("scoped workflows: required status contexts for %s: %v", input.Repo.RelativePath(), err)
}
// The same source repo may be registered at both the owner and instance level; dedup
// the IDs and batch-load them in one query instead of one round-trip per source.
seen := make(container.Set[int64], len(sources))
@@ -640,7 +695,7 @@ func detectAndHandleScopedWorkflows(
continue
}
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue
@@ -658,23 +713,40 @@ func detectAndHandleScopedWorkflows(
continue
}
}
// A filtered-out scoped workflow posts a skipped commit status for its required-check contexts.
if len(filtered) > 0 && len(requiredGlobs) > 0 {
scopedPrefix := actions_model.ScopedStatusContextPrefix(ctx, sourceRepo.ID)
for _, dwf := range filtered {
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, scopedPrefix, requiredGlobs); err != nil {
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
}
return nil
}
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch.
// detected are the workflows to run; filtered matched the event but were excluded by a branch/paths
// filter, and later post a skipped commit status only for a required-check context.
func detectScopedWorkflowsForSource(
ctx context.Context,
input *notifyInput,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
sourceRepo *repo_model.Repository,
) (sourceCommitSHA string, detected []*actions_module.DetectedWorkflow, err error) {
) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return "", nil, err
return "", nil, nil, err
}
return sourceCommitSHA, actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload), nil
detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return sourceCommitSHA, detected, filtered, nil
}
+40
View File
@@ -7,16 +7,47 @@ import (
"context"
"errors"
"fmt"
"sync"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
secret_model "gitea.dev/models/secret"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"google.golang.org/protobuf/types/known/structpb"
)
var (
taskPickSem chan struct{}
taskPickSemOnce sync.Once
)
func taskPickLimiter() chan struct{} {
taskPickSemOnce.Do(func() {
taskPickSem = make(chan struct{}, setting.Actions.MaxConcurrentTaskPicks)
})
return taskPickSem
}
// TryPickTask attempts to assign a task to the runner, bounding the number of
// concurrent assignment transactions to avoid a thundering herd when many
// runners poll at once. When the concurrency limit is reached it returns
// throttled=true without touching the DB, so the caller can let the runner
// retry on its next poll instead of advancing its tasks version.
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
sem := taskPickLimiter()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
return nil, false, true, nil
}
task, ok, err = PickTask(ctx, runner)
return task, ok, false, err
}
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
var (
task *runnerv1.Task
@@ -76,6 +107,15 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
}
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
// Release the claim so another runner can pick the job up.
if err := ctx.Err(); err != nil {
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
return nil, false, err
}
return task, true, nil
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryPickTaskThrottled(t *testing.T) {
sem := taskPickLimiter()
// Saturate every assignment slot so the next attempt must be throttled.
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
// No DB access happens on the throttled path, so this is safe without fixtures.
task, ok, throttled, err := TryPickTask(t.Context(), &actions_model.ActionRunner{})
require.NoError(t, err)
assert.Nil(t, task)
assert.False(t, ok)
assert.True(t, throttled)
}
+20 -24
View File
@@ -254,6 +254,17 @@ func BatchHandler(ctx *context.Context) {
var responseObject *lfs_module.ObjectResponse
if isUpload {
if exists && meta == nil {
// The object exists in the content store but is not linked to this
// repo. Do not auto-link it based on cross-repo access: the token
// only authorizes this repo, and for deploy keys ctx.Doer is the
// repo owner, so trusting it here would let a single-repo key pull
// objects from any repo the owner can see. Require proof of
// possession by making the client upload the (hash-verified) bytes,
// and treat it as a new upload for size-limit enforcement below.
exists = false
}
var err *lfs_module.ObjectError
if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize {
err = &lfs_module.ObjectError{
@@ -262,25 +273,6 @@ func BatchHandler(ctx *context.Context) {
}
}
if exists && meta == nil {
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
if err != nil {
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
writeStatus(ctx, http.StatusInternalServerError)
return
}
if accessible {
_, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)
if err != nil {
log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
writeStatus(ctx, http.StatusInternalServerError)
return
}
} else {
exists = false
}
}
responseObject = buildObjectResponse(rc, p, false, !exists, err)
} else {
var err *lfs_module.ObjectError
@@ -337,13 +329,17 @@ func UploadHandler(ctx *context.Context) {
uploadOrVerify := func() error {
if exists {
accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
if err != nil {
log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
// The bytes already exist in the content store. Only skip proof of
// possession when the object is already linked to *this* repo; never
// trust cross-repo access (ctx.Doer is the repo owner for deploy keys),
// which would let a caller link an object it cannot produce.
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
if err != nil && err != git_model.ErrLFSObjectNotExist {
log.Error("Unable to get LFS MetaObject [%s]. Error: %v", p.Oid, err)
return err
}
if !accessible {
// The file exists but the user has no access to it.
if meta == nil {
// The file exists but is not linked to this repo.
// The upload gets verified by hashing and size comparison to prove access to it.
hash := sha256.New()
written, err := io.Copy(hash, ctx.Req.Body)
+3
View File
@@ -22,6 +22,9 @@ func WarnAndNotice(fmtStr string, args ...any) {
}
func hasBaseURL(toCheck, baseURL string) bool {
if baseURL == "" {
return false
}
if len(baseURL) > 0 && baseURL[len(baseURL)-1] != '/' {
baseURL += "/"
}
+7 -7
View File
@@ -87,15 +87,14 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error {
}
func checkByAllowBlockList(hostName string, addrList []net.IP) error {
var ipAllowed bool
ipAllowed := len(addrList) > 0
var ipBlocked bool
for _, addr := range addrList {
ipAllowed = ipAllowed || allowList.MatchIPAddr(addr)
ipAllowed = ipAllowed && allowList.MatchIPAddr(addr)
ipBlocked = ipBlocked || blockList.MatchIPAddr(addr)
}
var blockedError error
if blockList.MatchHostName(hostName) || ipBlocked {
blockedError = &git.ErrInvalidCloneAddr{Host: hostName, IsPermissionDenied: true}
return &git.ErrInvalidCloneAddr{Host: hostName, IsPermissionDenied: true}
}
// if we have an allow-list, check the allow-list before return to get the more accurate error
if !allowList.IsEmpty() {
@@ -104,7 +103,7 @@ func checkByAllowBlockList(hostName string, addrList []net.IP) error {
}
}
// otherwise, we always follow the blocked list
return blockedError
return nil
}
// MigrateRepository migrate repository according MigrateOptions
@@ -524,9 +523,10 @@ func Init() error {
if setting.Migrations.AllowLocalNetworks {
allowList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
allowList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
} else {
blockList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
blockList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
}
// TODO: at the moment, if ALLOW_LOCALNETWORKS=false, ALLOWED_DOMAINS=domain.com, and domain.com has IP 127.0.0.1, then it's still allowed.
// if we want to block such case, the private&loopback should be added to the blockList when ALLOW_LOCALNETWORKS=false
return nil
}
+6 -4
View File
@@ -93,17 +93,19 @@ func TestAllowBlockList(t *testing.T) {
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
// allow wildcard, block some subdomains. if the domain name is allowed, then the local network check is skipped
// allow wildcard, block some subdomains. every resolved address must still be allowed.
init("*.domain.com", "blocked.domain.com", false)
assert.NoError(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
assert.NoError(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
assert.Error(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
assert.Error(t, checkByAllowBlockList("sub.domain.com", []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("127.0.0.1")}))
assert.Error(t, checkByAllowBlockList("blocked.domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
assert.Error(t, checkByAllowBlockList("sub.other.com", []net.IP{net.ParseIP("1.2.3.4")}))
// allow wildcard (it could lead to SSRF in production)
// allow wildcard still follows the local network policy for resolved addresses.
init("*", "", false)
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4")}))
assert.NoError(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
assert.Error(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("127.0.0.1")}))
assert.Error(t, checkByAllowBlockList("domain.com", []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("127.0.0.1")}))
// local network can still be blocked
init("*", "127.0.0.*", false)
+1 -1
View File
@@ -239,7 +239,7 @@ func (r *RepositoryRestorer) GetPullRequests(_ context.Context, page, perPage in
if pr.PatchURL != "" {
pr.PatchURL = "file://" + util.FilePathJoinAbs(r.baseDir, pr.PatchURL)
}
CheckAndEnsureSafePR(pr, "", r)
CheckAndEnsureSafePR(pr, "file://"+r.baseDir, r)
}
return pulls, true, nil
}
+27
View File
@@ -45,3 +45,30 @@ func TestRepositoryRestorer_GetReleases_LocalFileInclusion(t *testing.T) {
assert.Equal(t, "file://"+filepath.Join(baseDir, "good.txt"), optional.FromPtr(assets[0].DownloadURL).Value())
assert.Equal(t, "file://"+filepath.Join(baseDir, "etc/passwd"), optional.FromPtr(assets[1].DownloadURL).Value())
}
func TestRepositoryRestorer_GetPullRequestsStripsUnsafeCloneURL(t *testing.T) {
baseDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(baseDir, "change.patch"), []byte("patch"), 0o644))
pullRequestYML := `
- number: 1
patch_url: change.patch
head:
clone_url: http://127.0.0.1/private.git
ref: feature
base:
ref: main
`
require.NoError(t, os.WriteFile(filepath.Join(baseDir, "pull_request.yml"), []byte(pullRequestYML), 0o644))
r, err := NewRepositoryRestorer(t.Context(), baseDir, "owner", "repo", false)
require.NoError(t, err)
pulls, _, err := r.GetPullRequests(t.Context(), 1, 10)
require.NoError(t, err)
require.Len(t, pulls, 1)
assert.Equal(t, "file://"+filepath.Join(baseDir, "change.patch"), pulls[0].PatchURL)
assert.Empty(t, pulls[0].Head.CloneURL)
assert.True(t, pulls[0].EnsuredSafe)
}
+7 -3
View File
@@ -71,7 +71,8 @@ func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error
}
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
cmd := gitcmd.NewCommand("remote", "prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
// Never follow HTTP redirects, see cmdFetch in runSync.
cmd := gitcmd.NewCommand("remote", "prune").AddConfig("http.followRedirects", "false").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
if pruneErr != nil {
// sanitize the output, since it may contain the remote address, which may contain a password
@@ -119,7 +120,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
// use fetch but not remote update because git fetch support --tags but remote update doesn't
cmdFetch := func() *gitcmd.Command {
cmd := gitcmd.NewCommand("fetch", "--tags")
// Never follow HTTP redirects: a mirror remote that later starts redirecting to an
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
if m.EnablePrune {
cmd.AddArguments("--prune")
}
@@ -200,7 +203,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
}
cmdRemoteUpdatePrune := func() *gitcmd.Command {
return gitcmd.NewCommand("remote", "update", "--prune").
// Never follow HTTP redirects, see cmdFetch above.
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
}
+8 -20
View File
@@ -13,8 +13,9 @@ import (
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"xorm.io/builder"
)
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
@@ -100,28 +101,15 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
})
}
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
var issueList issues_model.IssueList
for _, colIssues := range issuesMap {
issueList = append(issueList, colIssues...)
}
err := issueList.LoadAssignees(ctx)
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
sub := builder.Select("distinct issue_assignees.assignee_id").
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
Where(builder.Eq{"project_issue.project_id": projectID})
err := db.GetEngine(ctx).Table("`user`").Where(builder.In("id", sub)).Find(&users)
if err != nil {
return nil, err
}
users := make([]*user_model.User, 0, len(issueList))
usersAdded := container.Set[int64]{}
for _, issue := range issueList {
for _, assignee := range issue.Assignees {
if !usersAdded.Contains(assignee.ID) {
usersAdded.Add(assignee.ID)
users = append(users, assignee)
}
}
}
slices.SortFunc(users, func(a, b *user_model.User) int {
return strings.Compare(a.Name, b.Name)
})
slices.SortFunc(users, func(a, b *user_model.User) int { return strings.Compare(a.Name, b.Name) })
return users, nil
}
-15
View File
@@ -15,7 +15,6 @@ import (
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Projects(t *testing.T) {
@@ -198,18 +197,4 @@ func Test_Projects(t *testing.T) {
assert.Len(t, columnIssues[3], 1)
})
})
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
issuesMap := map[int64]issues_model.IssueList{}
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
issuesMap[1] = issues_model.IssueList{issue1}
issuesMap[2] = issues_model.IssueList{issue6}
assignees, err := LoadIssuesAssigneesForProject(t.Context(), issuesMap)
require.NoError(t, err)
require.Len(t, assignees, 3)
require.Equal(t, "user1", assignees[0].Name)
require.Equal(t, "user10", assignees[1].Name)
require.Equal(t, "user2", assignees[2].Name)
})
}
+21 -24
View File
@@ -156,11 +156,16 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
}
// EffectiveRequiredContexts returns the required status-check contexts for a PR head:
// EffectiveRequiredContexts returns the required status-check contexts to enforce, drawn from:
// 1. every required scoped workflow's status-check patterns effective for the repo (always)
// 2. the branch protection's own configured contexts, only when its status check is enabled
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) {
if pb == nil {
// 2. each given protected branch rule's own configured contexts, only when that rule's status check is enabled
//
// Passing no rule or a single nil rule yields nothing, not even scoped patterns.
// A single rule yields that rule's effective contexts.
// Passing several rules unions their effective contexts; this is used when the governing rule is not yet known.
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pbs ...*git_model.ProtectedBranch) ([]string, error) {
// No protection rule in effect: nothing is required.
if len(pbs) == 0 || (len(pbs) == 1 && pbs[0] == nil) {
return nil, nil
}
@@ -169,36 +174,28 @@ func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository,
return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
required := make(container.Set[string])
// Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass:
// a required scoped check that posts no matching status blocks the merge.
seen := make(container.Set[string])
var scoped []string
for _, source := range sources {
for _, cfg := range source.WorkflowConfigs {
if !cfg.Required {
continue
}
for _, p := range cfg.Patterns {
if seen.Add(p) {
scoped = append(scoped, p)
}
}
required.AddMultiple(cfg.Patterns...)
}
}
slices.Sort(scoped) // sort for stable output
// With the branch protection's own status check disabled, only the required scoped checks (mandated by the owner or instance admin) gate the merge.
if !pb.EnableStatusCheck {
return scoped, nil
}
// Status check enabled: the rule's configured contexts, then the scoped patterns not already among them.
required := slices.Clone(pb.StatusCheckContexts)
for _, p := range scoped {
if !slices.Contains(pb.StatusCheckContexts, p) {
required = append(required, p)
// Union the configured contexts of every rule whose own status check is enabled (a disabled rule contributes none)
for _, pb := range pbs {
if pb == nil || !pb.EnableStatusCheck {
continue
}
required.AddMultiple(pb.StatusCheckContexts...)
}
return required, nil
values := required.Values()
slices.Sort(values) // stable output
return values, nil
}
+10
View File
@@ -153,4 +153,14 @@ func TestEffectiveRequiredContexts(t *testing.T) {
"org/src: ci.yaml / lint (pull_request)",
}, got)
})
t.Run("several rules union their enabled contexts, deduplicated; a disabled rule contributes none", func(t *testing.T) {
noSourceRepo := &repo_model.Repository{ID: consumer.ID, OwnerID: 99999} // no scoped sources, so only the rules' contexts gate
a := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"a/check", "shared/check"}}
b := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"b/check", "shared/check"}}
off := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"off/check"}}
got, err := EffectiveRequiredContexts(t.Context(), noSourceRepo, a, b, off)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"a/check", "b/check", "shared/check"}, got)
})
}
+8
View File
@@ -326,6 +326,14 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer
return fmt.Errorf("syncCommitDivergence: %w", err)
}
// The "official" flag of existing reviews was computed against the previous
// target branch's protection rules, so re-evaluate it against the new branch.
// Otherwise a stale official approval could bypass the new branch's protection.
pr.Issue.PullRequest = pr
if err := issues_model.RecalculateReviewsOfficial(ctx, pr.Issue); err != nil {
return fmt.Errorf("RecalculateReviewsOfficial: %w", err)
}
// Create comment
options := &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeChangeTargetBranch,
+13 -7
View File
@@ -20,9 +20,11 @@ import (
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/context/upload"
notify_service "gitea.dev/services/notify"
)
@@ -319,13 +321,17 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
}
for uuid, newName := range editAttachments {
if !deletedUUIDs.Contains(uuid) {
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
UUID: uuid,
Name: newName,
}, "name"); err != nil {
return err
}
if deletedUUIDs.Contains(uuid) {
continue
}
if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
return err
}
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
UUID: uuid,
Name: newName,
}, "name"); err != nil {
return err
}
}
}
+14
View File
@@ -12,8 +12,11 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"gitea.dev/services/attachment"
"gitea.dev/services/context/upload"
_ "gitea.dev/models/actions"
@@ -270,6 +273,17 @@ func TestRelease_Update(t *testing.T) {
assert.Equal(t, release.ID, release.Attachments[0].ReleaseID)
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
err = UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, map[string]string{
attach.UUID: "test.exe",
})
assert.Error(t, err)
assert.True(t, upload.IsErrFileTypeForbidden(err))
release.Attachments = nil
assert.NoError(t, repo_model.GetReleaseAttachments(t.Context(), release))
assert.Len(t, release.Attachments, 1)
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
// delete the attachment
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
release.Attachments = nil
+44 -6
View File
@@ -42,6 +42,38 @@ type ArchiveRequest struct {
archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id"
}
type archiveQueueItem struct {
RepoID int64 `json:"RepoID"`
Type repo_model.ArchiveType `json:"Type"`
CommitID string `json:"CommitID"`
Paths []string `json:"Paths,omitempty"`
ArchiveRefShortName string `json:"ArchiveRefShortName,omitempty"`
}
func (aReq *ArchiveRequest) toQueueItem() *archiveQueueItem {
return &archiveQueueItem{
RepoID: aReq.Repo.ID,
Type: aReq.Type,
CommitID: aReq.CommitID,
Paths: aReq.Paths,
ArchiveRefShortName: aReq.archiveRefShortName,
}
}
func (item *archiveQueueItem) toArchiveRequest(ctx context.Context) (*ArchiveRequest, error) {
repo, err := repo_model.GetRepositoryByID(ctx, item.RepoID)
if err != nil {
return nil, err
}
return &ArchiveRequest{
Repo: repo,
Type: item.Type,
CommitID: item.CommitID,
Paths: item.Paths,
archiveRefShortName: item.ArchiveRefShortName,
}, nil
}
// NewRequest creates an archival request, based on the URI. The
// resulting ArchiveRequest is suitable for being passed to Await()
// if it's determined that the request still needs to be satisfied.
@@ -227,13 +259,18 @@ func doArchive(ctx context.Context, r *ArchiveRequest) (*repo_model.RepoArchiver
return archiver, nil
}
var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest]
var archiverQueue *queue.WorkerPoolQueue[*archiveQueueItem]
// Init initializes archiver
func Init(ctx context.Context) error {
handler := func(items ...*ArchiveRequest) []*ArchiveRequest {
for _, archiveReq := range items {
log.Trace("ArchiverData Process: %#v", archiveReq)
handler := func(items ...*archiveQueueItem) []*archiveQueueItem {
for _, item := range items {
log.Trace("ArchiverData Process: %#v", item)
archiveReq, err := item.toArchiveRequest(ctx)
if err != nil {
log.Error("Archive repo %d: %v", item.RepoID, err)
continue
}
if archiver, err := doArchive(ctx, archiveReq); err != nil {
log.Error("Archive %v failed: %v", archiveReq, err)
} else {
@@ -254,14 +291,15 @@ func Init(ctx context.Context) error {
// StartArchive push the archive request to the queue
func StartArchive(request *ArchiveRequest) error {
has, err := archiverQueue.Has(request)
item := request.toQueueItem()
has, err := archiverQueue.Has(item)
if err != nil {
return err
}
if has {
return nil
}
return archiverQueue.Push(request)
return archiverQueue.Push(item)
}
func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error {
@@ -7,7 +7,9 @@ import (
"testing"
"time"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"gitea.dev/services/contexttest"
@@ -21,6 +23,22 @@ func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestArchiveQueueItemJSON(t *testing.T) {
orig := &archiveQueueItem{
RepoID: 7,
Type: repo_model.ArchiveZip,
CommitID: "abc123",
Paths: []string{"agents"},
ArchiveRefShortName: "main",
}
bs, err := json.Marshal(orig)
require.NoError(t, err)
var decoded archiveQueueItem
require.NoError(t, json.Unmarshal(bs, &decoded))
assert.Equal(t, *orig, decoded)
}
func TestArchive_Basic(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
+1 -1
View File
@@ -73,7 +73,7 @@
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
<td>
<a class="tw-text-red show-modal" href data-modal="#admin-package-delete-modal"
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}"
data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.Version.ID}}"
data-modal-package-name="{{.Package.Name}}" data-modal-package-version="{{.Version.Version}}"
>{{svg "octicon-trash"}}</a>
</td>
+1 -1
View File
@@ -86,7 +86,7 @@
<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td>
<a class="tw-text-red show-modal" href data-modal="#admin-repo-delete-modal"
data-modal-form.action="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}"
data-modal-form.url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}&id={{.ID}}"
data-modal-repo-name="{{.Name}}"
>{{svg "octicon-trash"}}</a>
</td>
+1 -1
View File
@@ -35,7 +35,7 @@
<div class="item-trailing">
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
<button class="ui red button show-modal" data-modal="#remove-team-member"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
data-modal-name="{{.DisplayName}}"
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
{{end}}
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="flex-text-block">
{{if .Team.IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-form.url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{$.Team.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{else if .IsOrganizationOwner}}
+1 -1
View File
@@ -37,7 +37,7 @@
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/repositories">{{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}</a>
{{if .IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
data-modal-form.action="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-form.url="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}/action/leave?uid={{$.SignedUser.ID}}"
data-modal-to-leave-team-name="{{.Name}}"
>{{ctx.Locale.Tr "org.teams.leave"}}</button>
{{end}}
+1 -1
View File
@@ -38,7 +38,7 @@
{{end}}
</div>
</div>
<div class="item-trailing">
<div class="run-list-item-trailing">
{{if $run.IsRefDeleted}}
<span class="ui label run-list-ref gt-ellipsis tw-line-through" data-tooltip-content="{{$run.RefTooltip}}">{{$run.PrettyRef}}</span>
{{else}}
+1 -1
View File
@@ -200,7 +200,7 @@
</span>
</button>
{{else}}
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.action="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.url="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
{{svg "octicon-trash"}}
</button>
{{end}}
+2 -2
View File
@@ -2,9 +2,9 @@
<table class="ui very basic table unstackable" id="commits-table">
<thead>
<tr>
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
<th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
<th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
<th class="one wide"></th>
</tr>
@@ -34,10 +34,10 @@
<div class="divider"></div>
{{end}}
{{if $canUserBlock}}
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
{{end}}
{{if $canOrgBlock}}
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
<div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.url="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
{{end}}
{{end}}
{{end}}
+1 -1
View File
@@ -13,7 +13,7 @@
</h2>
{{template "base/alert" .}}
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
<form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="ui right">
<button class="ui primary tiny button show-modal"
data-modal="#add-secret-modal"
data-modal-form.action="{{.Link}}"
data-modal-form.url="{{.Link}}"
data-modal-header="{{ctx.Locale.Tr "secrets.add_secret"}}"
data-modal-secret-name.value=""
data-modal-secret-name.read-only="false"
@@ -39,7 +39,7 @@
</span>
<button class="btn interact-bg show-modal tw-p-2"
data-modal="#add-secret-modal"
data-modal-form.action="{{$.Link}}"
data-modal-form.url="{{$.Link}}"
data-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}"
data-tooltip-content="{{ctx.Locale.Tr "secrets.edit_secret"}}"
data-modal-secret-name.value="{{.Name}}"
@@ -132,7 +132,7 @@
{{end}}
<li>
{{if not .UserBlocking}}
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
{{else}}
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
{{end}}
@@ -3,7 +3,7 @@
<div class="ui right">
<button class="ui primary tiny button show-modal"
data-modal="#edit-variable-modal"
data-modal-form.action="{{.Link}}/new"
data-modal-form.url="{{.Link}}/new"
data-modal-header="{{ctx.Locale.Tr "actions.variables.creation"}}"
data-modal-dialog-variable-name=""
data-modal-dialog-variable-data=""
@@ -39,7 +39,7 @@
<button class="btn interact-bg tw-p-2 show-modal"
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"
data-modal="#edit-variable-modal"
data-modal-form.action="{{$.Link}}/{{.ID}}/edit"
data-modal-form.url="{{$.Link}}/{{.ID}}/edit"
data-modal-header="{{ctx.Locale.Tr "actions.variables.edit"}}"
data-modal-dialog-variable-name="{{.Name}}"
data-modal-dialog-variable-data="{{.Data}}"
+1 -1
View File
@@ -24,7 +24,7 @@
</div>
<div class="item-trailing">
<button class="ui red button show-modal" data-modal="#leave-organization"
data-modal-form.action="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
data-modal-form.url="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
</button>
</div>
+1 -1
View File
@@ -35,7 +35,7 @@ test('pdf file', async ({page, request}) => {
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
await expect.poll(async () => (await container.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(container, page.locator('.file-view'));
});
@@ -344,6 +344,59 @@ jobs:
})
})
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
// so the required check is satisfied and the PR can merge.
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
on:
pull_request:
paths:
- src/**
jobs:
scoped-filtered-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-filtered
`
source := createTestRepo(t, "sw-filtered-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
consumer := createTestRepo(t, "sw-filtered-consumer", false)
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}), http.StatusSeeOther)
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
require.NoError(t, err)
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
})
t.Run("Settings page required patterns", func(t *testing.T) {
source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
+100 -2
View File
@@ -166,6 +166,14 @@ jobs:
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
RepoID: baseRepo.ID,
RuleName: "main",
EnableStatusCheck: true,
StatusCheckContexts: []string{"*"},
}, git_model.WhitelistOptions{}))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
@@ -215,8 +223,68 @@ jobs:
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record
// the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
require.NoError(t, err)
require.NotNil(t, pb)
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "bar.txt",
ContentReader: strings.NewReader("bar"),
},
},
Message: "add bar.txt",
OldBranch: "main",
NewBranch: "fork-branch-3",
Author: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue = &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "A mismatched path with no required status check posts no skipped status",
PosterID: user4.ID,
Poster: user4,
IsPull: true,
}
pullRequest = &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-3",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// filtered by paths and no required status check remains, so no run and no skipped commit status
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
})
}
@@ -338,6 +406,10 @@ jobs:
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp)
// the push to test-skip-ci is filtered by branches, so no run is created;
// its context is not a required status check either, so no skipped commit status is posted.
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
@@ -345,7 +417,7 @@ jobs:
url := test.RedirectURL(resp)
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
// the pr title contains a configured skip-ci string, so there is still only 1 record
// the pr title contains a configured skip-ci string, so no run and no skipped status are created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
}
@@ -1879,3 +1951,29 @@ jobs:
runner.fetchNoTask(t)
})
}
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
}
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
}
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
t.Helper()
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, s := range statuses {
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
return true
}
}
return false
}
@@ -6,6 +6,7 @@ package integration
import (
"fmt"
"net/http"
"strconv"
"testing"
auth_model "gitea.dev/models/auth"
@@ -150,3 +151,47 @@ func TestAPIDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
DependencyID: dependencyIssue.ID,
})
}
func TestWebDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
defer tests.PrepareTestEnv(t)()
targetRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
targetIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: targetRepo.ID, Index: 1})
dependencyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
assert.True(t, dependencyRepo.IsPrivate)
dependencyIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: dependencyRepo.ID, Index: 1})
enableRepoDependencies(t, targetIssue.RepoID)
enableRepoDependencies(t, dependencyRepo.ID)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, issues_model.CreateIssueDependency(t.Context(), user1, targetIssue, dependencyIssue))
user40 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), targetRepo, user40, perm.AccessModeWrite))
session := loginUser(t, user40.Name)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
"dependencyType": "blockedBy",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueDependency{
IssueID: targetIssue.ID,
DependencyID: dependencyIssue.ID,
})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), dependencyRepo, user40, perm.AccessModeRead))
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
"dependencyType": "blockedBy",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &issues_model.IssueDependency{
IssueID: targetIssue.ID,
DependencyID: dependencyIssue.ID,
})
}
@@ -11,10 +11,13 @@ import (
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -100,6 +103,30 @@ func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) {
})
}
func TestAPIUserTrackedTimesOmitsInaccessiblePrivateIssues(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
assert.True(t, privateRepo.IsPrivate)
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: privateRepo.ID})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), privateRepo, user, perm.AccessModeRead))
_, err := issues_model.AddTime(t.Context(), user, issue, 60, time.Time{})
assert.NoError(t, err)
assert.NoError(t, repo_service.DeleteCollaboration(t.Context(), privateRepo, user))
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadUser)
req := NewRequest(t, "GET", "/api/v1/user/times").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
trackedTimes := DecodeJSON(t, resp, api.TrackedTimeList{})
for _, trackedTime := range trackedTimes {
if assert.NotNil(t, trackedTime.Issue) {
assert.NotEqual(t, issue.ID, trackedTime.Issue.ID)
}
}
}
func TestAPIDeleteTrackedTime(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+58
View File
@@ -5,9 +5,14 @@ package integration
import (
"net/http"
"strconv"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
@@ -95,6 +100,59 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
assertPublicActivitiesOnly(t, activities)
}
func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 is a member of the private org private_org35
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "private_org35"})
// a full org-scoped token can read the membership permissions
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
req := NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
// a public-only token must not disclose permissions for a private org
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
req = NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(publicToken)
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPITeamReposPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// team 1 (Owners of org3) has access to the private repos org3/repo3 and org3/repo5
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
privateRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
// a full org+repo scoped token sees the private repos
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
repos := DecodeJSON(t, resp, []api.Repository{})
assert.Contains(t, repoNames(repos), privateRepo.FullName())
// a public-only token must not receive any private repo
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(publicToken)
resp = MakeRequest(t, req, http.StatusOK)
repos = DecodeJSON(t, resp, []api.Repository{})
for _, repo := range repos {
assert.False(t, repo.Private)
}
assert.NotContains(t, repoNames(repos), privateRepo.FullName())
assert.NotContains(t, repoNames(repos), privateRepo2.FullName())
// the total-count header must match the filtered page, otherwise it leaks the
// number of hidden private repos
assert.Equal(t, strconv.Itoa(len(repos)), resp.Header().Get("X-Total-Count"))
// the single-repo endpoint must not confirm a private repo for a public-only token
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(publicToken)
MakeRequest(t, req, http.StatusNotFound)
}
func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
t.Helper()
@@ -241,3 +241,27 @@ func TestGetFileHistoryNotOnMaster(t *testing.T) {
assert.Equal(t, "1", resp.Header().Get("X-Total"))
}
func TestGetFileHistoryEmptyDateRange(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Login as User2.
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
// readme.md exists in repo16 but no commits fall before 1970, so the date
// filter yields an empty range: this must return 200 with an empty list,
// not 404 (regression: a valid path with an empty date range was a 404).
req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=readme.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiData := DecodeJSON(t, resp, []api.Commit{})
assert.Empty(t, apiData)
assert.Equal(t, "0", resp.Header().Get("X-Total"))
// a path that does not exist must still return 404 even with a date filter
req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=does-not-exist.md&sha=good-sign&until=1970-01-01T00:00:00Z", user.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
+12 -4
View File
@@ -240,9 +240,14 @@ func TestAPILFSBatch(t *testing.T) {
assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message)
})
t.Run("AddMeta", func(t *testing.T) {
t.Run("CrossRepoObjectRequiresUpload", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// An object whose bytes already exist in the store but which is not
// linked to this repo must not be silently linked, even when the
// caller can access it in another repo. Auto-linking let a deploy key
// (whose token carries the repo owner's identity) exfiltrate objects
// across repos without proving possession. The client must upload.
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
contentStore := lfs.NewContentStore()
@@ -250,6 +255,7 @@ func TestAPILFSBatch(t *testing.T) {
assert.NoError(t, err)
assert.True(t, exist)
// The object is linked to another repo owned by the same user.
repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
storeObjectInRepo(t, repo2.ID, "dummy0")
@@ -266,11 +272,13 @@ func TestAPILFSBatch(t *testing.T) {
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
assert.Empty(t, br.Objects[0].Actions)
// The client is told to upload instead of the object being linked.
assert.Contains(t, br.Objects[0].Actions, "upload")
// No meta object may have been created for this repo.
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.NoError(t, err)
assert.NotNil(t, meta)
assert.Nil(t, meta)
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
// Cleanup
err = contentStore.Delete(p.RelativePath())
+25
View File
@@ -20,6 +20,7 @@ import (
"gitea.dev/modules/structs"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -306,6 +307,30 @@ func TestAPIGetTeamRepo(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPIAddRemoveTeamRepositoryRequiresOrgOwnerOrSetting(t *testing.T) {
defer tests.PrepareTestEnv(t)()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 12})
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: team.OrgID})
assert.False(t, org.RepoAdminChangeTeamAccess)
targetRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 32, OwnerID: org.ID})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), targetRepo, user, perm.AccessModeAdmin))
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteOrganization)
url := fmt.Sprintf("/api/v1/teams/%d/repos/%s", team.ID, targetRepo.FullName())
req := NewRequest(t, "PUT", url).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: team.ID, RepoID: targetRepo.ID})
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: targetRepo.ID}))
req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: team.ID, RepoID: targetRepo.ID})
}
func TestAPITeamVisibilityAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
+69
View File
@@ -9,11 +9,14 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -93,6 +96,52 @@ func TestAPIStar(t *testing.T) {
})
}
// TestAPIStarredReposAccessRevoked ensures a private repository disappears from a user's
// starred list once their access to it has been revoked, so no metadata leaks afterwards.
func TestAPIStarredReposAccessRevoked(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is a private repository owned by user2
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
// grant the collaborator read access and star the private repo as them
require.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), repo, collaborator, perm.AccessModeRead))
require.NoError(t, repo_model.StarRepo(t.Context(), collaborator, repo, true))
token := getUserToken(t, collaborator.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
// while access is granted, the private repo is visible in the starred list
req := NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
repos := DecodeJSON(t, resp, []api.Repository{})
require.Len(t, repos, 1)
assert.Equal(t, repo.FullName(), repos[0].FullName)
// revoke access
require.NoError(t, repo_service.DeleteCollaboration(t.Context(), repo, collaborator))
// the star record still exists, but the repo (and its metadata) must no longer be returned
assert.True(t, repo_model.IsStaring(t.Context(), collaborator.ID, repo.ID))
req = NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
repos = DecodeJSON(t, resp, []api.Repository{})
assert.Empty(t, repos)
// sanity: the owner still sees their own private repo
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
require.NoError(t, repo_model.StarRepo(t.Context(), owner, repo, true))
req = NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(ownerToken)
resp = MakeRequest(t, req, http.StatusOK)
repos = DecodeJSON(t, resp, []api.Repository{})
fullNames := make([]string, len(repos))
for i, r := range repos {
fullNames[i] = r.FullName
}
assert.Contains(t, fullNames, repo.FullName())
}
func TestAPIStarDisabled(t *testing.T) {
defer tests.PrepareTestEnv(t)()
@@ -175,3 +224,23 @@ func TestAPIStarPublicOnly(t *testing.T) {
require.Len(t, repos, 1)
assert.Equal(t, "user5/repo4", repos[0].FullName)
}
func TestAPIStarredReposOmitsInaccessiblePrivateRepos(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
assert.True(t, privateRepo.IsPrivate)
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), privateRepo, user, perm.AccessModeRead))
assert.NoError(t, repo_model.StarRepo(t.Context(), user, privateRepo, true))
assert.NoError(t, repo_service.DeleteCollaboration(t.Context(), privateRepo, user))
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
req := NewRequest(t, "GET", "/api/v1/user/starred").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
repos := DecodeJSON(t, resp, []api.Repository{})
for _, repo := range repos {
assert.NotEqual(t, privateRepo.FullName(), repo.FullName)
}
}
+5
View File
@@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) {
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
{"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound},
// draft release attachments must only be reachable by users with write access, even on a public repo
{"DraftReleaseByOwner", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user2Session, http.StatusOK},
{"DraftReleaseByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, adminSession, http.StatusOK},
{"DraftReleaseByNonCollaborator", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user8Session, http.StatusNotFound},
{"DraftReleaseByAnonymous", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, emptySession, http.StatusNotFound},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
+71
View File
@@ -22,6 +22,7 @@ import (
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/tests"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/builder"
@@ -489,3 +490,73 @@ func TestOAuth2GroupClaimsManualLinking(t *testing.T) {
})
}
}
// TestOAuth2AutoLinkWithTwoFactor verifies that automatic account linking completes
// after the user passes local 2FA when an OIDC identity matches an existing account.
func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
const (
sourceName = "test-oauth-auto-link-2fa"
sub = "oidc-auto-link-2fa-sub"
email = "oidc-auto-link-2fa@example.com"
userName = "oidc-auto-link-2fa"
)
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: email, Name: "OIDC Auto Link 2FA"})
addOAuth2Source(t, sourceName, oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
require.NoError(t, err)
localUser := &user_model.User{Name: userName, Email: email}
require.NoError(t, user_model.CreateUser(t.Context(), localUser, &user_model.Meta{}))
otpKey, err := totp.Generate(totp.GenerateOpts{
SecretSize: 40,
Issuer: "gitea-test",
AccountName: localUser.Name,
})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: localUser.ID}
require.NoError(t, tfa.SetSecret(otpKey.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
session := emptyTestSession(t)
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
location := resp.Header().Get("Location")
u, err := url.Parse(location)
require.NoError(t, err)
state := u.Query().Get("state")
require.NotEmpty(t, state)
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
resp = session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
assert.Contains(t, resp.Header().Get("Location"), "/user/two_factor")
session.MakeRequest(t, NewRequest(t, "GET", "/user/two_factor"), http.StatusOK)
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
require.NoError(t, err)
req := NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{
"passcode": passcode,
})
session.MakeRequest(t, req, http.StatusSeeOther)
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
assert.Equal(t, localUser.ID, externalLink.UserID)
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestUndoDeleteBranch(t *testing.T) {
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.action")
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.url")
assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
+38
View File
@@ -11,6 +11,7 @@ import (
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -140,6 +141,43 @@ func testGitPush(t *testing.T, u *url.URL) {
})
}
func TestGitPushVisibilityOption(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
Name: "repo-visibility-option",
AutoInit: false,
DefaultBranch: "master",
IsPrivate: false,
})
require.NoError(t, err)
require.NotEmpty(t, repo)
gitPath := t.TempDir()
doGitInitTestRepository(gitPath)(t)
oldPath, oldUser := u.Path, u.User
defer func() { u.Path, u.User = oldPath, oldUser }()
u.Path = repo.FullName() + ".git"
u.User = url.UserPassword(user.LowerName, userPassword)
doGitAddRemote(gitPath, "origin", u)(t)
// The first push into an empty repository is a "push-to-create", so the
// repo.private push option is honored to set the initial visibility.
doGitPushTestRepository(gitPath, "origin", "master", "-o", "repo.private=true")(t)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.True(t, repo.IsPrivate, "repo.private option should apply on push-to-create")
// The repository is now populated; a later push must NOT silently flip
// visibility, otherwise a repo admin could change it bypassing the audit
// trail, webhooks, and notifications a proper settings change would fire.
doGitCreateBranch(gitPath, "branch2")(t)
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
})
}
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{

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