Compare commits

...
60 Commits
Author SHA1 Message Date
Giteabotandsilverwind 4f5a3e13e5 fix(repo): don't get stuck generating contributor stats (#40125) (#40156)
Backport #40125 by @silverwind

A failed contributor stats generation never released its lock, so after
the cached error expired, the contributors and code frequency pages kept
saying stats are being generated until a restart. A generation
outlasting the 5 second wait also leaked its goroutine on an unread
channel.

The lock is now claimed atomically and released together with the
completion signal when generation ends, and a cache miss after the wait
counts as still generating.

Co-authored-by: silverwind <me@silverwind.io>
2026-11-01 01:20:24 -07:00
c50d5fc20d fix: list bot users in gitea admin user list and admin users API (#40060) (#40147)
Backport #40060 by @soujasK

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

The admin web UI lists every account type except organizations, but
`gitea admin user list` and `GET /api/v1/admin/users` only returned
individual users. That hid bots created for CI, even though the CLI and
the admin API can both create and manage them.

- The admin web UI, the admin API and the CLI share one type list:
individual, reserved, bot and remote accounts.
- `gitea admin user list` (and `--admin`) gains a `Type` column showing
`User`, `Bot`, `Reserved` or `Remote`.

Signed-off-by: Soujas K <kudchadkarsoujas@gmail.com>
Co-authored-by: soujasK <121026960+soujasK@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-01 07:27:30 +00:00
dfdb0438ad fix(auth): only auto-link the account the external identity points to (#40121) (#40137)
Backport https://github.com/go-gitea/gitea/pull/40121

With `[oauth2_client].ACCOUNT_LINKING = auto`, the link account sign-up
form linked the external identity to an existing account matching the
submitted user name or email and signed in as it. Automatic linking now
only happens when the existing user matches the external identity,
otherwise the linking login page asks for the account's password.

`release/v28` lacks `TestOAuth2AvatarFromPicture`, so only the new test
is added.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-31 22:33:10 +00:00
7868ade8f7 perf(pull): load the repository once for all commit statuses (#39930) (#40135)
Backport #39930 by @lunny

Pages showing commit statuses loaded the repository of every status
separately, so a PR with ~75 statuses ran ~75 identical repository
queries per merge box render (about 3.4s in a production trace).
`statusRepoCanReadActions` only loaded the repository inside the
permission cache loader, which never runs once `RepoAssignment` has
published the permission.

- `CommitStatus.loadRepository` resolves through the request context
cache
- `statusRepoCanReadActions` loads the repository before the permission
lookup
- `RepoAssignment` publishes its repository to the context cache next to
the permission
- `GetCommitActionsStatusMap` drops its own repository map

Queries per request with 50 statuses (45 Actions, 5 external) on the
head commit, SQLite:

| Page | Repository queries | Total queries |
|---|---|---|
| PR view | 61 → 11 | 157 → 107 |
| Commit | 6 → 5 | 24 → 23 |
| Branches | 6 → 5 | 33 → 32 |
| Commits | 6 → 5 | 23 → 22 |
| Repo home | 8 → 7 | 35 → 34 |
| `/pulls` | 15 → 15 | 37 → 37 |

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 19:12:36 +00:00
67904da6a5 perf: speed up repository issue and pull request lists (#40114) (#40132)
Backport #40114 by @silverwind

The repository issue and pull request lists filter `access` by
`repo_id`, which no index covers, so every load scans the whole table.
Issue counts on larger repositories merge three single-column indices,
and the API pull request list loads the latest reviews with `id IN
(SELECT max(id) ... GROUP BY ...)`, which MySQL runs as a scan of every
review, about 1.1s on
https://gitea.com/api/v1/repos/gitea/gitea-mcp/pulls?limit=1.

- Index `access.repo_id`
- Index `team_repo.repo_id`
- Replace the single `repo_id`, `is_closed` and `is_pull` indices with
one `issue` index on `(repo_id, is_closed, is_pull)`
- Join the latest review ids instead of `id IN (subquery)`

Median times in ms on MySQL 8.4 with tables sized like gitea.com (1M
access rows, 500k issues, 200k reviews), for a copy of
https://gitea.com/gitea/gitea-mcp and a repository with 9.7k issues:

| Page | Before | After |
|---|---|---|
| `/{owner}/{repo}/issues` | 107 | 40 |
| `/{owner}/{repo}/pulls` | 106 | 32 |
| `/{owner}/{repo}/pulls?state=closed` | 153 | 35 |
| `/{owner}/{repo}/issues/{index}` | 141 | 60 |
| `/{owner}/{repo}/issues`, 9.7k issues | 303 | 53 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=50` | 180 | 46 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=1` | 137 | 16 |
| `/api/v1/repos/{owner}/{repo}/issues`, 9.7k issues | 248 | 43 |

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-31 18:24:15 +01:00
Giteabotandsilverwind 6f5d66f6dd fix(pull): keep review body in sync when its comment is edited (#40058) (#40133)
Backport #40058 by @silverwind

A review's text is stored on both the review and its timeline comment,
but editing it in the web UI or through the issue comment API only
updated the comment. The reviews API kept returning the old body
afterwards.

Extracted out of https://github.com/go-gitea/gitea/pull/39865.

*Written by Claude.*

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 18:21:03 +01:00
a1c56dd96a fix(projects): hide linked pull requests the viewer cannot read (#40016) (#40134)
Backport #40016 by @basavaraj-sm05

Project board cards list the pull requests that reference their issue,
but loaded them without checking the viewer's access. A private pull
request mentioning a public issue showed its title, number and state on
that issue's board to anyone who can view the board, anonymous visitors
included.

Repository and organization boards now skip linked pull requests in
repositories where the viewer cannot read pull requests, matching how
the issue page already hides such references.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-31 18:14:26 +01:00
5a3f06430d fix: harden context user access scope (#39945) (#40115)
Backport https://github.com/go-gitea/gitea/pull/39945

API tokens and Actions task tokens could see data beyond their scope
through user-scoped API routes and web profile pages.

- Actions task tokens authenticate as a shared system user with a
negative ID, which org, project and SSH/GPG key filters treated as "no
owner filter". These filters now check `!= 0`, so system users own
nothing. The `/api/v1/user` routes except `GET /api/v1/user` only accept
individual and bot accounts, and repo search no longer skips access
checks for the owner.
- `/{username}` only accepts tokens for RSS/Atom feeds. HTML profile and
org pages render as anonymous for token requests.
- User and org feeds require `read:user` or `read:organization` from
tokens, the same way repo feeds require `read:repository`.

`release/v28` lacks https://github.com/go-gitea/gitea/pull/39967, so
`/{username}` enables basic auth only for feed requests through a
middleware. The profile page token tests from
https://github.com/go-gitea/gitea/pull/39853 are folded into
`TestFeedUser`, because profile pages no longer accept tokens.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-31 17:40:49 +01:00
bircniandsilverwind 6c8eaaf9e7 fix(actions): don't pick up jobs of repos with Actions disabled (#40106) (#40122)
Backport https://github.com/go-gitea/gitea/pull/40106

Repository-level and instance-level runners still picked up waiting jobs
of repositories with Actions disabled.
https://github.com/go-gitea/gitea/pull/30331 only covered owner-level
runners, by filtering them out in SQL.

Pickup now checks the Actions unit of each job it is about to claim, for
every runner scope, and cancels the job instead when Actions is
disabled, also honoring `DISABLED_REPO_UNITS`. Disabling Actions
therefore cancels its queued jobs once a matching runner polls, instead
of leaving them waiting until `ABANDONED_JOB_TIMEOUT`.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 15:56:26 +00:00
GiteabotandLazizbek Ergashev c9e1805cc9 fix(pull): respect max approvers in default merge message (#40118) (#40119)
Backport #40118 by @lazerg

The approver limit was checked before writing each `Reviewed-by:` line
but compared with `>`, so the default merge message got one more trailer
than `DEFAULT_MERGE_MESSAGE_MAX_APPROVERS` allows (2 with a limit of 1,
11 with the default 10). It now stops once the limit is reached.

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

Assisted-by: Claude Code:claude-opus-5-5

Co-authored-by: Lazizbek Ergashev <lazerg2@gmail.com>
2026-10-31 15:07:42 +00:00
Giteabotandsilverwind 5ba69f9af4 fix(actions): run review-triggered workflows on the pull request head (#40099) (#40117)
Backport #40099 by @silverwind

Runs triggered by pull request reviews took their ref and commit from
the review's `commit_id` instead of the pull request. They now use the
pull request head like other pull request events, and their commit
status is set on the commit that ran.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 13:13:39 +00:00
e395b4d9dc fix(mail): preserve outer spacing in notification emails (#40104) (#40111)
Backport #40104 by @0xJacky

Remove the `body {margin: 0; padding: 0}` reset from
https://github.com/go-gitea/gitea/pull/38396, which left HTML mails
flush against the edge in Apple Mail and Thunderbird. The devtest
preview stops injecting its own padding, so it matches delivered mails.

These clients render the mail as a document and apply the default 8px
body margin again, as with GitHub and GitLab notifications. Webmail
clients replace `<body>` and keep their own inset.

Fixes: https://github.com/go-gitea/gitea/issues/40103
Assisted-by: Codex:GPT-6.1, Claude:Opus 5.5
Co-authored-by: Jacky <jacky-943572677@qq.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-31 09:29:48 +00:00
308dd93484 perf: speed up the issue and pull request dashboards and fix table indices (#40001) (#40096)
Backport #40001 by @silverwind

The dashboard counts ran queries whose cost grew with the whole instance
instead of the viewing user. On https://gitea.com/pulls the two review
counts alone take about 1.4s per page load.

- Check the owner of the already joined repository with `EXISTS` instead
of listing every public repository and public user
- Hide superseded review requests with `NOT EXISTS` instead of grouping
every review on the instance
- Skip the review counts on `/issues`, which never shows them
- Index `team_user.uid` for permission and team lookups by user
- Fix indices for table `watch`, `package_property` and `action_run`,
fix #40090

Median page times in ms, before → after, on a synthetic dataset sized
like gitea.com (175k users, 40k repos, 120k issues, 1M comments):

| Page | SQLite | PostgreSQL 17 | MySQL 8.4 | MariaDB 13 |
|---|---|---|---|---|
| `/pulls` | 901 → 56 | 330 → 156 | 189 → 124 | 192 → 218 |
| `/pulls?type=review_requested` | 1797 → 66 | 557 → 254 | 307 → 245 |
604 → 499 |
| `/pulls?type=reviewed_by` | 1646 → 118 | 537 → 316 | 515 → 265 | 528 →
682 |
| `/pulls?type=mentioned` | 1403 → 46 | 426 → 119 | 165 → 116 | 179 →
179 |
| `/issues` | 923 → 33 | 293 → 39 | 178 → 65 | 140 → 41 |
| `/api/v1/repos/issues/search` (anonymous) | 870 → 110 | 144 → 104 |
123 → 87 | 170 → 193 |

MariaDB checks the owner per row on long scans across all public
repositories, which makes a few pages slower there.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-31 07:38:45 +00:00
3162dc4cbf fix(api): answer 409 when repository files already exist on disk (#40095) (#40098)
Backport #40095 by @hktitof

when a repository directory exists on disk without a database row, for
example after a failed delete, POST /user/repos answered 500 with an
empty message

the migrate endpoint already answers 409 for this error and the fork API
got the same treatment in https://github.com/go-gitea/gitea/pull/39909,
so the create handler now maps ErrRepoFilesAlreadyExist to 409 too. this
covers POST /user/repos, POST /orgs/{org}/repos and the admin user route
since they all go through CreateUserRepo

the integration test creates the leftover directory and asserts 409, on
master it gets 500. runs on sqlite as usual

this also touches routers/api/v1/repo/repo.go, which several open PRs
modify, but none of them changes this error mapping

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Signed-off-by: hktitof <hktitof@users.noreply.github.com>
Co-authored-by: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com>
Co-authored-by: hktitof <hktitof@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 22:26:30 +01:00
51736a9706 fix(pull): refuse review comments on locked pull requests (#39960) (#40084)
Backport https://github.com/go-gitea/gitea/pull/39960

Locking a pull request stops users without write access from commenting,
but the review paths never checked the lock, so any signed-in user could
still post reviews, code comments and replies on a locked pull request.

The cherry-pick conflicted on the `repo-diff.ts` imports, because
`release/v28` has no `DiffFileTreeLocale`.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 19:36:51 +00:00
silverwind 40079c7c09 fix(ui): ignore code line anchors below 1, show JS errors in vite dev mode (#39432) (#40087)
Backport https://github.com/go-gitea/gitea/pull/39432

- A `#L0` URL hash made `selectRange` index the line list at -1 and
throw inside `initRepoCodeView`, which aborted every init function after
it on the page. Line numbers below 1 now select nothing.
- The JS error filter only accepted stacks under the asset prefix, but
the vite dev server serves the sources from `/web_src/`, so dev mode
dropped every error without showing a banner. Outside production, the
filter now also accepts stacks from `/web_src/`.
2026-10-30 19:07:17 +00:00
Giteabot 2b11ba861f fix(repo): honor ALLOW_FORK_INTO_SAME_OWNER for organizations (#40025) (#40094)
Backport #40025 by @somaz94

With `[repository] ALLOW_FORK_INTO_SAME_OWNER = true` a user can fork
their own repository, but an organization still cannot fork a repository
it owns. The fork button check and the owner list on the fork page
compared the organization with the repository owner directly instead of
asking `CanUserForkBetweenOwners`, and the walk over parent repositories
dropped the owning organization whenever the setting was on.
Organizations now follow the setting the same way users already do, and
nothing changes when it is off.

Fixes https://github.com/go-gitea/gitea/issues/40021
Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: somaz <112675579+somaz94@users.noreply.github.com>
2026-10-30 18:06:59 +00:00
9ad9f99060 fix(user): answer the follow of a bot user with the profile card (#39980) (#40086)
Backport https://github.com/go-gitea/gitea/pull/39980

Following a bot user from its profile succeeded but answered with an
error, because the follow handler only rendered a response for
individuals and organizations.

The cherry-pick conflicted in `OwnerProfile`, which still serves the
user feeds on `release/v28`.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 07:30:13 +00:00
0964106988 fix: add missing access checks and size limits (#39942) (#40085)
Backport https://github.com/go-gitea/gitea/pull/39942

Several handlers and parsers skipped checks that their siblings already
perform.

- Reject issue, comment, label, milestone and release mutations in
archived repositories via the API with one guard per route group,
matching the web UI
- Hide timeline references and dependencies pointing at issues the doer
cannot read, in the web UI and the API, including pull requests of the
same repository without pull request access
- Reject workflow runs with more than `MaxJobNumPerRun` jobs, including
inline reusable workflow jobs, and show rejected pushes as invalid
workflow runs
- Bound how much of a Helm chart archive is inflated while looking for
`Chart.yaml`

`release/v28` lacks two helpers this needs, so they are included:
`insertInvalidWorkflowRun`, split out of `handleInvalidWorkflows` by
https://github.com/go-gitea/gitea/pull/39480, and
`packages.NewLimitedReader` from
https://github.com/go-gitea/gitea/pull/39366.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-30 08:08:32 +01:00
silverwindandBasavaraj S m 8cf0f9798f fix(feed): require code read access for the repo home branch feed (#39964) (#40067)
Backport https://github.com/go-gitea/gitea/pull/39964

The repo home URL served the branch or file commit feed for RSS or Atom
`Accept` headers without the code unit check of the explicit
`/rss/branch/*` routes, exposing commit messages and authors to users
without code access.

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
2026-10-29 10:04:51 +00:00
5d737d660b perf(actions): is_reusable_caller index makes SQLite read nearly every job row (#40055) (#40064)
Backport #40055 by @myers

On SQLite, the `is_reusable_caller = false` filters (e.g. the admin job
queue page) are planned from `IDX_action_run_job_is_reusable_caller`,
which matches almost every row, so nearly the whole `action_run_job`
table is read. `ANALYZE` does not help: `sqlite_stat1` only keeps
average rows per key.

No query needs that index. `is_reusable_caller = true` is only used by
`expandedCallerCond`, always together with `repo_id` and
`concurrency_group`. Their plans use other indexes on SQLite, PostgreSQL
17 and MySQL 8.4 (PostgreSQL is slightly slower with it).

This removes the index tag. `SyncAllTables` drops the existing index on
the next start, so no migration is needed.

Job queue count on SQLite, before: a search on the `is_reusable_caller`
index; after: a `MULTI-INDEX OR` on the `status` and `pickup` indexes.
On an instance with about 7,000 jobs the admin job queue page went from
`Page: 784ms` to `39ms` (measured with an earlier revision of this PR
that gave the same plan).

Signed-off-by: Myers Carpenter <myers@maski.org>
Co-authored-by: Myers Carpenter <myers@maski.org>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-29 07:40:30 +01:00
silverwind 5be73fa93a fix: size mermaid iframe by the diagram's aspect ratio (#40046) (#40052)
Backport https://github.com/go-gitea/gitea/pull/40046

Mermaid scales diagrams wider than the markup column down to its width,
but the iframe kept the unscaled viewBox height, leaving a large blank
area below them. The iframe now uses a CSS `aspect-ratio` capped at the
viewBox height. Diagrams that don't scale (`useMaxWidth: false`) keep
the fixed height.

The cherry-pick conflicted only on the `srcdoc` line, which uses an
`html` template on `main` but a plain string on `release/v28`.

Fixes: https://github.com/go-gitea/gitea/issues/40045
2026-10-28 19:10:25 +00:00
silverwindandGiteabot 3ed5886066 fix(deps): update module golang.org/x/net to v0.62.0 [security] (#40050) (#40051)
Backport https://github.com/go-gitea/gitea/pull/40050

`release/v28` is still on x/net v0.60.0, so the cherry-pick conflicted.
x/net v0.62.0 raises the minimum x/crypto, x/sync, x/sys, x/text and
x/tools versions, which `go get` and `make tidy` bump to what `main`
already uses.

Co-authored-by: Giteabot <teabot@gitea.io>
2026-10-28 09:57:35 +01:00
12b5af068f fix(repo): use the viewed ref's editorconfig in file view, blame and editor (#39973) (#40019)
Backport #39973 by @Hello-Liar

The file view, blame and web editor read `.editorconfig` from the
default branch, so branch-specific settings like tab width were ignored.
The file view's middleware ran before the ref was resolved, and blame
never loaded it.

- `GetEditorconfig` reads the current commit, falling back to the
default branch only where no ref is resolved
- `Home` and `RefBlame` load the editorconfig after ref resolution

Fixes: https://github.com/go-gitea/gitea/issues/27413
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Hello-Liar <80651389+Hello-Liar@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 22:39:01 +00:00
Giteabotandleo 8e83bb0e61 fix(markup): keep commit title emoji inside links (#39986) (#40032)
Backport #39986 by @Hello-Liar

Render emoji inside the default commit-title link so the whole title
remains clickable, including emoji-only titles. Explicit links and
mentions keep their own destinations.

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

Verified failing-before/passing-after native, shortcode, custom, and
emoji-only cases; markup/template tests and Go lint pass.
Browser-checked actual renderer output; blue outlines show link
boundaries.


![Before](https://github.com/user-attachments/assets/f56a4607-3265-4536-9fa2-ecc297dada6f)

![After](https://github.com/user-attachments/assets/7e0ad96d-6732-4d41-b7c6-cc28e90b747a)

AI-assisted by Codex (GPT-6); prepared and tested by the assistant.

Co-authored-by: leo <80651389+Hello-Liar@users.noreply.github.com>
2026-10-26 20:07:13 +00:00
Giteabotandpetervavrinecz 2042caf945 fix(issue): prevent redirect to external tracker when built-in tracker is enabled (#39997) (#40031)
Backport #39997 by petervavrinecz

Signed-off-by: Vavrinecz Péter <98645804+petervavrinecz@users.noreply.github.com>
Co-authored-by: petervavrinecz <98645804+petervavrinecz@users.noreply.github.com>
2026-10-26 19:38:30 +00:00
wxiaoguang 79189a959f fix: replace null char with space for issue/comment content (#40014) (#40034)
A simple workaround to fix
https://github.com/go-gitea/gitea/issues/40003

Backport of https://github.com/go-gitea/gitea/pull/40014
2026-10-27 03:14:59 +08:00
Giteabotandsilverwind 1b8a7c4d85 fix(git): default to the gc maintenance strategy (#40022) (#40029)
Backport #40022 by @silverwind

Git 2.54 made "geometric" the default auto-maintenance strategy. Pushes
wait for its foreground reflog expiry
(https://github.com/go-gitea/gitea/issues/39693), and its detached
repacks race with filesystem operations, as in
https://github.com/go-gitea/gitea/actions/runs/40236681252/job/121299686933.
Pin the pre-2.54 "gc" strategy, older git ignores it.

https://github.com/go-gitea/gitea/pull/39745 is the more complete fix
which disables the auto-maintenance.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 19:43:11 +01:00
dbe80ac05c fix: Fix OneDev migration with OneDev 16.8 and newer (#40017) (#40026)
Backport #40017 by @lunny

OneDev 16.8 moved the server version endpoint from
`/~api/version/server` to `/~api/server/version`. Both are tried, the
new one first, and both errors are reported when neither works.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 13:54:28 +00:00
silverwind 4b669ca620 fix(migrations): update gitea sdk to fix nil pointer panic (#39999)
`gitea.dev/sdk` v1.2.0 loses the error of a failed server version lookup
after the first call, so Gitea-to-Gitea migrations panic on the next
version check. v1.3.0 (https://gitea.com/gitea/go-sdk/pulls/855) keeps
returning the error. `main` already has v1.3.0 via
https://github.com/go-gitea/gitea/pull/39742.

Fixes: https://github.com/go-gitea/gitea/issues/39989
2026-10-24 23:57:14 +02:00
9d335e8783 fix(diff): load both blobs of renamed files for diff rendering (#39969) (#39992)
Backport #39969

Co-authored-by: schop-rob <49682405+schop-rob@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-24 14:05:09 +00:00
85a85878c7 fix: get valid UTF-8 for diff_hunk and composer readme (#39932) (#40000)
Backport #39932 by @fixfon

* Fixes #39929
* Fixes #39994

Co-authored-by: fixfon <12512757+fixfon@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-24 13:38:33 +00:00
ef2ceff6cd fix(actions): drop untrusted Content-Encoding from v1 artifact uploads (#39855) (#39996)
Backport #39855 by @basavaraj-sm05

Legacy (v1 to v3) artifact uploads stored the request's
`Content-Encoding` header in the column that v4 artifacts read as their
`Content-Type`.

- Store only the `gzip` marker on legacy uploads, the only encoding
their clients send
- Pick the PDF and audio/video serve headers only for content types that
`mime.ParseMediaType` accepts

Co-authored-by: Basavaraj S m <basavaraj@digiscrypt.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-24 15:06:46 +02:00
35a6f3d4e0 enhance(project): filter project milestones by linked issues (#39958) (#39984)
Backport #39958 by @SHIVANSHGARG07

The project milestone filter listed every milestone of the repo, or of
all accessible org repos for org projects, including ones with no issue
in the project. That made the dropdown noisy and unhelpful.

Both repo and org projects now list only milestones that have at least
one issue linked to the project, via a new `ProjectID` option on
`FindMilestoneOptions`.

Fixes: https://github.com/go-gitea/gitea/issues/39150
Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-24 06:24:46 +00:00
Giteabotandsilverwind cf3d325e45 ci(release): stop creating deployments for release tags (#39966) (#39971)
Backport #39966 by @silverwind

Each `release-create-tag` run created a deployment record, which makes
GitHub show a useless "This branch has not been deployed" box on every
PR:

<img width="819" height="113" alt="image"
src="https://github.com/user-attachments/assets/129c8348-1d70-46b9-8996-8872c84a11f2"
/>

With `deployment: false`, the `release-signing` approval and branch
policy still apply.

Co-authored-by: silverwind <me@silverwind.io>
2026-10-23 12:12:33 -07:00
Lunny Xiaoandsilverwind b140311d54 perf(actions): cache successful runner token verification (#39934) (#39959)
Backport of https://github.com/go-gitea/gitea/pull/39934 to release/v28.

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

_Backport prepared with Codet._

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

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

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

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

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

Fixes #39651

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This base-branch update was prepared with AI assistance.

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #39908

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

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

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

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

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

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

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

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

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

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

fix  #39892

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

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

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

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

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

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

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

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

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

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

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

fix #39884

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

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

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

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

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 - uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea gitea_fork: giteabot/gitea
+3 -3
View File
@@ -37,7 +37,7 @@ on:
checks: checks:
description: Comma-separated list of non-backport checks to run description: Comma-separated list of non-backport checks to run
required: false required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions default: backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions: {} permissions: {}
@@ -56,7 +56,7 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
- uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1 - uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} checks: ${{ github.event.inputs.checks || 'backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+3 -1
View File
@@ -13,7 +13,9 @@ permissions: {}
jobs: jobs:
tag: tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: release-signing environment:
name: release-signing
deployment: false
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
+5 -5
View File
@@ -33,7 +33,7 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
return err return err
} }
users, err := user_model.GetAllUsers(ctx) users, err := user_model.GetAllUsersForAdminListing(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -41,17 +41,17 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
if c.IsSet("admin") { if c.IsSet("admin") {
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n") fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tType\n")
for _, u := range users { for _, u := range users {
if u.IsAdmin { if u.IsAdmin {
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive) fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.Type.DisplayName())
} }
} }
} else { } else {
twofa := user_model.UserList(users).GetTwoFaStatus(ctx) twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n") fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\tType\n")
for _, u := range users { for _, u := range users {
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID]) fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID], u.Type.DisplayName())
} }
} }
+2 -1
View File
@@ -811,6 +811,7 @@ LEVEL = Info
;[git.config] ;[git.config]
;diff.algorithm = histogram ;diff.algorithm = histogram
;core.logAllRefUpdates = true ;core.logAllRefUpdates = true
;maintenance.strategy = gc
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -3083,7 +3084,7 @@ LEVEL = Info
;LOG_RETENTION_DAYS = 365 ;LOG_RETENTION_DAYS = 365
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever. ;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded. ;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step. ;; Workflows can request a shorter retention period, down to 1 day.
;ARTIFACT_RETENTION_DAYS = 90 ;ARTIFACT_RETENTION_DAYS = 90
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit. ;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760 ;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
+8 -8
View File
@@ -2,7 +2,7 @@ module gitea.dev
go 1.27 go 1.27
toolchain go1.27.2 toolchain go1.27.3
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ require (
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
gitea.dev/actionslib v1.2.1 gitea.dev/actionslib v1.2.1
gitea.dev/sdk v1.2.0 gitea.dev/sdk v1.3.0
github.com/42wim/httpsig v1.2.4 github.com/42wim/httpsig v1.2.4
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
github.com/Azure/go-ntlmssp v0.1.1 github.com/Azure/go-ntlmssp v0.1.1
@@ -95,14 +95,14 @@ require (
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0 gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.58.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0 golang.org/x/mod v0.41.0
golang.org/x/net v0.60.0 golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.37.0 golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.24.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.49.0
golang.org/x/text v0.42.0 golang.org/x/text v0.43.0
google.golang.org/grpc v1.84.0 google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12 google.golang.org/protobuf v1.36.12
gopkg.in/ini.v1 v1.67.3 gopkg.in/ini.v1 v1.67.3
@@ -252,7 +252,7 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect golang.org/x/tools v0.51.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.75.7 // indirect modernc.org/libc v1.75.7 // indirect
+16 -16
View File
@@ -24,8 +24,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0= gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4= gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y= github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
@@ -686,8 +686,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -728,8 +728,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -756,8 +756,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -767,8 +767,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -779,8 +779,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -790,8 +790,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+6 -1
View File
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp { func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
if requested.Has() { if requested.Has() {
return max(requested.Value(), artifactKeepForever+1) now := timeutil.TimeStampNow()
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
if setting.Actions.ArtifactRetentionDays > 0 {
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
}
return expiry
} }
if setting.Actions.ArtifactRetentionDays <= 0 { if setting.Actions.ArtifactRetentionDays <= 0 {
return artifactKeepForever return artifactKeepForever
+24
View File
@@ -5,6 +5,12 @@ package actions
import ( import (
"testing" "testing"
"time"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
} }
assert.Equal(t, []int64{1, 4, 5}, ids) assert.Equal(t, []int64{1, 4, 5}, ids)
} }
func TestArtifactExpiry(t *testing.T) {
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
now := timeutil.TimeStampNow()
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
requested := optional.Some[timeutil.TimeStamp]
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
setting.Actions.ArtifactRetentionDays = 0
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
assert.Equal(t, days(1), artifactExpiry(requested(now)))
}
+2 -2
View File
@@ -34,8 +34,8 @@ type ActionRun struct {
RepoID int64 `xorm:"unique(repo_index) index(repo_status)"` RepoID int64 `xorm:"unique(repo_index) index(repo_status)"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"` OwnerID int64 `xorm:"index"`
WorkflowID string `xorm:"index"` // the name of workflow file WorkflowID string `xorm:"index"` // the name of workflow file
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository Index int64 `xorm:"unique(repo_index)"` // a unique number for each run of a repository
TriggerUserID int64 `xorm:"index"` TriggerUserID int64 `xorm:"index"`
TriggerUser *user_model.User `xorm:"-"` TriggerUser *user_model.User `xorm:"-"`
ScheduleID int64 ScheduleID int64
+2 -11
View File
@@ -25,7 +25,6 @@ import (
// MaxJobNumPerRun is the maximum number of jobs in a single run. // MaxJobNumPerRun is the maximum number of jobs in a single run.
// https://docs.github.com/en/actions/reference/limits#existing-system-limits // https://docs.github.com/en/actions/reference/limits#existing-system-limits
// TODO: check this limit when creating jobs
const MaxJobNumPerRun = 256 const MaxJobNumPerRun = 256
// ActionRunJob represents a job of a run // ActionRunJob represents a job of a run
@@ -100,7 +99,7 @@ type ActionRunJob struct {
// IsReusableCaller marks this job as a reusable workflow caller. // IsReusableCaller marks this job as a reusable workflow caller.
// Caller jobs do not run on a runner; their status is derived from their child jobs. // Caller jobs do not run on a runner; their status is derived from their child jobs.
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"` IsReusableCaller bool `xorm:"NOT NULL DEFAULT FALSE"`
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete. // IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
// For a reusable workflow caller, true means children rows exist and CallPayload is populated. // For a reusable workflow caller, true means children rows exist and CallPayload is populated.
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"` IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
@@ -440,14 +439,6 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
return out return out
} }
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
// remains in the repo, i.e. work that an idle runner could still pick up.
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
return db.GetEngine(ctx).
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
Exist(&ActionRunJob{})
}
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) { func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
e := db.GetEngine(ctx) e := db.GetEngine(ctx)
@@ -498,7 +489,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
// Only bump when waiting work actually remains for this repo, otherwise // Only bump when waiting work actually remains for this repo, otherwise
// every job completion would needlessly bump the global version and wake // every job completion would needlessly bump the global version and wake
// every idle runner instance-wide for nothing. // every idle runner instance-wide for nothing.
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID) hasWaiting, err := JobQueueOptions{RepoID: job.RepoID, Status: StatusWaiting}.session(ctx).Exist(new(ActionRunJob))
if err != nil { if err != nil {
return 0, err return 0, err
} }
+1 -2
View File
@@ -27,13 +27,12 @@ func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session {
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID}) sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
} }
if opts.OwnerID > 0 { if opts.OwnerID > 0 {
sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID) sess = sess.And(builder.In("`action_run_job`.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.OwnerID})))
} }
return sess.And(opts.statusCond()) return sess.And(opts.statusCond())
} }
var ( var (
// keep in sync with CreateTaskForRunner
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0} queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
// a cancelling job still occupies its runner // a cancelling job still occupies its runner
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling) runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
+28 -32
View File
@@ -261,21 +261,10 @@ var pickTaskBatchSize = 100
// atomically claims it. It iterates through all matching jobs so that a // atomically claims it. It iterates through all matching jobs so that a
// concurrent claim by another runner (which would lose the optimistic lock on // concurrent claim by another runner (which would lose the optimistic lock on
// job #1) does not leave the remaining jobs permanently unassigned. // job #1) does not leave the remaining jobs permanently unassigned.
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) { func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (task *ActionTask, cancelledJobs []*ActionRunJob, err error) {
if db.InTransaction(ctx) { if db.InTransaction(ctx) {
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction") return nil, nil, errors.New("CreateTaskForRunner must not be called within a database transaction")
} }
e := db.GetEngine(ctx)
jobCond := builder.NewCond()
if runner.RepoID != 0 {
jobCond = builder.Eq{"repo_id": runner.RepoID}
} else if runner.OwnerID != 0 {
jobCond = builder.In("repo_id", builder.Select("`repository`.id").From("repository").
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
}
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
// TODO: a more efficient way to filter labels // TODO: a more efficient way to filter labels
log.Trace("runner labels: %v", runner.AgentLabels) log.Trace("runner labels: %v", runner.AgentLabels)
@@ -286,17 +275,17 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
var cursorUpdated timeutil.TimeStamp var cursorUpdated timeutil.TimeStamp
var cursorID int64 var cursorID int64
for { for {
cond := baseCond sess := JobQueueOptions{RepoID: runner.RepoID, OwnerID: runner.OwnerID, Status: StatusWaiting}.session(ctx)
if cursorID > 0 { if cursorID > 0 {
cond = cond.And(builder.Or( sess.And(builder.Or(
builder.Gt{"updated": cursorUpdated}, builder.Gt{"updated": cursorUpdated},
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}), builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
)) ))
} }
var jobs []*ActionRunJob var jobs []*ActionRunJob
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil { if err := sess.Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err return nil, cancelledJobs, err
} }
// A short page means no waiting jobs remain beyond it. // A short page means no waiting jobs remain beyond it.
isLastPage := len(jobs) < pickTaskBatchSize isLastPage := len(jobs) < pickTaskBatchSize
@@ -309,28 +298,29 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if !runner.CanMatchLabels(v.RunsOn) { if !runner.CanMatchLabels(v.RunsOn) {
continue continue
} }
task, ok, err := claimJobForRunner(ctx, runner, v) task, cancelled, err := claimJobForRunner(ctx, runner, v)
if err != nil { if cancelled {
return nil, false, err cancelledJobs = append(cancelledJobs, v)
} }
if ok { if err != nil || task != nil {
return task, true, nil return task, cancelledJobs, err
} }
// Another runner claimed this job concurrently; try the next one. // Another runner claimed this job concurrently, or it was cancelled. Try the next one.
} }
if isLastPage { if isLastPage {
return nil, false, nil return nil, cancelledJobs, nil
} }
} }
} }
// claimJobForRunner attempts to atomically claim job for runner inside its own // claimJobForRunner attempts to atomically claim job for runner inside its own
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when // transaction. Returns the task on success, cancelled when the job's repository
// another runner wins the optimistic-lock race (the caller should try the next // has Actions disabled, or neither when another runner wins the optimistic-lock
// candidate job). // race (the caller should try the next candidate job).
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) { func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
var resultTask *ActionTask var resultTask *ActionTask
var cancelled bool
err := db.WithTx(ctx, func(ctx context.Context) error { err := db.WithTx(ctx, func(ctx context.Context) error {
e := db.GetEngine(ctx) e := db.GetEngine(ctx)
@@ -338,6 +328,15 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
if err := job.LoadAttributes(ctx); err != nil { if err := job.LoadAttributes(ctx); err != nil {
return err return err
} }
if err := job.Run.Repo.LoadUnits(ctx); err != nil {
return err
}
if !job.Run.Repo.UnitEnabled(ctx, unit.TypeActions) {
job.Status, job.Stopped = StatusCancelled, timeutil.TimeStampNow()
affected, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0, "status": StatusWaiting}, "status", "stopped")
cancelled = err == nil && affected == 1
return err
}
now := timeutil.TimeStampNow() now := timeutil.TimeStampNow()
job.Started = now job.Started = now
@@ -403,13 +402,10 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
return nil return nil
}) })
if errors.Is(err, errJobAlreadyClaimed) { if err != nil && !errors.Is(err, errJobAlreadyClaimed) {
return nil, false, nil
}
if err != nil {
return nil, false, err return nil, false, err
} }
return resultTask, true, nil return resultTask, cancelled, nil
} }
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes // ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
+19 -4
View File
@@ -13,6 +13,7 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser" "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -258,6 +259,11 @@ func TestReleaseTaskForRunner(t *testing.T) {
} }
require.NoError(t, db.Insert(t.Context(), run)) require.NoError(t, db.Insert(t.Context(), run))
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
require.NoError(t, db.Insert(t.Context(), disabledRun))
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), disabledJob))
job := &ActionRunJob{ job := &ActionRunJob{
RunID: run.ID, RunID: run.ID,
RepoID: run.RepoID, RepoID: run.RepoID,
@@ -279,11 +285,13 @@ func TestReleaseTaskForRunner(t *testing.T) {
} }
runner.GenerateAndFillToken() runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner)) require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
task, ok, err := CreateTaskForRunner(t.Context(), runner) task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err) require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task) require.NotNil(t, task)
require.Len(t, cancelledJobs, 1)
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}) claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
require.Equal(t, StatusRunning, claimed.Status) require.Equal(t, StatusRunning, claimed.Status)
@@ -324,6 +332,11 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
} }
require.NoError(t, db.Insert(t.Context(), run)) require.NoError(t, db.Insert(t.Context(), run))
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
require.NoError(t, db.Insert(t.Context(), disabledRun))
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
require.NoError(t, db.Insert(t.Context(), disabledJob))
// Five waiting jobs the runner cannot run, then one it can. // 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. // With a page size of 2 the matching job only appears on the third page.
for i := range 5 { for i := range 5 {
@@ -362,11 +375,13 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
} }
runner.GenerateAndFillToken() runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner)) require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
task, ok, err := CreateTaskForRunner(t.Context(), runner) task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err) require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, task) require.NotNil(t, task)
require.Len(t, cancelledJobs, 1)
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID}) claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
assert.Equal(t, StatusRunning, claimed.Status) assert.Equal(t, StatusRunning, claimed.Status)
+1 -1
View File
@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
cond = cond.And(builder.Eq{"primary_key_id": ""}) cond = cond.And(builder.Eq{"primary_key_id": ""})
} }
if opts.OwnerID > 0 { if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
} }
if opts.KeyID != "" { if opts.KeyID != "" {
+1 -1
View File
@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
func (opts FindPublicKeyOptions) ToConds() builder.Cond { func (opts FindPublicKeyOptions) ToConds() builder.Cond {
cond := builder.NewCond() cond := builder.NewCond()
if opts.OwnerID > 0 { if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
} }
if opts.Fingerprint != "" { if opts.Fingerprint != "" {
+9 -10
View File
@@ -182,7 +182,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) { func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
if status.Repo == nil { if status.Repo == nil {
status.Repo, err = repo_model.GetRepositoryByID(ctx, status.RepoID) status.Repo, err = cache.GetWithContextCache(ctx, cachegroup.Repository, status.RepoID, repo_model.GetRepositoryByID)
if err != nil { if err != nil {
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err) return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
} }
@@ -230,11 +230,9 @@ func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context)
return "", false return "", false
} }
if status.Repo == nil { if err := status.loadRepository(ctx); err != nil {
if err := status.loadRepository(ctx); err != nil { log.Error("loadRepository: %v", err)
log.Error("loadRepository: %v", err) return "", false
return "", false
}
} }
prefix := status.Repo.Link() + "/actions" prefix := status.Repo.Link() + "/actions"
@@ -572,11 +570,12 @@ func SignCommitsApplyDoerPermission(ctx context.Context, doer *user_model.User,
} }
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool { func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
if err := status.loadRepository(ctx); err != nil {
log.Error("loadRepository: %v", err)
return false
}
perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer), perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer),
func(ctx context.Context, _ string) (access_model.Permission, error) { // only runs on a cache miss func(ctx context.Context, _key string) (access_model.Permission, error) {
if err := status.loadRepository(ctx); err != nil {
return access_model.Permission{}, err
}
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer) return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
}, },
) )
+4 -1
View File
@@ -13,6 +13,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/commitstatus" "gitea.dev/modules/commitstatus"
"gitea.dev/modules/git" "gitea.dev/modules/git"
@@ -281,8 +282,10 @@ func TestCommitStatusesApplyDoerPermission(t *testing.T) {
}, },
} }
git_model.CommitStatusesApplyDoerPermission(t.Context(), doer, statuses) git_model.CommitStatusesApplyDoerPermission(cache.WithCacheContext(t.Context()), doer, statuses)
assert.Equal(t, visibleURL, statuses[0].TargetURL) assert.Equal(t, visibleURL, statuses[0].TargetURL)
assert.Empty(t, statuses[1].TargetURL) assert.Empty(t, statuses[1].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL) assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
assert.NotNil(t, statuses[0].Repo)
assert.Same(t, statuses[0].Repo, statuses[2].Repo)
} }
+15
View File
@@ -34,6 +34,7 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm"
) )
// ErrCommentNotExist represents a "CommentNotExist" kind of error. // ErrCommentNotExist represents a "CommentNotExist" kind of error.
@@ -369,12 +370,19 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) {
return err return err
} }
var (
_ xorm.BeforeInsertProcessor = (*Comment)(nil)
_ xorm.BeforeUpdateProcessor = (*Comment)(nil)
)
// BeforeInsert will be invoked by XORM before inserting a record // BeforeInsert will be invoked by XORM before inserting a record
func (c *Comment) BeforeInsert() { func (c *Comment) BeforeInsert() {
c.PatchQuoted = c.Patch c.PatchQuoted = c.Patch
if !utf8.ValidString(c.Patch) { if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch) c.PatchQuoted = strconv.Quote(c.Patch)
} }
sanitizeDatabaseStringContent(&c.Content)
} }
// BeforeUpdate will be invoked by XORM before updating a record // BeforeUpdate will be invoked by XORM before updating a record
@@ -383,6 +391,8 @@ func (c *Comment) BeforeUpdate() {
if !utf8.ValidString(c.Patch) { if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch) c.PatchQuoted = strconv.Quote(c.Patch)
} }
sanitizeDatabaseStringContent(&c.Content)
} }
// AfterLoad is invoked from XORM after setting the values of all fields of this object. // AfterLoad is invoked from XORM after setting the values of all fields of this object.
@@ -1164,6 +1174,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
if affected == 0 { if affected == 0 {
return ErrCommentAlreadyChanged return ErrCommentAlreadyChanged
} }
if c.Type == CommentTypeReview {
if _, err := db.GetEngine(ctx).ID(c.ReviewID).Cols("content").NoAutoTime().Update(&Review{Content: c.Content}); err != nil { // updated_unix picks a reviewer's latest review
return err
}
}
if err := c.LoadIssue(ctx); err != nil { if err := c.LoadIssue(ctx); err != nil {
return err return err
} }
+26 -3
View File
@@ -11,6 +11,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
"strings"
"gitea.dev/models/db" "gitea.dev/models/db"
project_model "gitea.dev/models/project" project_model "gitea.dev/models/project"
@@ -25,6 +26,7 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm"
) )
// ErrIssueNotExist represents a "IssueNotExist" kind of error. // ErrIssueNotExist represents a "IssueNotExist" kind of error.
@@ -53,7 +55,7 @@ var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already
// Issue represents an issue or pull request of repository. // Issue represents an issue or pull request of repository.
type Issue struct { type Issue struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"` RepoID int64 `xorm:"INDEX(repo_closed_pull) UNIQUE(repo_index)"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository. Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
PosterID int64 `xorm:"INDEX"` PosterID int64 `xorm:"INDEX"`
@@ -75,9 +77,9 @@ type Issue struct {
AssigneeID int64 `xorm:"-"` AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"` Assignee *user_model.User `xorm:"-"`
isAssigneeLoaded bool `xorm:"-"` isAssigneeLoaded bool `xorm:"-"`
IsClosed bool `xorm:"INDEX"` IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
IsRead bool `xorm:"-"` IsRead bool `xorm:"-"`
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not. IsPull bool `xorm:"INDEX(repo_closed_pull)"` // Indicates whether is a pull request or not.
PullRequest *PullRequest `xorm:"-"` PullRequest *PullRequest `xorm:"-"`
NumComments int NumComments int
@@ -123,6 +125,27 @@ func init() {
db.RegisterModel(new(IssueIndex)) db.RegisterModel(new(IssueIndex))
} }
func sanitizeDatabaseStringContent(s *string) {
// postgresql doesn't like "\x00"
// this is only a simple workaround for issues like https://github.com/go-gitea/gitea/issues/28890 and https://github.com/go-gitea/gitea/issues/40003
*s = strings.ReplaceAll(*s, "\x00", " ")
}
var (
_ xorm.BeforeInsertProcessor = (*Issue)(nil)
_ xorm.BeforeUpdateProcessor = (*Issue)(nil)
)
func (issue *Issue) BeforeInsert() {
sanitizeDatabaseStringContent(&issue.Title)
sanitizeDatabaseStringContent(&issue.Content)
}
func (issue *Issue) BeforeUpdate() {
sanitizeDatabaseStringContent(&issue.Title)
sanitizeDatabaseStringContent(&issue.Content)
}
// LoadTotalTimes load total tracked time // LoadTotalTimes load total tracked time
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) { func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
opts := FindTrackedTimesOptions{IssueID: issue.ID} opts := FindTrackedTimesOptions{IssueID: issue.ID}
+15 -23
View File
@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
// Since this sortType is dynamically created, it has to be treated specially. // Since this sortType is dynamically created, it has to be treated specially.
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok { if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
scope := after scope := after
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id") // a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%") " INNER JOIN label ON label.id = issue_label.label_id"+
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int) " WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id") Desc("issue.id")
return return
} }
@@ -218,10 +218,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs) opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
} }
if opts.AllPublic { if opts.AllPublic {
if opts.RepoCond == nil { opts.RepoCond = builder.Or(opts.RepoCond, repo_model.PublicRepoUnderPublicOwnerCond())
opts.RepoCond = builder.NewCond()
}
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
} }
if opts.RepoCond != nil { if opts.RepoCond != nil {
sess.And(opts.RepoCond) sess.And(opts.RepoCond)
@@ -402,22 +399,17 @@ func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
From("team_user"). From("team_user").
Where(builder.Eq{"team_user.uid": reviewRequestedID}) Where(builder.Eq{"team_user.uid": reviewRequestedID})
// if the review is approved or rejected, it should not be shown in the review requested list
maxReview := builder.Select("MAX(r.id)").
From("review as r").
Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
subQuery := builder.Select("review.issue_id"). subQuery := builder.Select("review.issue_id").
From("review"). From("review").
Where(builder.And( Where(builder.Eq{"review.type": ReviewTypeRequest}).
builder.Eq{"review.type": ReviewTypeRequest}, And(builder.Or(
builder.Or( builder.Eq{"review.reviewer_id": reviewRequestedID},
builder.Eq{"review.reviewer_id": reviewRequestedID}, builder.In("review.reviewer_team_id", existInTeamQuery),
builder.In("review.reviewer_team_id", existInTeamQuery), )).
), // if the review is approved or rejected, it should not be shown in the review requested list
builder.In("review.id", maxReview), And(builder.NotExists(builder.Select("1").From("review as r").
)) Where(builder.Expr("r.issue_id = review.issue_id AND r.reviewer_id = review.reviewer_id AND r.reviewer_team_id = review.reviewer_team_id AND r.id > review.id").
And(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})))))
sess.Where("issue.poster_id <> ?", reviewRequestedID). sess.Where("issue.poster_id <> ?", reviewRequestedID).
And(builder.In("issue.id", subQuery)) And(builder.In("issue.id", subQuery))
} }
+12 -6
View File
@@ -39,12 +39,13 @@ func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList)
// FindMilestoneOptions contain options to get milestones // FindMilestoneOptions contain options to get milestones
type FindMilestoneOptions struct { type FindMilestoneOptions struct {
db.ListOptions db.ListOptions
RepoID int64 RepoID int64
IsClosed optional.Option[bool] IsClosed optional.Option[bool]
Name string Name string
SortType string SortType string
RepoCond builder.Cond RepoCond builder.Cond
RepoIDs []int64 RepoIDs []int64
ProjectID int64
} }
func (opts FindMilestoneOptions) ToConds() builder.Cond { func (opts FindMilestoneOptions) ToConds() builder.Cond {
@@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond {
if len(opts.Name) != 0 { if len(opts.Name) != 0 {
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name)) cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
} }
if opts.ProjectID != 0 {
cond = cond.And(builder.In("id", builder.Select("issue.milestone_id").From("issue").
InnerJoin("project_issue", "project_issue.issue_id = issue.id").
Where(builder.Eq{"project_issue.project_id": opts.ProjectID})))
}
return cond return cond
} }
+1 -1
View File
@@ -378,7 +378,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
reviewersWritten := 0 reviewersWritten := 0
for _, review := range reviews { for _, review := range reviews {
if maxReviewers > 0 && reviewersWritten > maxReviewers { if maxReviewers > 0 && reviewersWritten >= maxReviewers {
break break
} }
+3 -3
View File
@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0, "reviewer_team_id": 0,
}). }).
GroupBy("issue_id, reviewer_id") GroupBy("issue_id, reviewer_id")
// Get latest review of each reviewer, sorted in order they were made // Get latest review of each reviewer, sorted in order they were made. Joined because MySQL scans all reviews for "id IN (subquery)", xorm aliases the subquery "review".
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil { if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
return nil, err return nil, err
} }
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0, "reviewer_team_id": 0,
}). }).
GroupBy("issue_id, reviewer_team_id") GroupBy("issue_id, reviewer_team_id")
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil { if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQueryTeam, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&teamReviewRequests); err != nil {
return nil, err return nil, err
} }
+5
View File
@@ -14,6 +14,7 @@ import (
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -403,6 +404,10 @@ func testGetApprovers(t *testing.T) {
approvers := pr.GetApprovers(t.Context()) approvers := pr.GetApprovers(t.Context())
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n" expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
assert.Equal(t, expected, approvers) assert.Equal(t, expected, approvers)
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers, 1)()
approvers = pr.GetApprovers(t.Context())
assert.Equal(t, "Reviewed-by: User Five <user5@example.com>\n", approvers)
} }
func testGetPullRequestByMergedCommit(t *testing.T) { func testGetPullRequestByMergedCommit(t *testing.T) {
+1 -1
View File
@@ -70,7 +70,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
func (opts FindOrgOptions) ToConds() builder.Cond { func (opts FindOrgOptions) ToConds() builder.Cond {
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization} var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
if opts.UserID > 0 { if opts.UserID != 0 {
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate))) cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
} }
// public=0, limited=1, private=2 // public=0, limited=1, private=2
+2 -2
View File
@@ -13,12 +13,12 @@ import (
"xorm.io/builder" "xorm.io/builder"
) )
// TeamRepo represents an team-repository relation. // TeamRepo represents an org-team-repository relation.
type TeamRepo struct { type TeamRepo struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"` OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"` TeamID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"` RepoID int64 `xorm:"UNIQUE(s) INDEX"`
} }
// HasTeamRepo returns true if given repository belongs to team. // HasTeamRepo returns true if given repository belongs to team.
+2 -2
View File
@@ -12,12 +12,12 @@ import (
"xorm.io/builder" "xorm.io/builder"
) )
// TeamUser represents an team-user relation. // TeamUser represents a team-user relation.
type TeamUser struct { type TeamUser struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"` OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"` TeamID int64 `xorm:"UNIQUE(s)"`
UID int64 `xorm:"UNIQUE(s)"` UID int64 `xorm:"UNIQUE(s) INDEX"`
} }
// IsTeamMember returns true if given user is a member of team. // IsTeamMember returns true if given user is a member of team.
+5 -2
View File
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
} }
// IsBlobAccessibleForUser tests if the user has access to the blob // IsBlobAccessibleForUser tests if the user has access to the blob
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) { func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
if user.IsAdmin { if user.IsAdmin && !publicOnly {
return true, nil return true, nil
} }
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic} ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}. Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))), And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
) )
if publicOnly {
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
}
return db.GetEngine(ctx). return db.GetEngine(ctx).
Table("package_blob"). Table("package_blob").
+6 -2
View File
@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"}) _, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
require.NoError(t, err) require.NoError(t, err)
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})) accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, accessible) assert.True(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})) accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
require.NoError(t, err)
assert.False(t, accessible)
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, accessible) assert.False(t, accessible)
} }
+3 -3
View File
@@ -30,9 +30,9 @@ const (
// PackageProperty represents a property of a package, version or file // PackageProperty represents a property of a package, version or file
type PackageProperty struct { type PackageProperty struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
RefType PropertyType `xorm:"INDEX NOT NULL"` RefType PropertyType `xorm:"NOT NULL"`
RefID int64 `xorm:"INDEX NOT NULL"` RefID int64 `xorm:"INDEX(ref_id_name) NOT NULL"`
Name string `xorm:"INDEX NOT NULL"` Name string `xorm:"INDEX(ref_id_name) NOT NULL"`
Value string `xorm:"LONGTEXT NOT NULL"` Value string `xorm:"LONGTEXT NOT NULL"`
} }
+1 -1
View File
@@ -25,7 +25,7 @@ import (
type Access struct { type Access struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(s)"` UserID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"` RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Mode perm.AccessMode Mode perm.AccessMode
} }
+1 -1
View File
@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
if opts.Type > 0 { if opts.Type > 0 {
cond = cond.And(builder.Eq{"type": opts.Type}) cond = cond.And(builder.Eq{"type": opts.Type})
} }
if opts.OwnerID > 0 { if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
} }
+1 -2
View File
@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
repo.MustOwner(ctx) repo.MustOwner(ctx)
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
teams := make([]string, 0, 5) teams := make([]string, 0, 5)
_ = db.GetEngine(ctx).Table("team_repo"). _ = db.GetEngine(ctx).Select("team.lower_name").Table("team_repo").
Join("INNER", "team", "team.id = team_repo.team_id"). Join("INNER", "team", "team.id = team_repo.team_id").
Where("team_repo.repo_id = ?", repo.ID). Where("team_repo.repo_id = ?", repo.ID).
Select("team.lower_name").
OrderBy("team.lower_name"). OrderBy("team.lower_name").
Find(&teams) Find(&teams)
metas["teams"] = "," + strings.Join(teams, ",") + "," metas["teams"] = "," + strings.Join(teams, ",") + ","
+3 -3
View File
@@ -373,8 +373,7 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
cond := builder.NewCond() cond := builder.NewCond()
if opts.Private { if opts.Private {
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID { if opts.Actor != nil && !opts.Actor.IsAdmin {
// OK we're in the context of a User
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid)) cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
} }
} else { } else {
@@ -762,7 +761,8 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
func PublicRepoUnderPublicOwnerCond() builder.Cond { func PublicRepoUnderPublicOwnerCond() builder.Cond {
return builder.And( return builder.And(
builder.Eq{"`repository`.is_private": false}, builder.Eq{"`repository`.is_private": false},
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})), builder.Exists(builder.Select("1").From("`user` repo_owner").
Where(builder.Expr("repo_owner.id = `repository`.owner_id").And(builder.Eq{"repo_owner.visibility": structs.VisibleTypePublic}))),
) )
} }
+1 -1
View File
@@ -77,7 +77,7 @@ func IsErrRepoAlreadyExist(err error) bool {
} }
func (err ErrRepoAlreadyExist) Error() string { func (err ErrRepoAlreadyExist) Error() string {
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name) return fmt.Sprintf("repository already exists [owner: %s, repo: %s]", err.Uname, err.Name)
} }
func (err ErrRepoAlreadyExist) Unwrap() error { func (err ErrRepoAlreadyExist) Unwrap() error {
+4 -1
View File
@@ -39,7 +39,7 @@ const (
type Watch struct { type Watch struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch)"` UserID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch)"` RepoID int64 `xorm:"UNIQUE(watch) INDEX"`
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -112,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
} }
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) { func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
if watch.UserID <= 0 {
return nil
}
if watch.Mode == mode { if watch.Mode == mode {
return nil return nil
} }
+10 -4
View File
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watches, err := repo_model.GetWatchers(t.Context(), repo.ID) watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
assert.NoError(t, err) assert.NoError(t, err)
// One watchers are inactive, thus minus 1 // One watcher is inactive, thus minus 1
assert.Len(t, watches, repo.NumWatches-1) assert.Len(t, watches, repo.NumWatches-1)
for _, watch := range watches { for _, watch := range watches {
assert.Equal(t, repo.ID, watch.RepoID) assert.Equal(t, repo.ID, watch.RepoID)
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
setting.Service.AutoWatchOnChanges = true setting.Service.AutoWatchOnChanges = true
// Must not add watch // Must not add watch (user 8 has "don't watch" set)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount) assert.Len(t, watchers, prevCount)
// Should not add watch // Should not add watch (read operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount) assert.Len(t, watchers, prevCount)
// Should add watch // Should add watch (write operation)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true)) assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1) assert.Len(t, watchers, prevCount+1)
// Should not add watch (system user should not be able to watch)
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount+1)
// Should remove watch, inhibit from adding auto // Should remove watch, inhibit from adding auto
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false)) assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
+13 -1
View File
@@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"slices" "slices"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -103,6 +104,10 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
require.NoError(t, err, "creating fixture %s", fixturePath) require.NoError(t, err, "creating fixture %s", fixturePath)
defer out.Close() defer out.Close()
if resp.StatusCode != http.StatusOK {
_, err = fmt.Fprintf(out, "HTTP/1.1 %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode))
require.NoError(t, err, "writing fixture header for %s", liveURL)
}
for _, name := range slices.Sorted(maps.Keys(resp.Header)) { for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
if slices.Contains(ignoredHeaders, strings.ToLower(name)) { if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
continue continue
@@ -125,14 +130,21 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL) replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
headers, body, _ := strings.Cut(replayed, "\n\n") headers, body, _ := strings.Cut(replayed, "\n\n")
code := http.StatusOK
for line := range strings.SplitSeq(headers, "\n") { for line := range strings.SplitSeq(headers, "\n") {
if strings.HasPrefix(line, "HTTP/") {
parts := strings.SplitN(line, " ", 3)
require.Len(t, parts, 3)
code, _ = strconv.Atoi(parts[1])
continue
}
name, value, ok := strings.Cut(line, ": ") name, value, ok := strings.Cut(line, ": ")
if !ok || strings.EqualFold(name, "Content-Length") { if !ok || strings.EqualFold(name, "Content-Length") {
continue continue
} }
w.Header().Set(name, value) w.Header().Set(name, value)
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(code)
_, err = w.Write([]byte(body)) _, err = w.Write([]byte(body))
require.NoError(t, err) require.NoError(t, err)
}) })
+4
View File
@@ -8,6 +8,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder" "xorm.io/builder"
) )
@@ -32,6 +33,9 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool {
// FollowUser marks someone be another's follower. // FollowUser marks someone be another's follower.
func FollowUser(ctx context.Context, user, follow *User) (err error) { func FollowUser(ctx context.Context, user, follow *User) (err error) {
if !follow.IsFollowable() {
return util.NewInvalidArgumentErrorf("user %d can not be followed", follow.ID)
}
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) { if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
return nil return nil
} }
+24 -5
View File
@@ -68,13 +68,20 @@ const (
UserTypeRemoteUser // 5 UserTypeRemoteUser // 5
) )
// AdminListedUserTypes HINT: USER-TYPE-GLOSSARY: administrators list every user type except organizations
var AdminListedUserTypes = []UserType{UserTypeIndividual, UserTypeUserReserved, UserTypeBot, UserTypeRemoteUser}
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead // DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
func (t UserType) DisplayName() string { func (t UserType) DisplayName() string {
switch t { switch t {
case UserTypeOrganization, UserTypeOrganizationReserved: case UserTypeOrganization, UserTypeOrganizationReserved:
return "Organization" return "Organization"
case UserTypeUserReserved:
return "Reserved"
case UserTypeBot: case UserTypeBot:
return "Bot" return "Bot"
case UserTypeRemoteUser:
return "Remote"
default: default:
return "User" return "User"
} }
@@ -250,10 +257,10 @@ func (u *User) GetEmail() string {
return u.Email return u.Email
} }
// GetAllUsers returns a slice of all individual users found in DB. // GetAllUsersForAdminListing returns a slice of all users of AdminListedUserTypes found in DB.
func GetAllUsers(ctx context.Context) ([]*User, error) { func GetAllUsersForAdminListing(ctx context.Context) (users []*User, err error) {
users := make([]*User, 0) err = db.GetEngine(ctx).OrderBy("id").In("type", AdminListedUserTypes).Find(&users)
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users) return users, err
} }
// IsLocal returns true if user login type is LoginPlain. // IsLocal returns true if user login type is LoginPlain.
@@ -440,6 +447,18 @@ func (u *User) IsIndividual() bool {
return u.ID > 0 && u.Type == UserTypeIndividual return u.ID > 0 && u.Type == UserTypeIndividual
} }
// IsFollowable returns whether other users can follow this user (individuals, bots and organizations)
func (u *User) IsFollowable() bool {
if u.ID <= 0 {
return false
}
switch u.Type {
case UserTypeIndividual, UserTypeBot, UserTypeOrganization:
return true
}
return false
}
// IsTypeBot returns whether the user is of type bot // IsTypeBot returns whether the user is of type bot
func (u *User) IsTypeBot() bool { func (u *User) IsTypeBot() bool {
return u.Type == UserTypeBot return u.Type == UserTypeBot
@@ -1048,7 +1067,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err return users, err
} }
// Glossaries for XxxUser: // HINT: USER-TYPE-GLOSSARY: Glossaries for XxxUser:
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID. // * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
// * Individual: The user is a real person, not an organization or a bot. // * Individual: The user is a real person, not an organization or a bot.
// * Doer: The user is the one performing an action // * Doer: The user is the one performing an action
+1 -10
View File
@@ -11,7 +11,6 @@ import (
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/log" "gitea.dev/modules/log"
) )
@@ -36,19 +35,11 @@ func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.Commit
return nil return nil
} }
statusByJobID := make(map[int64]*git_model.CommitStatus) statusByJobID := make(map[int64]*git_model.CommitStatus)
repoByID := make(map[int64]*repo_model.Repository)
for _, status := range statuses { for _, status := range statuses {
if status == nil || status.TargetURL == "" { if status == nil || status.TargetURL == "" {
continue continue
} }
if status.Repo == nil { if _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx); ok {
status.Repo = repoByID[status.RepoID]
}
// ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the
// outcome so later entries with the same RepoID skip that load.
_, jobID, ok := status.ParseGiteaActionsTargetURL(ctx)
repoByID[status.RepoID] = status.Repo
if ok {
statusByJobID[jobID] = status statusByJobID[jobID] = status
} }
} }
+1
View File
@@ -8,5 +8,6 @@ const (
EmailAvatarLink = "email_avatar_link" EmailAvatarLink = "email_avatar_link"
UserEmailAddresses = "user_email_addresses" UserEmailAddresses = "user_email_addresses"
GPGKeyWithSubKeys = "gpg_key_with_subkeys" GPGKeyWithSubKeys = "gpg_key_with_subkeys"
Repository = "repository"
RepoUserPermission = "repo_user_permission" RepoUserPermission = "repo_user_permission"
) )
+8 -7
View File
@@ -52,7 +52,7 @@ func Lookup(label string) (e encoding.Encoding, name string) {
type ConvertOpts struct { type ConvertOpts struct {
KeepBOM bool KeepBOM bool
ErrorReplacement []byte ErrorReplacement []byte // leave it empty to remove the invalid bytes
ErrorReturnOrigin bool ErrorReturnOrigin bool
} }
@@ -99,7 +99,12 @@ func ToUTF8DropErrors(content []byte) []byte {
func ToUTF8(content []byte, opts ConvertOpts) []byte { func ToUTF8(content []byte, opts ConvertOpts) []byte {
charsetLabel, _ := DetectEncoding(content) charsetLabel, _ := DetectEncoding(content)
if charsetLabel == "UTF-8" { if charsetLabel == "UTF-8" {
return maybeRemoveBOM(content, opts) // even if the detected charset is UTF-8, the content may contain invalid UTF-8 sequences, so need to convert it to valid UTF-8
content = maybeRemoveBOM(content, opts)
if opts.ErrorReturnOrigin {
return content
}
return bytes.ToValidUTF8(content, opts.ErrorReplacement)
} }
encoding, _ := Lookup(charsetLabel) encoding, _ := Lookup(charsetLabel)
@@ -123,11 +128,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
if opts.ErrorReturnOrigin { if opts.ErrorReturnOrigin {
return content return content
} }
if opts.ErrorReplacement == nil { decoded = append(decoded, opts.ErrorReplacement...)
decoded = append(decoded, content[idx+n])
} else {
decoded = append(decoded, opts.ErrorReplacement...)
}
idx += n + 1 idx += n + 1
} }
return maybeRemoveBOM(decoded, opts) return maybeRemoveBOM(decoded, opts)
+2
View File
@@ -171,6 +171,8 @@ func TestToUTF8DropErrors(t *testing.T) {
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}) res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res) assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
assert.Equal(t, "caf ", string(ToUTF8DropErrors([]byte("caf\xe9"))))
} }
func TestDetectEncoding(t *testing.T) { func TestDetectEncoding(t *testing.T) {
+13 -17
View File
@@ -5,6 +5,7 @@ package git
import ( import (
"bufio" "bufio"
"cmp"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -200,22 +201,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
} }
if old { if old {
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64) begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
end, _ = strconv.ParseInt(groups["endOld"], 10, 64) end, _ = strconv.ParseInt(cmp.Or(groups["endOld"], "1"), 10, 64)
// init otherLine with begin of opposite side // init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64) otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
} else { } else {
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64) begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
if groups["endNew"] != "" { end, _ = strconv.ParseInt(cmp.Or(groups["endNew"], "1"), 10, 64)
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
} else {
end = 0
}
// init otherLine with begin of opposite side // init otherLine with begin of opposite side
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64) otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
} }
end += begin // end is for real only the number of lines in hunk end += begin // end is for real only the number of lines in hunk
// lof is between begin and end // lof is between begin and end
if begin <= line && end >= line { if begin <= line && line < end {
hunk = append(hunk, lof) hunk = append(hunk, lof)
currentLine = begin currentLine = begin
continue continue
@@ -265,27 +262,26 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
oldBegin = otherLine oldBegin = otherLine
newBegin = currentLine newBegin = currentLine
} }
// headers + hunk header
newHunk := make([]string, headerLines)
// transfer existing headers
copy(newHunk, hunk[:headerLines])
// transfer last n lines
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
// calculate newBegin, ... by counting lines // calculate newBegin, ... by counting lines
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- { start := len(hunk)
switch hunk[i][0] { for count := 0; count < numbersOfLine && start > headerLines+1; {
start--
switch hunk[start][0] {
case '+': case '+':
newBegin-- newBegin--
newNumOfLines++ newNumOfLines++
case '-': case '-':
oldBegin-- oldBegin--
oldNumOfLines++ oldNumOfLines++
case '\\':
continue
default: default:
oldBegin-- oldBegin--
newBegin-- newBegin--
newNumOfLines++ newNumOfLines++
oldNumOfLines++ oldNumOfLines++
} }
count++
} }
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC" // "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
@@ -294,9 +290,9 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part) // For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
// construct the new hunk header // construct the new hunk header
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@", hunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
oldBegin, oldNumOfLines, newBegin, newNumOfLines) oldBegin, oldNumOfLines, newBegin, newNumOfLines)
return strings.Join(newHunk, "\n"), nil return strings.Join(append(hunk[:headerLines+1], hunk[start:]...), "\n"), nil
} }
// GetAffectedFiles returns the affected files between two commits // GetAffectedFiles returns the affected files between two commits
+9 -1
View File
@@ -120,10 +120,18 @@ func TestCutDiffAroundLine(t *testing.T) {
assert.Empty(t, emptyResult) assert.Empty(t, emptyResult)
// Line is out of scope // Line is out of scope
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0) emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 7, false, 3)
assert.NoError(t, err) assert.NoError(t, err)
assert.Empty(t, emptyResult) assert.Empty(t, emptyResult)
noNewlineDiff := "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a"
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, false, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+a", result)
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, true, 2)
assert.NoError(t, err)
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a", result)
// Handle minus diffs properly // Handle minus diffs properly
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4) minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
assert.NoError(t, err) assert.NoError(t, err)
+7 -3
View File
@@ -71,12 +71,12 @@ func DefaultFeatures() *Features {
func loadGitVersionFeatures() (*Features, error) { func loadGitVersionFeatures() (*Features, error) {
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background()) stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
if runErr != nil { if runErr != nil {
return nil, runErr return nil, fmt.Errorf(`failed to run command "git version": %w`, runErr)
} }
ver, err := parseGitVersionLine(strings.TrimSpace(stdout)) ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf(`failed to parse output of command "git version": %w`, err)
} }
features := &Features{gitVersion: ver, UsingGogit: isGogit} features := &Features{gitVersion: ver, UsingGogit: isGogit}
@@ -108,7 +108,11 @@ func parseGitVersionLine(s string) (*version.Version, error) {
if len(versionFields) > 3 { if len(versionFields) > 3 {
versionFields = versionFields[:3] versionFields = versionFields[:3]
} }
return version.NewVersion(strings.Join(versionFields, ".")) ver, err := version.NewVersion(strings.Join(versionFields, "."))
if err != nil {
return nil, fmt.Errorf("invalid git version: %q: %w", s, err)
}
return ver, nil
} }
func checkGitVersionCompatibility(gitVer *version.Version) error { func checkGitVersionCompatibility(gitVer *version.Version) error {
+1
View File
@@ -93,6 +93,7 @@ const (
StderrPathSpec StderrPrefix = "fatal: pathspec" StderrPathSpec StderrPrefix = "fatal: pathspec"
StderrBadRevision StderrPrefix = "fatal: bad revision" StderrBadRevision StderrPrefix = "fatal: bad revision"
StderrNoSuchPath StderrPrefix = "fatal: no such path" StderrNoSuchPath StderrPrefix = "fatal: no such path"
StderrBadObject StderrPrefix = "fatal: bad object "
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128 StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2 StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
+8 -5
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"mime"
"net/http" "net/http"
"path" "path"
"strconv" "strconv"
@@ -60,11 +61,13 @@ func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
header.Set("X-Content-Type-Options", "nosniff") header.Set("X-Content-Type-Options", "nosniff")
csp := serveHeaderCspDefault csp := serveHeaderCspDefault
if strings.HasPrefix(contentType, "application/pdf") { if mediaType, _, err := mime.ParseMediaType(contentType); err == nil { // browsers may parse malformed types differently
csp = serveHeaderCspPdf if mediaType == "application/pdf" {
} csp = serveHeaderCspPdf
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") { }
csp = serveHeaderCspAudioVideo if strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") {
csp = serveHeaderCspAudioVideo
}
} }
if csp != "" { if csp != "" {
header.Set("Content-Security-Policy", csp) header.Set("Content-Security-Policy", csp)
+3 -2
View File
@@ -117,9 +117,10 @@ func TestServeSetHeaderContentRelated(t *testing.T) {
{"", serveHeaderCspDefault}, {"", serveHeaderCspDefault},
{"any", serveHeaderCspDefault}, {"any", serveHeaderCspDefault},
{"application/pdf", serveHeaderCspPdf}, {"application/pdf", serveHeaderCspPdf},
{"application/pdf; other", serveHeaderCspPdf}, {"application/pdf; charset=binary", serveHeaderCspPdf},
{"audio/mp4", serveHeaderCspAudioVideo}, {"audio/mp4", serveHeaderCspAudioVideo},
{"video/ogg; other", serveHeaderCspAudioVideo}, {"video/ogg; codecs=theora", serveHeaderCspAudioVideo},
{"video/mp4;,text/html", serveHeaderCspDefault},
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault}, {typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
} }
for _, c := range cases { for _, c := range cases {
+5 -4
View File
@@ -23,7 +23,7 @@ type Decoder interface {
// Interface represents an interface to handle json data // Interface represents an interface to handle json data
type Interface interface { type Interface interface {
Marshal(v any) ([]byte, error) Marshal(v any) ([]byte, error)
MarshalWrite(w io.Writer, v any) error MarshalWriteRelaxed(w io.Writer, v any) error
Unmarshal(data []byte, v any) error Unmarshal(data []byte, v any) error
NewEncoder(writer io.Writer) Encoder NewEncoder(writer io.Writer) Encoder
NewDecoder(reader io.Reader) Decoder NewDecoder(reader io.Reader) Decoder
@@ -37,9 +37,10 @@ func Marshal(v any) ([]byte, error) {
return DefaultJSONHandler.Marshal(v) return DefaultJSONHandler.Marshal(v)
} }
// MarshalWrite writes the JSON encoding of v to the given writer // MarshalWriteRelaxed writes the JSON encoding of v to the given writer
func MarshalWrite(w io.Writer, v any) error { // It tolerates invalid UTF-8 in strings and replaces them with the Unicode replacement character U+FFFD.
return DefaultJSONHandler.MarshalWrite(w, v) func MarshalWriteRelaxed(w io.Writer, v any) error {
return DefaultJSONHandler.MarshalWriteRelaxed(w, v)
} }
// Unmarshal decodes object from bytes // Unmarshal decodes object from bytes
+5 -2
View File
@@ -13,6 +13,7 @@ import (
type JSONv2 struct { type JSONv2 struct {
marshalOptions jsonv2.Options marshalOptions jsonv2.Options
marshalRelaxedOptions jsonv2.Options
marshalKeepOptionalEmptyOptions jsonv2.Options marshalKeepOptionalEmptyOptions jsonv2.Options
unmarshalOptions jsonv2.Options unmarshalOptions jsonv2.Options
unmarshalCaseInsensitiveOptions jsonv2.Options unmarshalCaseInsensitiveOptions jsonv2.Options
@@ -26,6 +27,8 @@ func init() {
jsonv2.FormatNilMapAsNull(true), jsonv2.FormatNilMapAsNull(true),
} }
jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...) jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...)
// when handling user's data, we want to allow invalid UTF-8 to be replaced with the replacement character, rather than returning an error.
jsonV2.marshalRelaxedOptions = jsonv2.JoinOptions(append(commonMarshalOptions, jsontext.AllowInvalidUTF8(true))...)
jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2() jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2()
// By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from. // By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from.
@@ -53,8 +56,8 @@ func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions) return jsonv2.Marshal(v, j.marshalOptions)
} }
func (j *JSONv2) MarshalWrite(w io.Writer, v any) error { func (j *JSONv2) MarshalWriteRelaxed(w io.Writer, v any) error {
return jsonv2.MarshalWrite(w, v, j.marshalOptions) return jsonv2.MarshalWrite(w, v, j.marshalRelaxedOptions)
} }
func (j *JSONv2) Unmarshal(data []byte, v any) error { func (j *JSONv2) Unmarshal(data []byte, v any) error {
+1 -1
View File
@@ -22,7 +22,7 @@ type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
// Client is used to communicate with a LFS source // Client is used to communicate with a LFS source
type Client interface { type Client interface {
BatchSize() int BatchSize() int
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error // ref is the full git ref name the objects belong to, may be empty
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
} }
+1 -1
View File
@@ -38,7 +38,7 @@ func (c *FilesystemClient) objectPath(oid string) (string, error) {
} }
// Download reads the specific LFS object from the target path // Download reads the specific LFS object from the target path
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { func (c *FilesystemClient) Download(ctx context.Context, _ string, objects []Pointer, callback DownloadCallback) error {
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
+12 -12
View File
@@ -67,18 +67,18 @@ func (c *HTTPClient) transferNames() []string {
return keys return keys
} }
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) { func (c *HTTPClient) batch(ctx context.Context, operation, ref string, objects []Pointer) (*BatchResponse, error) {
log.Trace("BATCH operation with objects: %v", objects) log.Trace("BATCH operation with objects: %v", objects)
url := c.endpoint + "/objects/batch" url := c.endpoint + "/objects/batch"
// Original: In some lfs server implementations, they require the ref attribute. #32838 // `ref` is optional, but some servers like aliyun require it, while others like Hugging Face reject an empty or null one
// `ref` is an "optional object describing the server ref that the objects belong to"
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37 // https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
// // Related issues: #32838, #33453, #39891
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453 request := &BatchRequest{Operation: operation, Transfers: c.transferNames(), Objects: objects}
request := &BatchRequest{operation, c.transferNames(), nil, objects} if ref != "" {
request.Ref = &Reference{Name: ref}
}
payload := new(bytes.Buffer) payload := new(bytes.Buffer)
err := json.NewEncoder(payload).Encode(request) err := json.NewEncoder(payload).Encode(request)
@@ -113,17 +113,17 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
} }
// Download reads the specific LFS object from the LFS server // Download reads the specific LFS object from the LFS server
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { func (c *HTTPClient) Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error {
return c.performOperation(ctx, objects, callback, nil) return c.performOperation(ctx, ref, objects, callback, nil)
} }
// Upload sends the specific LFS object to the LFS server // Upload sends the specific LFS object to the LFS server
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error { func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
return c.performOperation(ctx, objects, nil, callback) return c.performOperation(ctx, "", objects, nil, callback)
} }
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch // performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { func (c *HTTPClient) performOperation(ctx context.Context, ref string, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
if len(objects) == 0 { if len(objects) == 0 {
return nil return nil
} }
@@ -133,7 +133,7 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
operation = "upload" operation = "upload"
} }
result, err := c.batch(ctx, operation, objects) result, err := c.batch(ctx, operation, ref, objects)
if err != nil { if err != nil {
return err return err
} }
+3 -1
View File
@@ -175,6 +175,7 @@ func TestHTTPClientDownload(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "download", batchRequest.Operation) assert.Equal(t, "download", batchRequest.Operation)
assert.Equal(t, &Reference{Name: "refs/heads/main"}, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1) assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid) assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size) assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
@@ -248,7 +249,7 @@ func TestHTTPClientDownload(t *testing.T) {
}, },
} }
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error { err := client.Download(t.Context(), "refs/heads/main", []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
if objectError != nil { if objectError != nil {
return objectError return objectError
} }
@@ -283,6 +284,7 @@ func TestHTTPClientUpload(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "upload", batchRequest.Operation) assert.Equal(t, "upload", batchRequest.Operation)
assert.Nil(t, batchRequest.Ref)
assert.Len(t, batchRequest.Objects, 1) assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid) assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size) assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
+3 -2
View File
@@ -153,8 +153,6 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
issueIndexPatternProcessor, issueIndexPatternProcessor,
commitCrossReferencePatternProcessor, commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor, hashCurrentPatternProcessor,
emojiShortCodeProcessor,
emojiProcessor,
linkProcessor, linkProcessor,
} }
@@ -175,6 +173,9 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
node.DataAtom = atom.A node.DataAtom = atom.A
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}} node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
node.FirstChild, node.LastChild = ch, ch node.FirstChild, node.LastChild = ch, ch
for n := node.FirstChild; n != nil; {
n = visitNode(ctx, emojiProcessors, n)
}
}) })
rendered := postProcessHTML(ctx, procs, htmlutil.EscapeString(content)) rendered := postProcessHTML(ctx, procs, htmlutil.EscapeString(content))
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered) return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
+13 -2
View File
@@ -4,17 +4,28 @@
package optional package optional
import ( import (
"reflect"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
func (o *Option[T]) assignUnmarshalledValue(v *T) {
if reflect.TypeFor[T]().Kind() == reflect.Pointer && v == nil {
var zero T
*o = Some[T](zero)
} else {
*o = FromPtr(v)
}
}
func (o *Option[T]) UnmarshalJSON(data []byte) error { func (o *Option[T]) UnmarshalJSON(data []byte) error {
var v *T var v *T
if err := json.Unmarshal(data, &v); err != nil { if err := json.Unmarshal(data, &v); err != nil {
return err return err
} }
*o = FromPtr(v) o.assignUnmarshalledValue(v)
return nil return nil
} }
@@ -31,7 +42,7 @@ func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&v); err != nil { if err := value.Decode(&v); err != nil {
return err return err
} }
*o = FromPtr(v) o.assignUnmarshalledValue(v)
return nil return nil
} }
+35
View File
@@ -6,6 +6,7 @@ package optional_test
import ( import (
std_json "encoding/json" //nolint:depguard // for testing purpose std_json "encoding/json" //nolint:depguard // for testing purpose
"testing" "testing"
"time"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
@@ -28,6 +29,14 @@ type testSerializationStruct struct {
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"` OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
} }
type testSerializationNullValue struct {
OptString optional.Option[string] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
type testSerializationNullPointer struct {
OptNull optional.Option[*time.Time] `json:"opt_null,omitempty" yaml:"opt_null,omitempty"`
}
func TestOptionalToJson(t *testing.T) { func TestOptionalToJson(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -63,6 +72,32 @@ func TestOptionalToJson(t *testing.T) {
assert.Equal(t, tc.want, string(b), "std json module returned unexpected") assert.Equal(t, tc.want, string(b), "std json module returned unexpected")
}) })
} }
t.Run("NullValue", func(t *testing.T) {
var v1 testSerializationNullValue
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.False(t, v1.OptString.Has())
})
t.Run("NullPointer", func(t *testing.T) {
var v1 testSerializationNullPointer
err := json.Unmarshal([]byte(`{"opt_null":null}`), &v1)
assert.NoError(t, err)
assert.True(t, v1.OptNull.Has())
assert.Nil(t, v1.OptNull.Value())
var v2 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{}`), &v2)
assert.NoError(t, err)
assert.False(t, v2.OptNull.Has())
var v3 testSerializationNullPointer
err = std_json.Unmarshal([]byte(`{"opt_null":"2006-01-02T15:04:05Z"}`), &v3)
assert.NoError(t, err)
assert.True(t, v3.OptNull.Has())
assert.NotNil(t, v3.OptNull.Value())
})
} }
func TestOptionalFromJson(t *testing.T) { func TestOptionalFromJson(t *testing.T) {
+2
View File
@@ -15,6 +15,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"gitea.dev/modules/charset"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/modules/validation" "gitea.dev/modules/validation"
@@ -261,6 +262,7 @@ func ParsePackage(r ReadSeekAt, optVersion ...string) (*PackageInfo, error) {
cj.Readme = "README.md" cj.Readme = "README.md"
} }
dataReadmeMd, _ := readPackageFile(pkgExt, r, cj.Readme, 10*1024) dataReadmeMd, _ := readPackageFile(pkgExt, r, cj.Readme, 10*1024)
dataReadmeMd = charset.ToUTF8DropErrors(dataReadmeMd)
// FIXME: legacy problem, the "Readme" field is abused, it should always be the path to the readme file // FIXME: legacy problem, the "Readme" field is abused, it should always be the path to the readme file
if len(dataReadmeMd) == 0 { if len(dataReadmeMd) == 0 {
+19 -42
View File
@@ -4,8 +4,6 @@
package composer package composer
import ( import (
"archive/tar"
"archive/zip"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"io" "io"
@@ -13,6 +11,7 @@ import (
"testing" "testing"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/test"
"github.com/dsnet/compress/bzip2" "github.com/dsnet/compress/bzip2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -76,37 +75,8 @@ func TestCommentsUnmarshal(t *testing.T) {
} }
func TestParsePackage(t *testing.T) { func TestParsePackage(t *testing.T) {
createArchive := func(files map[string]string) []byte {
var buf bytes.Buffer
archive := zip.NewWriter(&buf)
for name, content := range files {
w, _ := archive.Create(name)
_, _ = w.Write([]byte(content))
}
_ = archive.Close()
return buf.Bytes()
}
createArchiveTar := func(comp func(io.Writer) io.WriteCloser, files map[string]string) []byte {
var buf bytes.Buffer
w := comp(&buf)
archive := tar.NewWriter(w)
for name, content := range files {
hdr := &tar.Header{
Name: name,
Mode: 0o600,
Size: int64(len(content)),
}
_ = archive.WriteHeader(hdr)
_, _ = archive.Write([]byte(content))
}
_ = w.Close()
_ = archive.Close()
return buf.Bytes()
}
t.Run("MissingComposerFile", func(t *testing.T) { t.Run("MissingComposerFile", func(t *testing.T) {
data := createArchive(map[string]string{"dummy.txt": ""}) data := test.WriteZipArchive(map[string]string{"dummy.txt": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp) assert.Nil(t, cp)
@@ -114,7 +84,7 @@ func TestParsePackage(t *testing.T) {
}) })
t.Run("MissingComposerFileInRoot", func(t *testing.T) { t.Run("MissingComposerFileInRoot", func(t *testing.T) {
data := createArchive(map[string]string{"sub/sub/composer.json": ""}) data := test.WriteZipArchive(map[string]string{"sub/sub/composer.json": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp) assert.Nil(t, cp)
@@ -122,7 +92,7 @@ func TestParsePackage(t *testing.T) {
}) })
t.Run("InvalidComposerFile", func(t *testing.T) { t.Run("InvalidComposerFile", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": ""}) data := test.WriteZipArchive(map[string]string{"composer.json": ""}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp) assert.Nil(t, cp)
@@ -130,7 +100,7 @@ func TestParsePackage(t *testing.T) {
}) })
t.Run("InvalidPackageName", func(t *testing.T) { t.Run("InvalidPackageName", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": "{}"}) data := test.WriteZipArchive(map[string]string{"composer.json": "{}"}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp) assert.Nil(t, cp)
@@ -138,7 +108,7 @@ func TestParsePackage(t *testing.T) {
}) })
t.Run("InvalidPackageVersion", func(t *testing.T) { t.Run("InvalidPackageVersion", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "version": "1.a.3"}`}) data := test.WriteZipArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "version": "1.a.3"}`}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.Nil(t, cp) assert.Nil(t, cp)
@@ -146,7 +116,7 @@ func TestParsePackage(t *testing.T) {
}) })
t.Run("InvalidReadmePath", func(t *testing.T) { t.Run("InvalidReadmePath", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "readme": "sub/README.md"}`}) data := test.WriteZipArchive(map[string]string{"composer.json": `{"name": "gitea/composer-package", "readme": "sub/README.md"}`}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data)) cp, err := ParsePackage(bytes.NewReader(data))
assert.NoError(t, err) assert.NoError(t, err)
@@ -177,22 +147,29 @@ func TestParsePackage(t *testing.T) {
} }
t.Run("ValidZip", func(t *testing.T) { t.Run("ValidZip", func(t *testing.T) {
data := createArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}) data := test.WriteZipArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}).Bytes()
assertValidPackage(t, data, "", "gitea-composer-package.zip") assertValidPackage(t, data, "", "gitea-composer-package.zip")
}) })
t.Run("ValidTarBz2", func(t *testing.T) { t.Run("ValidTarBz2", func(t *testing.T) {
data := createArchiveTar(func(w io.Writer) io.WriteCloser { data := test.WriteTarCompression(func(w io.Writer) io.WriteCloser {
bz2Writer, _ := bzip2.NewWriter(w, nil) bz2Writer, _ := bzip2.NewWriter(w, nil)
return bz2Writer return bz2Writer
}, map[string]string{"composer.json": buildComposerContent("1.0"), "README.md": readme}) }, map[string]string{"composer.json": buildComposerContent("1.0"), "README.md": readme}).Bytes()
assertValidPackage(t, data, "1.0", "gitea-composer-package.1.0.tar.bz2") assertValidPackage(t, data, "1.0", "gitea-composer-package.1.0.tar.bz2")
}) })
t.Run("ValidTarGz", func(t *testing.T) { t.Run("ValidTarGz", func(t *testing.T) {
data := createArchiveTar(func(w io.Writer) io.WriteCloser { data := test.WriteTarCompression(func(w io.Writer) io.WriteCloser {
return gzip.NewWriter(w) return gzip.NewWriter(w)
}, map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}) }, map[string]string{"composer.json": buildComposerContent(""), "README.md": readme}).Bytes()
assertValidPackage(t, data, "", "gitea-composer-package.tar.gz") assertValidPackage(t, data, "", "gitea-composer-package.tar.gz")
}) })
t.Run("ReadmeEncoding", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{"composer.json": buildComposerContent(""), "README.md": "🌞🌞"[:6]}).Bytes()
cp, err := ParsePackage(bytes.NewReader(data))
require.NoError(t, err)
assert.Equal(t, "🌞 ", cp.Metadata.Readme)
})
} }
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"strings" "strings"
"gitea.dev/modules/packages"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/modules/validation" "gitea.dev/modules/validation"
@@ -69,6 +70,8 @@ type Dependency struct {
Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
} }
const maxArchiveSize = 256 * 1024 * 1024 // tar members are inflated while looking for Chart.yaml
// ParseChartArchive parses the metadata of a Helm archive // ParseChartArchive parses the metadata of a Helm archive
func ParseChartArchive(r io.Reader) (*Metadata, error) { func ParseChartArchive(r io.Reader) (*Metadata, error) {
gzr, err := gzip.NewReader(r) gzr, err := gzip.NewReader(r)
@@ -77,7 +80,7 @@ func ParseChartArchive(r io.Reader) (*Metadata, error) {
} }
defer gzr.Close() defer gzr.Close()
tr := tar.NewReader(gzr) tr := tar.NewReader(packages.NewLimitedReader(gzr, maxArchiveSize))
for { for {
hd, err := tr.Next() hd, err := tr.Next()
if err == io.EOF { if err == io.EOF {
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package packages
import (
"io"
"gitea.dev/modules/util"
)
var ErrContentTooLarge = util.NewInvalidArgumentErrorf("content exceeds the size limit")
type limitedReader struct {
io.LimitedReader
}
// NewLimitedReader returns a reader that fails with ErrContentTooLarge once it reads a byte past limit
func NewLimitedReader(r io.Reader, limit int64) io.Reader {
return &limitedReader{io.LimitedReader{R: r, N: limit + 1}}
}
func (l *limitedReader) Read(p []byte) (int, error) {
n, err := l.LimitedReader.Read(p)
if l.N == 0 {
return max(n-1, 0), ErrContentTooLarge
}
return n, err
}
+17
View File
@@ -8,6 +8,8 @@ import (
"compress/gzip" "compress/gzip"
"io" "io"
"regexp" "regexp"
"slices"
"strings"
"sync" "sync"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -23,6 +25,8 @@ var (
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
// ErrInvalidVersion indicates an invalid version in the metadata.gz file // ErrInvalidVersion indicates an invalid version in the metadata.gz file
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
// ErrInvalidMetadata indicates metadata that would corrupt the generated index
ErrInvalidMetadata = util.NewInvalidArgumentErrorf("package metadata is invalid")
) )
var globalVars = sync.OnceValue(func() (ret struct { var globalVars = sync.OnceValue(func() (ret struct {
@@ -220,6 +224,19 @@ func parseMetadataFile(r io.Reader) (*Package, error) {
} }
} }
values := []string{m.Platform}
reqs := slices.Concat(m.RequiredRubyVersion, m.RequiredRubygemsVersion)
for _, dep := range slices.Concat(m.RuntimeDependencies, m.DevelopmentDependencies) {
values = append(values, dep.Name)
reqs = append(reqs, dep.Version...)
}
for _, req := range reqs {
values = append(values, req.Restriction, req.Version)
}
if slices.ContainsFunc(values, func(s string) bool { return strings.ContainsAny(s, "\r\n") }) {
return nil, ErrInvalidMetadata // the compact index is line-based, a line break would forge entries
}
return &Package{ return &Package{
Name: spec.Name, Name: spec.Name,
Version: spec.Version.Version, Version: spec.Version.Version,
@@ -43,6 +43,19 @@ version:
assert.Nil(t, rp) assert.Nil(t, rp)
} }
}) })
t.Run("LineBreakInIndexedMetadata", func(t *testing.T) {
for _, field := range []string{
`platform: "ruby\nx"`,
`dependencies: [{name: "d\nx"}]`,
`dependencies: [{name: d, requirement: {requirements: [[">=", {version: "1\nx"}]]}}]`,
`required_ruby_version: {requirements: [[">=", {version: "1\nx"}]]}`,
} {
rp, err := parseMetadataFile(test.CompressGzip("name: g\nversion:\n version: 1\n" + field + "\n"))
assert.ErrorIs(t, err, ErrInvalidMetadata, field)
assert.Nil(t, rp)
}
})
} }
func TestParseMetadataFile(t *testing.T) { func TestParseMetadataFile(t *testing.T) {
+1 -1
View File
@@ -35,7 +35,7 @@ func CanUserForkRepo(ctx context.Context, user *user_model.User, repo *repo_mode
return false, err return false, err
} }
for _, org := range ownedOrgs { for _, org := range ownedOrgs {
if repo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, repo.ID) { if CanUserForkBetweenOwners(repo.OwnerID, org.ID) && !repo_model.HasForkedRepo(ctx, org.ID, repo.ID) {
return true, nil return true, nil
} }
} }
+28 -1
View File
@@ -6,6 +6,10 @@ package repository
import ( import (
"testing" "testing"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
@@ -13,7 +17,7 @@ import (
) )
func TestCanUserForkBetweenOwners(t *testing.T) { func TestCanUserForkBetweenOwners(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.AllowForkIntoSameOwner) defer test.MockVariableValue(&setting.Repository.AllowForkIntoSameOwner)()
setting.Repository.AllowForkIntoSameOwner = true setting.Repository.AllowForkIntoSameOwner = true
assert.True(t, CanUserForkBetweenOwners(1, 1)) assert.True(t, CanUserForkBetweenOwners(1, 1))
@@ -23,3 +27,26 @@ func TestCanUserForkBetweenOwners(t *testing.T) {
assert.False(t, CanUserForkBetweenOwners(1, 1)) assert.False(t, CanUserForkBetweenOwners(1, 1))
assert.True(t, CanUserForkBetweenOwners(1, 2)) assert.True(t, CanUserForkBetweenOwners(1, 2))
} }
func TestCanUserForkRepoIntoOwningOrg(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Repository.AllowForkIntoSameOwner)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
// user2's own fork leaves org3, the owner, as the only target
assert.NoError(t, db.Insert(t.Context(), &repo_model.Repository{
OwnerID: 2, Name: "repo3-fork", LowerName: "repo3-fork",
IsFork: true, ForkID: 3,
}))
setting.Repository.AllowForkIntoSameOwner = true
ok, err := CanUserForkRepo(t.Context(), user2, repo3)
assert.NoError(t, err)
assert.True(t, ok)
setting.Repository.AllowForkIntoSameOwner = false
ok, err = CanUserForkRepo(t.Context(), user2, repo3)
assert.NoError(t, err)
assert.False(t, ok)
}
+6 -1
View File
@@ -67,8 +67,13 @@ func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *repo_model.Re
errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan) errChan <- lfs.SearchPointerBlobs(ctx, gitRepo, pointerChan)
}() }()
// some servers require `ref`, send the default branch for objects from all refs like
// `git lfs fetch --all` does on the main branch: it uses current branch as `ref`:
// https://github.com/git-lfs/git-lfs/blob/58a763d09d5fe4b7ea85c89ec799d71c5b0dc1da/commands/commands.go#L124-L137
// `man git-lfs-fetch` said that "--all" can fetch all objects, so the default branch should work
ref := git.RefNameFromBranch(repo.DefaultBranch).String()
downloadObjects := func(pointers []lfs.Pointer) error { downloadObjects := func(pointers []lfs.Pointer) error {
err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { err := lfsClient.Download(ctx, ref, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
if errors.Is(objectError, lfs.ErrObjectNotExist) { if errors.Is(objectError, lfs.ErrObjectNotExist) {
log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError) log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError)
return nil return nil
+1
View File
@@ -89,6 +89,7 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.Options = make(map[string]string) GitConfig.Options = make(map[string]string)
GitConfig.SetOption("diff.algorithm", "histogram") GitConfig.SetOption("diff.algorithm", "histogram")
GitConfig.SetOption("core.logAllRefUpdates", "true") GitConfig.SetOption("core.logAllRefUpdates", "true")
GitConfig.SetOption("maintenance.strategy", "gc") // git 2.54's push-time default "geometric" delays pushes and repacks concurrently with other writes
secGitReflog := rootCfg.Section("git.reflog") secGitReflog := rootCfg.Section("git.reflog")
if secGitReflog.HasKey("ENABLED") { if secGitReflog.HasKey("ENABLED") {
+1
View File
@@ -27,6 +27,7 @@ a.b = 1
loadGitFrom(cfg) loadGitFrom(cfg)
assert.Equal(t, "1", GitConfig.Options["a.b"]) assert.Equal(t, "1", GitConfig.Options["a.b"])
assert.Equal(t, "histogram", GitConfig.Options["diff.algorithm"]) assert.Equal(t, "histogram", GitConfig.Options["diff.algorithm"])
assert.Equal(t, "gc", GitConfig.Options["maintenance.strategy"])
cfg, err = NewConfigProviderFromData(` cfg, err = NewConfigProviderFromData(`
[git.config] [git.config]
+3 -1
View File
@@ -56,7 +56,9 @@ type EditLabelOption struct {
// IssueLabelsOption a collection of labels // IssueLabelsOption a collection of labels
type IssueLabelsOption struct { type IssueLabelsOption struct {
// Labels can be a list of integers representing label IDs // Labels can be a list of integers representing label IDs
// or a list of strings representing label names // or a list of strings representing label names.
// If a repository label and an organization label share a name,
// the repository label is used.
Labels []any `json:"labels"` Labels []any `json:"labels"`
} }
+5 -2
View File
@@ -5,6 +5,8 @@ package structs
import ( import (
"time" "time"
"gitea.dev/modules/optional"
) )
// Milestone milestone is a collection of issues on one repository // Milestone milestone is a collection of issues on one repository
@@ -54,6 +56,7 @@ type EditMilestoneOption struct {
// State indicates the updated state of the milestone // State indicates the updated state of the milestone
// enum: ["open","closed"] // enum: ["open","closed"]
State *string `json:"state"` State *string `json:"state"`
// Deadline is the updated due date for the milestone // Deadline is the due date for the milestone, use null or empty string to remove the deadline
Deadline *time.Time `json:"due_on"` // swagger:strfmt date-time
Deadline optional.Option[*string] `json:"due_on,omitzero"`
} }
+17
View File
@@ -158,6 +158,23 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
}) })
t.Run("RenderCommitMessageLinkSubjectEmoji", func(t *testing.T) {
defer test.MockVariableValue(&setting.UI.CustomEmojisMap, map[string]string{"custom-emoji": ":custom-emoji:"})()
for _, tt := range []struct {
name, subject, content string
}{
{"native", "Bug ❌ Feature ✅", `Bug <span class="emoji" data-alias="x">❌</span> Feature <span class="emoji" data-alias="white_check_mark">✅</span>`},
{"shortcode", "Ship :+1: today", `Ship <span class="emoji" data-alias="+1">👍</span> today`},
{"emoji only", "👍", `<span class="emoji" data-alias="+1">👍</span>`},
{"custom", "Ship :custom-emoji:", `Ship <span class="emoji"><img alt=":custom-emoji:" src="/assets/img/emoji/custom-emoji.png"/></span>`},
} {
t.Run(tt.name, func(t *testing.T) {
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">` + tt.content + `</a></span>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(tt.subject, "https://example.com/link", mockRepo))
})
}
})
t.Run("RenderIssueTitle", func(t *testing.T) { t.Run("RenderIssueTitle", func(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
expected := ` space @mention-user<SPACE><SPACE> expected := ` space @mention-user<SPACE><SPACE>
+1 -1
View File
@@ -3556,7 +3556,7 @@
"units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.", "units.error.no_unit_allowed_repo": "You are not allowed to access any section of this repository.",
"units.error.unit_not_allowed": "You are not allowed to access this repository section.", "units.error.unit_not_allowed": "You are not allowed to access this repository section.",
"packages.title": "Packages", "packages.title": "Packages",
"packages.desc": "Manage repository packages.", "packages.desc": "Access all packages of the organization, regardless of the team's repositories.",
"packages.empty": "There are no packages yet.", "packages.empty": "There are no packages yet.",
"packages.no_metadata": "No metadata.", "packages.no_metadata": "No metadata.",
"packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.", "packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
+5
View File
@@ -34,6 +34,11 @@ export default defineConfig({
name: 'firefox', name: 'firefox',
use: { use: {
...devices['Desktop Firefox'], ...devices['Desktop Firefox'],
launchOptions: {
firefoxUserPrefs: {
'dom.events.testing.asyncClipboard': true, // disable paste prompt
},
},
}, },
}, },
], ],
+1 -1
View File
@@ -281,7 +281,7 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) {
artifact.FileCompressedSize != chunksTotalSize { artifact.FileCompressedSize != chunksTotalSize {
artifact.FileSize = fileRealTotalSize artifact.FileSize = fileRealTotalSize
artifact.FileCompressedSize = chunksTotalSize artifact.FileCompressedSize = chunksTotalSize
artifact.ContentEncodingOrType = ctx.Req.Header.Get("Content-Encoding") artifact.ContentEncodingOrType = util.Iif(ctx.Req.Header.Get("Content-Encoding") == actions.ContentEncodingV3Gzip, actions.ContentEncodingV3Gzip, "") // other values could be served as a v4 Content-Type
if err := actions.UpdateArtifact(ctx, artifact, "file_size", "file_compressed_size", "content_encoding"); err != nil { if err := actions.UpdateArtifact(ctx, artifact, "file_size", "file_compressed_size", "content_encoding"); err != nil {
log.Error("Error update artifact: %v", err) log.Error("Error update artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error update artifact") ctx.HTTPError(http.StatusInternalServerError, "Error update artifact")
+19 -1
View File
@@ -5,6 +5,7 @@ package runner
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"strings" "strings"
"time" "time"
@@ -17,6 +18,7 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"connectrpc.com/connect" "connectrpc.com/connect"
lru "github.com/hashicorp/golang-lru/v2"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@@ -26,6 +28,10 @@ const (
tokenHeaderKey = protocol.TokenHeader tokenHeaderKey = protocol.TokenHeader
) )
type runnerTokenHash struct{ salt, hash string }
var verifiedRunnerTokens, _ = lru.New[runnerTokenHash, [sha256.Size]byte](10000) // not auth_model.TokenCache, whose small default size thrashes once more runners poll in turn
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
methodName := getMethodName(request) methodName := getMethodName(request)
@@ -42,7 +48,7 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
} }
return nil, status.Error(codes.Internal, err.Error()) return nil, status.Error(codes.Internal, err.Error())
} }
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { if !verifyRunnerToken(runner, token) {
return nil, status.Error(codes.Unauthenticated, "unregistered runner") return nil, status.Error(codes.Unauthenticated, "unregistered runner")
} }
@@ -73,6 +79,18 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar
} }
})) }))
func verifyRunnerToken(runner *actions_model.ActionRunner, token string) bool {
key, digest := runnerTokenHash{runner.TokenSalt, runner.TokenHash}, sha256.Sum256([]byte(token))
if cached, ok := verifiedRunnerTokens.Get(key); ok && util.CryptoConstTimeEqual(cached[:], digest[:]) {
return true
}
if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) {
return false
}
verifiedRunnerTokens.Add(key, digest)
return true
}
func getMethodName(req connect.AnyRequest) string { func getMethodName(req connect.AnyRequest) string {
splits := strings.Split(req.Spec().Procedure, "/") splits := strings.Split(req.Spec().Procedure, "/")
if len(splits) > 0 { if len(splits) > 0 {
@@ -0,0 +1,26 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"crypto/sha256"
"testing"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"github.com/stretchr/testify/assert"
)
func TestVerifyRunnerToken(t *testing.T) {
verifiedRunnerTokens.Purge()
runner := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("token", "salt")}
assert.True(t, verifyRunnerToken(runner, "token"))
assert.True(t, verifyRunnerToken(runner, "token"))
assert.False(t, verifyRunnerToken(runner, "wrong"))
other := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("other", "salt")}
assert.False(t, verifyRunnerToken(other, "token"))
assert.Equal(t, [][sha256.Size]byte{sha256.Sum256([]byte("token"))}, verifiedRunnerTokens.Values())
}
+1 -1
View File
@@ -244,7 +244,7 @@ func PostBlobsUploads(ctx *context.Context) {
Digest: mount, Digest: mount,
}) })
if blob != nil { if blob != nil {
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer) accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer, context.TokenIsPublicOnly(ctx))
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
return return
+1 -1
View File
@@ -481,7 +481,7 @@ func SearchUsers(ctx *context.APIContext) {
searchOpts := user_model.SearchUserOptions{ searchOpts := user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual}, Types: user_model.AdminListedUserTypes,
LoginName: ctx.FormTrim("login_name"), LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"), SourceID: ctx.FormInt64("source_id"),
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
+46 -30
View File
@@ -317,8 +317,14 @@ func rejectPublicOnly() func(ctx *context.APIContext) {
} }
} }
func contextAuthenticatedUser() func(ctx *context.APIContext) { func authenticatedUserAsContextUser(allowSystemUser bool) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
isIndividualOrBot := ctx.Doer.IsIndividual() || ctx.Doer.IsTypeBot()
isSystem := ctx.Doer.ID < 0
if !isIndividualOrBot || isSystem && !allowSystemUser {
ctx.APIError(http.StatusForbidden, "this endpoint is only available for individual user or bot accounts")
return
}
ctx.ContextUser = ctx.Doer ctx.ContextUser = ctx.Doer
} }
} }
@@ -492,6 +498,9 @@ func reqAnyRepoReader() func(ctx *context.APIContext) {
// reqOrgOwnership user should be an organization owner, or a site admin // reqOrgOwnership user should be an organization owner, or a site admin
func reqOrgOwnership() func(ctx *context.APIContext) { func reqOrgOwnership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
if rejectPublicOnly()(ctx); ctx.Written() { // org ownership reaches the org's private repos (hooks, teams, secrets)
return
}
if ctx.IsUserSiteAdmin() { if ctx.IsUserSiteAdmin() {
return return
} }
@@ -876,6 +885,10 @@ func mustEnableRepoProjects(ctx *context.APIContext) {
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor // FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
func mustNotBeArchived(ctx *context.APIContext) { func mustNotBeArchived(ctx *context.APIContext) {
switch ctx.Req.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return
}
if ctx.Repo.Repository.IsArchived { if ctx.Repo.Repository.IsArchived {
ctx.APIError(http.StatusLocked, "repo is archived") ctx.APIError(http.StatusLocked, "repo is archived")
return return
@@ -1155,8 +1168,11 @@ func Routes() *web.Router {
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
// Users (requires user scope) // Users (requires user scope)
contextRealDoerUser := authenticatedUserAsContextUser(false)
contextRealOrSystemUser := authenticatedUserAsContextUser(true)
m.Get("/user", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextRealOrSystemUser, checkTokenPublicOnly(), user.GetAuthenticatedUser)
m.Group("/user", func() { m.Group("/user", func() {
m.Get("", user.GetAuthenticatedUser) m.Get("/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
m.Group("/settings", func() { m.Group("/settings", func() {
m.Get("", user.GetUserSettings) m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
@@ -1276,13 +1292,14 @@ func Routes() *web.Router {
m.Delete("", user.UnblockUser) m.Delete("", user.UnblockUser)
}, context.UserAssignmentAPI(), checkTokenPublicOnly()) }, context.UserAssignmentAPI(), checkTokenPublicOnly())
}, rejectPublicOnly()) }, rejectPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextRealDoerUser, checkTokenPublicOnly())
// Repositories (requires repo scope, org scope) // Repositories (requires repo scope, org scope)
m.Post("/org/{org}/repos", m.Post("/org/{org}/repos",
// FIXME: we need org in context // FIXME: we need org in context
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
reqToken(), reqToken(),
rejectPublicOnly(),
bind(api.CreateRepoOption{}), bind(api.CreateRepoOption{}),
repo.CreateOrgRepoDeprecated) repo.CreateOrgRepoDeprecated)
@@ -1303,7 +1320,7 @@ func Routes() *web.Router {
m.Combo("").Get(reqAnyRepoReader(), repo.Get). m.Combo("").Get(reqAnyRepoReader(), repo.Get).
Delete(reqToken(), reqRepoDangerZone(), repo.Delete). Delete(reqToken(), reqRepoDangerZone(), repo.Delete).
Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit)
m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) m.Post("/generate", reqToken(), rejectPublicOnly(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate)
m.Group("/transfer", func() { m.Group("/transfer", func() {
m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer) m.Post("", reqRepoDangerZone(), bind(api.TransferRepoOption{}), repo.Transfer)
m.Post("/accept", repo.AcceptTransfer) m.Post("/accept", repo.AcceptTransfer)
@@ -1483,7 +1500,7 @@ func Routes() *web.Router {
Get(repo.GetReleaseByTag). Get(repo.GetReleaseByTag).
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
}) })
}, reqRepoReader(unit.TypeReleases)) }, reqRepoReader(unit.TypeReleases), mustNotBeArchived)
m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync)
m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync)
m.Group("/push_mirrors", func() { m.Group("/push_mirrors", func() {
@@ -1630,14 +1647,14 @@ func Routes() *web.Router {
m.Group("/{username}/{reponame}", func() { m.Group("/{username}/{reponame}", func() {
m.Group("/issues", func() { m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues). m.Combo("").Get(repo.ListIssues).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue) Post(reqToken(), bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue)
m.Get("/pinned", reqRepoReader(unit.TypeIssues), repo.ListPinnedIssues) m.Get("/pinned", reqRepoReader(unit.TypeIssues), repo.ListPinnedIssues)
m.Group("/comments", func() { m.Group("/comments", func() {
m.Get("", repo.ListRepoIssueComments) m.Get("", repo.ListRepoIssueComments)
m.Group("/{id}", func() { m.Group("/{id}", func() {
m.Combo(""). m.Combo("").
Get(repo.GetIssueComment). Get(repo.GetIssueComment).
Patch(mustNotBeArchived, reqToken(), bind(api.EditIssueCommentOption{}), repo.EditIssueComment). Patch(reqToken(), bind(api.EditIssueCommentOption{}), repo.EditIssueComment).
Delete(reqToken(), repo.DeleteIssueComment) Delete(reqToken(), repo.DeleteIssueComment)
m.Combo("/reactions"). m.Combo("/reactions").
Get(repo.GetIssueCommentReactions). Get(repo.GetIssueCommentReactions).
@@ -1646,35 +1663,35 @@ func Routes() *web.Router {
m.Group("/assets", func() { m.Group("/assets", func() {
m.Combo(""). m.Combo("").
Get(repo.ListIssueCommentAttachments). Get(repo.ListIssueCommentAttachments).
Post(reqToken(), mustNotBeArchived, repo.CreateIssueCommentAttachment) Post(reqToken(), repo.CreateIssueCommentAttachment)
m.Combo("/{attachment_id}"). m.Combo("/{attachment_id}").
Get(repo.GetIssueCommentAttachment). Get(repo.GetIssueCommentAttachment).
Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). Patch(reqToken(), bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment).
Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueCommentAttachment) Delete(reqToken(), repo.DeleteIssueCommentAttachment)
}, mustEnableAttachments) }, mustEnableAttachments)
}) })
}) })
m.Group("/{index}", func() { m.Group("/{index}", func() {
m.Combo("").Get(repo.GetIssue). m.Combo("").Get(repo.GetIssue).
Patch(reqToken(), mustNotBeArchived, bind(api.EditIssueOption{}), repo.EditIssue). Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue).
Delete(reqToken(), reqAdmin(), mustNotBeArchived, context.ReferencesGitRepo(), repo.DeleteIssue) Delete(reqToken(), reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue)
m.Combo("/assignees"). m.Combo("/assignees").
Post(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.AddIssueAssignees). Post(reqToken(), bind(api.IssueAssigneesOption{}), repo.AddIssueAssignees).
Delete(reqToken(), mustNotBeArchived, bind(api.IssueAssigneesOption{}), repo.DeleteIssueAssignees) Delete(reqToken(), bind(api.IssueAssigneesOption{}), repo.DeleteIssueAssignees)
m.Get("/assignees/{assignee}", repo.CheckIssueAssignee) m.Get("/assignees/{assignee}", repo.CheckIssueAssignee)
m.Group("/comments", func() { m.Group("/comments", func() {
m.Combo("").Get(repo.ListIssueComments). m.Combo("").Get(repo.ListIssueComments).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) Post(reqToken(), bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment)
m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated).
Delete(repo.DeleteIssueCommentDeprecated) Delete(repo.DeleteIssueCommentDeprecated)
}) })
m.Get("/timeline", repo.ListIssueCommentsAndTimeline) m.Get("/timeline", repo.ListIssueCommentsAndTimeline)
m.Group("/labels", func() { m.Group("/labels", func() {
m.Combo("").Get(repo.ListIssueLabels). m.Combo("").Get(repo.ListIssueLabels).
Post(reqToken(), mustNotBeArchived, bind(api.IssueLabelsOption{}), repo.AddIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels).
Put(reqToken(), mustNotBeArchived, bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels).
Delete(reqToken(), mustNotBeArchived, repo.ClearIssueLabels) Delete(reqToken(), repo.ClearIssueLabels)
m.Delete("/{id}", reqToken(), mustNotBeArchived, repo.DeleteIssueLabel) m.Delete("/{id}", reqToken(), repo.DeleteIssueLabel)
}) })
m.Group("/times", func() { m.Group("/times", func() {
m.Combo(""). m.Combo("").
@@ -1702,16 +1719,16 @@ func Routes() *web.Router {
m.Group("/assets", func() { m.Group("/assets", func() {
m.Combo(""). m.Combo("").
Get(repo.ListIssueAttachments). Get(repo.ListIssueAttachments).
Post(reqToken(), mustNotBeArchived, repo.CreateIssueAttachment) Post(reqToken(), repo.CreateIssueAttachment)
m.Combo("/{attachment_id}"). m.Combo("/{attachment_id}").
Get(repo.GetIssueAttachment). Get(repo.GetIssueAttachment).
Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). Patch(reqToken(), bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment).
Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueAttachment) Delete(reqToken(), repo.DeleteIssueAttachment)
}, mustEnableAttachments) }, mustEnableAttachments)
m.Combo("/dependencies"). m.Combo("/dependencies").
Get(repo.GetIssueDependencies). Get(repo.GetIssueDependencies).
Post(reqToken(), mustNotBeArchived, bind(api.IssueMeta{}), repo.CreateIssueDependency). Post(reqToken(), bind(api.IssueMeta{}), repo.CreateIssueDependency).
Delete(reqToken(), mustNotBeArchived, bind(api.IssueMeta{}), repo.RemoveIssueDependency) Delete(reqToken(), bind(api.IssueMeta{}), repo.RemoveIssueDependency)
m.Combo("/blocks"). m.Combo("/blocks").
Get(repo.GetIssueBlocks). Get(repo.GetIssueBlocks).
Post(reqToken(), bind(api.IssueMeta{}), repo.CreateIssueBlocking). Post(reqToken(), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
@@ -1744,9 +1761,9 @@ func Routes() *web.Router {
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
}) })
m.Group("/projects", func() { m.Group("/projects", func() {
addProjectRoutes(m, reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived) addProjectRoutes(m, reqToken(), reqRepoWriter(unit.TypeProjects))
}, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects) }, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects)
}, repoAssignment(), checkTokenPublicOnly()) }, repoAssignment(), checkTokenPublicOnly(), mustNotBeArchived)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
// NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs
@@ -1772,12 +1789,11 @@ func Routes() *web.Router {
}, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly())
// Organizations // Organizations
m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), checkTokenPublicOnly(), org.ListMyOrgs)
m.Group("/users/{username}/orgs", func() { m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs) m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker)
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), rejectPublicOnly(), bind(api.CreateOrgOption{}), org.Create)
m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll) m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll)
m.Group("/orgs/{org}", func() { m.Group("/orgs/{org}", func() {
m.Combo("").Get(org.Get). m.Combo("").Get(org.Get).
@@ -1785,7 +1801,7 @@ func Routes() *web.Router {
Delete(reqToken(), reqOrgOwnership(), org.Delete) Delete(reqToken(), reqOrgOwnership(), org.Delete)
m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename) m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
m.Combo("/repos").Get(user.ListOrgRepos). m.Combo("/repos").Get(user.ListOrgRepos).
Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), bind(api.CreateRepoOption{}), repo.CreateOrgRepo). Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo).
Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos) Delete(reqToken(), reqOrgOwnership(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), org.DeleteOrgRepos)
m.Group("/members", func() { m.Group("/members", func() {
m.Get("", reqToken(), org.ListMembers) m.Get("", reqToken(), org.ListMembers)
@@ -1921,7 +1937,7 @@ func Routes() *web.Router {
m.Get("/runs", admin.ListWorkflowRuns) m.Get("/runs", admin.ListWorkflowRuns)
m.Get("/jobs", admin.ListWorkflowJobs) m.Get("/jobs", admin.ListWorkflowJobs)
}) })
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin(), rejectPublicOnly())
m.Group("/topics", func() { m.Group("/topics", func() {
m.Get("/search", repo.TopicSearch) m.Get("/search", repo.TopicSearch)
+1 -1
View File
@@ -299,7 +299,7 @@ func GetEditorconfig(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
ec, _, err := ctx.Repo.GetEditorconfig(ctx, ctx.Repo.Commit) ec, _, err := ctx.Repo.GetEditorconfig(ctx)
if err != nil { if err != nil {
ctx.APIErrorAuto(err) ctx.APIErrorAuto(err)
return return
+1 -8
View File
@@ -12,7 +12,6 @@ import (
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access" access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -166,13 +165,7 @@ func CreateFork(ctx *context.APIContext) {
Description: repo.Description, Description: repo.Description,
}) })
if err != nil { if err != nil {
if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { ctx.APIErrorAuto(err)
ctx.APIError(http.StatusConflict, err.Error())
} else if errors.Is(err, user_model.ErrBlockedUser) {
ctx.APIError(http.StatusForbidden, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
+28 -31
View File
@@ -337,8 +337,8 @@ func ListIssues(ctx *context.APIContext) {
keyword := ctx.FormTrim("q") keyword := ctx.FormTrim("q")
var labelIDs []int64 var labelIDs []int64
if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { if labelsParam := ctx.FormTrim("labels"); labelsParam != "" {
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, strings.Split(labelsParam, ","))
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -513,17 +513,12 @@ func GetIssue(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { if err := issue.LoadAttributes(ctx); err != nil {
ctx.APIErrorNotFound() ctx.APIErrorInternal(err)
return return
} }
ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue)) ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue))
@@ -698,19 +693,13 @@ func EditIssue(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
form := web.GetForm[*api.EditIssueOption](ctx) form := web.GetForm[*api.EditIssueOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
issue.Repo = ctx.Repo.Repository
canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
err = issue.LoadAttributes(ctx) err := issue.LoadAttributes(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -901,13 +890,12 @@ func DeleteIssue(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
if err = issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil { if err := issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
@@ -953,13 +941,8 @@ func UpdateIssueDeadline(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm[*api.EditDeadlineOption](ctx) form := web.GetForm[*api.EditDeadlineOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -999,3 +982,17 @@ func closeOrReopenIssue(ctx *context.APIContext, issue *issues_model.Issue, stat
} }
} }
} }
func getRepoIssueByIndexForDoer(ctx *context.APIContext, index int64) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, index)
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
+4 -11
View File
@@ -146,14 +146,8 @@ func CheckIssueAssignee(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return
}
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return return
} }
@@ -187,9 +181,8 @@ func checkAssignableUser(ctx *context.APIContext, assigneeName string, repo *rep
} }
func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) { func updateIssueAssignees(ctx *context.APIContext, opts api.IssueAssigneesOption, isAdd bool) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
+4 -16
View File
@@ -57,7 +57,7 @@ func GetIssueAttachment(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -100,7 +100,7 @@ func ListIssueAttachments(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -163,7 +163,7 @@ func CreateIssueAttachment(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return return
} }
@@ -333,20 +333,8 @@ func DeleteIssueAttachment(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }
func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
issue := getIssueFromContext(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if issue == nil { if issue == nil {
return nil return nil
} }
+12 -47
View File
@@ -5,14 +5,11 @@
package repo package repo
import ( import (
stdCtx "context"
"errors" "errors"
"net/http" "net/http"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
@@ -69,17 +66,10 @@ func ListIssueComments(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return
}
issue.Repo = ctx.Repo.Repository
opts := &issues_model.FindCommentsOptions{ opts := &issues_model.FindCommentsOptions{
IssueID: issue.ID, IssueID: issue.ID,
@@ -173,12 +163,10 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return return
} }
issue.Repo = ctx.Repo.Repository
opts := &issues_model.FindCommentsOptions{ opts := &issues_model.FindCommentsOptions{
ListOptions: utils.GetListOptions(ctx), ListOptions: utils.GetListOptions(ctx),
@@ -198,10 +186,14 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
if comments, err = issue_service.FilterReadableComments(ctx, ctx.Doer, ctx.Repo.Permission, issue, comments); err != nil {
ctx.APIErrorInternal(err)
return
}
var apiComments []*api.TimelineComment var apiComments []*api.TimelineComment
for _, comment := range comments { for _, comment := range comments {
if comment.Type != issues_model.CommentTypeCode && isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID) { if comment.Type != issues_model.CommentTypeCode {
comment.Issue = issue comment.Issue = issue
apiComments = append(apiComments, convert.ToTimelineComment(ctx, issue.Repo, comment, ctx.Doer)) apiComments = append(apiComments, convert.ToTimelineComment(ctx, issue.Repo, comment, ctx.Doer))
} }
@@ -211,26 +203,6 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, &apiComments) ctx.JSON(http.StatusOK, &apiComments)
} }
func isXRefCommentAccessible(ctx stdCtx.Context, user *user_model.User, c *issues_model.Comment, issueRepoID int64) bool {
// Remove comments that the user has no permissions to see
if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issueRepoID && c.RefRepoID != 0 {
var err error
// Set RefRepo for description in template
c.RefRepo, err = repo_model.GetRepositoryByID(ctx, c.RefRepoID)
if err != nil {
return false
}
perm, err := access_model.GetDoerRepoPermission(ctx, c.RefRepo, user)
if err != nil {
return false
}
if !perm.CanReadIssuesOrPulls(c.RefIsPull) {
return false
}
}
return true
}
// ListRepoIssueComments returns all issue-comments for a repo // ListRepoIssueComments returns all issue-comments for a repo
func ListRepoIssueComments(ctx *context.APIContext) { func ListRepoIssueComments(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/issues/comments issue issueGetRepoComments // swagger:operation GET /repos/{owner}/{repo}/issues/comments issue issueGetRepoComments
@@ -381,19 +353,12 @@ func CreateIssueComment(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
form := web.GetForm[*api.CreateIssueCommentOption](ctx) form := web.GetForm[*api.CreateIssueCommentOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorInternal(err)
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { if isIssueLockedForDoer(ctx, issue) {
ctx.APIErrorNotFound()
return
}
if issue.IsLocked && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
ctx.APIError(http.StatusForbidden, ctx.Locale.TrString("repo.issues.comment_on_locked"))
return return
} }
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"testing"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestIssueReadEndpointsRequireUnitPermission(t *testing.T) {
unittest.PrepareTestEnv(t)
for name, handler := range map[string]func(*context.APIContext){
"timeline": ListIssueCommentsAndTimeline,
"labels": ListIssueLabels,
"subscribers": GetIssueSubscribers,
"attachments": ListIssueAttachments,
} {
t.Run(name, func(t *testing.T) {
request := func(index string) int {
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
ctx.Repo.Permission = access_model.Permission{}
ctx.Repo.Permission.SetUnitsWithDefaultAccessMode([]*repo_model.RepoUnit{{Type: unit.TypeIssues}}, perm_model.AccessModeRead)
ctx.SetPathParam("index", index)
handler(ctx)
return resp.Code
}
assert.Equal(t, http.StatusOK, request("1"))
assert.Equal(t, http.StatusNotFound, request("2"))
})
}
}
+8 -30
View File
@@ -61,15 +61,8 @@ func GetIssueDependencies(ctx *context.APIContext) {
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return
}
// 1. We must be able to read this issue
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return return
} }
@@ -79,7 +72,7 @@ func GetIssueDependencies(ctx *context.APIContext) {
blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems)) blockerIssues := make([]*issues_model.Issue, 0, min(listOptions.PageSize, setting.API.MaxResponseItems))
// 2. Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>` // Get the issues this issue depends on, i.e. the `<#b>`: `<issue> <- <#b>`
blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions) blockersInfo, total, err := issue.BlockedByDependencies(ctx, listOptions)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -177,7 +170,7 @@ func CreateIssueDependency(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
// We want to make <:index> depend on <Form>, i.e. <:index> is the target // We want to make <:index> depend on <Form>, i.e. <:index> is the target
target := getParamsIssue(ctx) target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -238,7 +231,7 @@ func RemoveIssueDependency(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
// We want to make <:index> depend on <Form>, i.e. <:index> is the target // We want to make <:index> depend on <Form>, i.e. <:index> is the target
target := getParamsIssue(ctx) target := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -303,16 +296,11 @@ func GetIssueBlocks(ctx *context.APIContext) {
// We need to list the issues that DEPEND on this issue not the other way round // We need to list the issues that DEPEND on this issue not the other way round
// Therefore whether dependencies are enabled or not in this repository is potentially irrelevant. // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant.
issue := getParamsIssue(ctx) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) {
ctx.APIErrorNotFound()
return
}
page := max(ctx.FormInt("page"), 1) page := max(ctx.FormInt("page"), 1)
limit := ctx.FormInt("limit") limit := ctx.FormInt("limit")
if limit <= 1 { if limit <= 1 {
@@ -399,7 +387,7 @@ func CreateIssueBlocking(ctx *context.APIContext) {
// "404": // "404":
// description: the issue does not exist // description: the issue does not exist
dependency := getParamsIssue(ctx) dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -456,7 +444,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
dependency := getParamsIssue(ctx) dependency := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -480,16 +468,6 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency))
} }
func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorAuto(err)
return nil
}
issue.Repo = ctx.Repo.Repository
return issue
}
func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue { func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue {
var repo *repo_model.Repository var repo *repo_model.Repository
if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name { if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name {
+23 -31
View File
@@ -10,6 +10,7 @@ import (
"reflect" "reflect"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/modules/container"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/web" "gitea.dev/modules/web"
"gitea.dev/services/context" "gitea.dev/services/context"
@@ -47,13 +48,8 @@ func ListIssueLabels(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -167,13 +163,8 @@ func DeleteIssueLabel(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -292,13 +283,8 @@ func ClearIssueLabels(ctx *context.APIContext) {
// "423": // "423":
// "$ref": "#/responses/repoArchivedError" // "$ref": "#/responses/repoArchivedError"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -316,14 +302,9 @@ func ClearIssueLabels(ctx *context.APIContext) {
} }
func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) { func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) { return nil, nil, errors.New("issue not found")
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil, nil, err
} }
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) { if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
@@ -359,12 +340,23 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
} }
labelIDs = append(labelIDs, repoLabelIDs...) labelIDs = append(labelIDs, repoLabelIDs...)
if ctx.Repo.Owner.IsOrganization() { if ctx.Repo.Owner.IsOrganization() {
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, labelNames) repoLabels, err := issues_model.GetLabelsByIDs(ctx, repoLabelIDs, "name")
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return nil, nil, err return nil, nil, err
} }
labelIDs = append(labelIDs, orgLabelIDs...) orgLabelNames := container.SetOf(labelNames...)
for _, label := range repoLabels {
orgLabelNames.Remove(label.Name)
}
if len(orgLabelNames) > 0 {
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, orgLabelNames.Values())
if err != nil {
ctx.APIErrorInternal(err)
return nil, nil, err
}
labelIDs = append(labelIDs, orgLabelIDs...)
}
} }
} }
+6 -8
View File
@@ -51,9 +51,8 @@ func LockIssue(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
reason := web.GetForm[*api.LockIssueOption](ctx).Reason reason := web.GetForm[*api.LockIssueOption](ctx).Reason
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -70,7 +69,7 @@ func LockIssue(ctx *context.APIContext) {
} }
issue.Repo = ctx.Repo.Repository issue.Repo = ctx.Repo.Repository
err = issues_model.LockIssue(ctx, opt) err := issues_model.LockIssue(ctx, opt)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -114,9 +113,8 @@ func UnlockIssue(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -132,7 +130,7 @@ func UnlockIssue(ctx *context.APIContext) {
} }
issue.Repo = ctx.Repo.Repository issue.Repo = ctx.Repo.Repository
err = issues_model.UnlockIssue(ctx, opt) err := issues_model.UnlockIssue(ctx, opt)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+9 -26
View File
@@ -41,20 +41,13 @@ func PinIssue(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else if issues_model.IsErrIssueMaxPinReached(err) {
ctx.APIError(http.StatusBadRequest, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
// If we don't do this, it will crash when trying to add the pin event to the comment history // If we don't do this, it will crash when trying to add the pin event to the comment history
err = issue.LoadRepo(ctx) err := issue.LoadRepo(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -98,18 +91,13 @@ func UnpinIssue(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
// If we don't do this, it will crash when trying to add the unpin event to the comment history // If we don't do this, it will crash when trying to add the unpin event to the comment history
err = issue.LoadRepo(ctx) err := issue.LoadRepo(ctx)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -159,17 +147,12 @@ func MoveIssuePin(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
err = issues_model.MovePin(ctx, issue, ctx.PathParamInt("position")) err := issues_model.MovePin(ctx, issue, ctx.PathParamInt("position"))
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+2 -7
View File
@@ -172,13 +172,8 @@ func DeleteIssueStopwatch(ctx *context.APIContext) {
} }
func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue { func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return nil return nil
} }
+6 -24
View File
@@ -104,14 +104,8 @@ func DelIssueSubscription(ctx *context.APIContext) {
} }
func setIssueSubscription(ctx *context.APIContext, watch bool) { func setIssueSubscription(ctx *context.APIContext, watch bool) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -185,14 +179,8 @@ func CheckIssueSubscription(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
@@ -251,14 +239,8 @@ func GetIssueSubscribers(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return return
} }
+11 -13
View File
@@ -75,9 +75,8 @@ func ListTrackedTimes(ctx *context.APIContext) {
ctx.APIErrorNotFound("timetracker is disabled") ctx.APIErrorNotFound("timetracker is disabled")
return return
} }
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -100,6 +99,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
opts.UserID = user.ID opts.UserID = user.ID
} }
var err error
if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err.Error()) ctx.APIError(http.StatusUnprocessableEntity, err.Error())
return return
@@ -200,9 +200,8 @@ func AddTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm[*api.AddTimeOption](ctx) form := web.GetForm[*api.AddTimeOption](ctx)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -219,6 +218,7 @@ func AddTime(ctx *context.APIContext) {
if form.User != "" { if form.User != "" {
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin { if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
// allow only RepoAdmin, Admin and User to add time // allow only RepoAdmin, Admin and User to add time
var err error
user, err = user_model.GetUserByName(ctx, form.User) user, err = user_model.GetUserByName(ctx, form.User)
if err != nil { if err != nil {
ctx.APIErrorAuto(err) ctx.APIErrorAuto(err)
@@ -280,9 +280,8 @@ func ResetIssueTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
@@ -295,7 +294,7 @@ func ResetIssueTime(ctx *context.APIContext) {
return return
} }
err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer) err := issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer)
if err != nil { if err != nil {
if db.IsErrNotExist(err) { if db.IsErrNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error()) ctx.APIError(http.StatusNotFound, err.Error())
@@ -349,9 +348,8 @@ func DeleteTime(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) issue := getRepoIssueByIndexForDoer(ctx, ctx.PathParamInt64("index"))
if err != nil { if issue == nil {
ctx.APIErrorAuto(err)
return return
} }
+4 -1
View File
@@ -219,7 +219,10 @@ func EditMilestone(ctx *context.APIContext) {
if form.Description != nil { if form.Description != nil {
milestone.Content = *form.Description milestone.Content = *form.Description
} }
milestone.DeadlineUnix, _ = common.ParseAPIDeadlineToEndOfDay(form.Deadline) if _, err := common.ApplyAPIDeadlineTimestamp(&milestone.DeadlineUnix, form.Deadline, nil); err != nil {
ctx.APIErrorAuto(err)
return
}
oldIsClosed := milestone.IsClosed oldIsClosed := milestone.IsClosed
if form.State != nil { if form.State != nil {
+32 -14
View File
@@ -266,6 +266,9 @@ func CreatePullReviewCommentReply(ctx *context.APIContext) {
ctx.APIError(http.StatusBadRequest, "comment is not a review comment") ctx.APIError(http.StatusBadRequest, "comment is not a review comment")
return return
} }
if isIssueLockedForDoer(ctx, parent.Issue) {
return
}
comment, err := pull_service.CreateCodeComment(ctx, comment, err := pull_service.CreateCodeComment(ctx,
ctx.Doer, ctx.Repo.GitRepo, parent.Issue, ctx.Doer, ctx.Repo.GitRepo, parent.Issue,
@@ -507,8 +510,12 @@ func CreatePullReview(ctx *context.APIContext) {
} }
// determine review type // determine review type
reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body) reviewType := preparePullReviewType(ctx, pr, opts.Event, opts.Body)
if isWrong { if reviewType == issues_model.ReviewTypeUnknown {
return
}
if isIssueLockedForDoer(ctx, pr.Issue) {
return return
} }
@@ -634,8 +641,12 @@ func SubmitPullReview(ctx *context.APIContext) {
} }
// determine review type // determine review type
reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body) reviewType := preparePullReviewType(ctx, pr, opts.Event, opts.Body)
if isWrong { if reviewType == issues_model.ReviewTypeUnknown {
return
}
if isIssueLockedForDoer(ctx, pr.Issue) {
return return
} }
@@ -671,11 +682,18 @@ func SubmitPullReview(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, apiReview) ctx.JSON(http.StatusOK, apiReview)
} }
// preparePullReviewType return ReviewType and false or nil and true if an error happen func isIssueLockedForDoer(ctx *context.APIContext, issue *issues_model.Issue) bool {
func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest, event api.ReviewStateType, body string) (issues_model.ReviewType, bool) { if issue_service.IsLockedForDoer(issue, ctx.Repo.Permission) {
ctx.APIError(http.StatusForbidden, "cannot comment on a locked issue")
return true
}
return false
}
func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest, event api.ReviewStateType, body string) issues_model.ReviewType {
if err := pr.LoadIssue(ctx); err != nil { if err := pr.LoadIssue(ctx); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return -1, true return issues_model.ReviewTypeUnknown
} }
switch event { switch event {
@@ -683,26 +701,26 @@ func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest
// can not approve your own PR // can not approve your own PR
if pr.Issue.IsPoster(ctx.Doer.ID) { if pr.Issue.IsPoster(ctx.Doer.ID) {
ctx.APIError(http.StatusUnprocessableEntity, "approve your own pull is not allowed") ctx.APIError(http.StatusUnprocessableEntity, "approve your own pull is not allowed")
return -1, true return issues_model.ReviewTypeUnknown
} }
return issues_model.ReviewTypeApprove, false return issues_model.ReviewTypeApprove
case api.ReviewStateRequestChanges: case api.ReviewStateRequestChanges:
// can not reject your own PR // can not reject your own PR
if pr.Issue.IsPoster(ctx.Doer.ID) { if pr.Issue.IsPoster(ctx.Doer.ID) {
ctx.APIError(http.StatusUnprocessableEntity, "reject your own pull is not allowed") ctx.APIError(http.StatusUnprocessableEntity, "reject your own pull is not allowed")
return -1, true return issues_model.ReviewTypeUnknown
} }
if strings.TrimSpace(body) == "" { if strings.TrimSpace(body) == "" {
ctx.APIError(http.StatusUnprocessableEntity, "review event REQUEST_CHANGES requires a body") ctx.APIError(http.StatusUnprocessableEntity, "review event REQUEST_CHANGES requires a body")
return -1, true return issues_model.ReviewTypeUnknown
} }
return issues_model.ReviewTypeReject, false return issues_model.ReviewTypeReject
case api.ReviewStateComment: case api.ReviewStateComment:
return issues_model.ReviewTypeComment, false return issues_model.ReviewTypeComment
default: default:
return issues_model.ReviewTypePending, false return issues_model.ReviewTypePending
} }
} }
+1 -12
View File
@@ -23,7 +23,6 @@ import (
unit_model "gitea.dev/models/unit" unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/label"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
@@ -255,17 +254,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre
ObjectFormatName: string(opt.ObjectFormatName), ObjectFormatName: string(opt.ObjectFormatName),
}) })
if err != nil { if err != nil {
if repo_model.IsErrRepoAlreadyExist(err) { ctx.APIErrorAuto(err)
ctx.APIError(http.StatusConflict, "The repository with the same name already exists.")
} else if db.IsErrNameReserved(err) ||
db.IsErrNamePatternNotAllowed(err) ||
label.IsErrTemplateLoad(err) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
+2 -9
View File
@@ -5,7 +5,6 @@
package user package user
import ( import (
"errors"
"net/http" "net/http"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
@@ -212,7 +211,6 @@ func CheckFollowing(ctx *context.APIContext) {
checkUserFollowing(ctx, ctx.ContextUser, target.ID) checkUserFollowing(ctx, ctx.ContextUser, target.ID)
} }
// Follow follow a user
func Follow(ctx *context.APIContext) { func Follow(ctx *context.APIContext) {
// swagger:operation PUT /user/following/{username} user userCurrentPutFollow // swagger:operation PUT /user/following/{username} user userCurrentPutFollow
// --- // ---
@@ -232,17 +230,12 @@ func Follow(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if err := user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser); err != nil { if err := user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser); err != nil {
if errors.Is(err, user_model.ErrBlockedUser) { ctx.APIErrorAuto(err)
ctx.APIError(http.StatusForbidden, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return return
} }
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }
// Unfollow unfollow a user
func Unfollow(ctx *context.APIContext) { func Unfollow(ctx *context.APIContext) {
// swagger:operation DELETE /user/following/{username} user userCurrentDeleteFollow // swagger:operation DELETE /user/following/{username} user userCurrentDeleteFollow
// --- // ---
@@ -260,7 +253,7 @@ func Unfollow(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorAuto(err)
return return
} }
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more